Map Image Share Prototype

Created this map image sharing prototype source here using various technologies

Components used

  • Nodejs (Hapijs framework): To handle the uploads and reading of data
  • DataStore (AirTable spreadsheet): Picked this option because AirTable has numerous options with images, graphics, and sharing
  • Map (Mapbox)
  • Digital Ocean file storage (S3 compatible library): This is to do the temporary upload of the image file location that AirTable requires a link to. Using presigned expiring links and code has full compatibility with S3

General approach

  • Create the AirTable with appropriate columns, sharing and the API key
  • Setup Digital Ocean spaces to work with the temporary image files for linking into AirTable
  • Merge all this processing code together with Nodejs (Hapijs framework) running on a boosted glitch instance. This all is to create a testable API that runs OpenAPI for validating methods prior to work on the map interface.
  • Adjust some of the Mapbox code for clicks, clustering, and preview and upload rendering

Image upload details

  • Create the Document Object Model element as an img tag with the source content being the file encoded as base64 image
  • Upload this received content from hapijs to S3 and generate a presigned link to send to AirTable

Relevant code

const s3 = require("@aws-sdk/client-s3");
const s3Link = require("@aws-sdk/s3-request-presigner");
const crypto = require("crypto");

const s3Client = new s3.S3Client({
  endpoint: process.env.spaces_endpoint,
  forcePathStyle: false,
  region: process.env.spaces_region,
  credentials: {
    accessKeyId: process.env.spaces_access_id,
    secretAccessKey: process.env.spaces_secret,
  },
});

function getUploadParameters(inputData) {
  const base64Data = new Buffer.from(
    inputData.replace(/^data:image\/\w+;base64,/, ""),
    "base64"
  );
  const type = inputData.split(";")[0].split("/")[1];
  const params = {
    Bucket: process.env.spaces_bucket,
    Key: `${crypto.randomUUID()}.${type}`,
    Body: base64Data,
    ACL: "private",
    ContentEncoding: "base64",
    ContentType: `image/${type}`,
    Metadata: {
      "x-amz-meta-my-key": "Upload File",
    },
  };
  return params;
}

module.exports = {
  uploadImage: async function (fileData) {
    const params = getUploadParameters(fileData);
    const cmd = new s3.PutObjectCommand(params);
    const data = await s3Client.send(cmd);
    console.log(`Successfully uploaded object: ${params.Bucket}/${params.Key}`);
    const getObjectParams = {
      Bucket: params.Bucket,
      Key: params.Key,
    };
    const command = new s3.GetObjectCommand(getObjectParams);
    const shareUrl = await s3Link.getSignedUrl(s3Client, command, {
      expiresIn: process.env.spaces_expires_in,
    });
    console.log(shareUrl);
    return shareUrl;
  },
};

and the hapijs intermediate handler code

const Joi = require("joi");
const appHelper = require("../src/helpers");
const uploadHelper = require("../src/s3uploader");

module.exports = [
  {
    method: "POST",
    path: "/upload",
    options: {
      description: "Upload a file",
      notes: "Uploads a file",
      tags: ["api", "Upload"],
      validate: {
        failAction: async (request, h, err) => {
          // During development, log and respond with the full error.
          console.log(err);
          throw err;
        },
        payload: Joi.object({
          fileData: Joi.string()
            .required()
            .default(
              "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAD0lEQVR42mNkwAIYh7IgAAVVAAuInjI5AAAAAElFTkSuQmCC"
            )
            .description("Base 64 Image bytes"),
        }),
      },
    },
    handler: async (request, h) => {
      const input = request.payload;
      return await appHelper.GeneralErrorHandlerFn(async () => {
        return {
          shareUrl: await uploadHelper.uploadImage(input.fileData),
        };
      });
    },
  },
];

relevant upload handling code in the html map page

  // Because the FileReader looks for an onload event async it is problematic
  const toBase64 = (file) => new Promise((resolve, reject) => {
	const reader = new FileReader();
	reader.readAsDataURL(file);
	reader.onload = () => resolve(reader.result);
	reader.onerror = reject;
  });

  document
	.getElementById("savePhoto")
	.addEventListener("click", async (e) => {
	  const currentText = document.getElementById("photoText").value || "";
	  if (currentText == "") {
		alert("Photo description required");
		return;
	  }

	  const position = photoMarker.getLngLat() || null;
	  if (position == null) {
		alert(
		  "You must place a marker that corresponds to the photo location"
		);
		return;
	  }

	  const base64Data = document.getElementById(currentFiles[0].name).src;
	  const photoData = { fileData: base64Data };
	  const savePhotoUrl = `${window.location.href}upload`;
	  const savePhotoResponse = await fetch(savePhotoUrl, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify(photoData),
	  });
	  const shareablePhoto = await savePhotoResponse.json();
	  const shareUrl = shareablePhoto.shareUrl || "";
	  if (shareUrl == "") {
		return;
	  }
	  const newRecord = {
		photo: shareUrl,
		publicText: currentText,
		latitude: position.lat.toFixed(6),
		longitude: position.lng.toFixed(6),
	  };
	  const saveNewRecord = await fetch(
		`${window.location.href}locations`,
		{
		  method: "POST",
		  headers: { "Content-Type": "application/json" },
		  body: JSON.stringify(newRecord),
		}
	  );
	  const saveNewRecordResponse = await saveNewRecord.json();
	  console.log(saveNewRecordResponse);
	  location.reload();
	});
Mapping Images
MapBox, HERE Search, AirTable