Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Image Uploads #287

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
714c73e
Add library multiparty to parse form data.
Nov 18, 2023
b166eb8
Use BoardDataList instead of plain object
Nov 18, 2023
8fa13cb
Return public interface for Sockets object
Nov 18, 2023
073ff19
Image asset uploads and updates.
Nov 18, 2023
b23bf11
Validate image size before allowing upload.
Nov 18, 2023
7abbda4
Get MIME type before returning image assets.
Nov 18, 2023
ff2768f
Remove old TODO
Nov 18, 2023
f278e68
Use XHR to upload images. Allows access to progress.
Nov 18, 2023
6b847cf
Move all image upload stuff to image tool
Nov 18, 2023
bf67be1
Use image tool to initiate upload
Nov 18, 2023
15781ee
Delete images if they are no longer in use.
Nov 18, 2023
f1cd94d
Drop onto board, not canvas.
Nov 18, 2023
daed5e3
Remove unnecessary console.log
Nov 18, 2023
962bb22
Use cell cursor for image tool.
Nov 18, 2023
9bf0e3f
Disable image upload if Image tool is in block list
Nov 18, 2023
5d82a78
Remove tmp file after image upload.
Nov 18, 2023
d09411b
Don't block main handler fn when serving board image assets.
Nov 18, 2023
84ff1e1
Validate image uploads on client and server.
Nov 18, 2023
1a815a6
Add log for when unused images are purged
Nov 18, 2023
c321964
Ensure that board image asset URLs always point to current domain.
Nov 19, 2023
1e3708e
Use fs.promises to read board image assets.
Nov 19, 2023
7fe1fe3
Use board name and asset ID to build image href
Nov 20, 2023
b0e47a1
Fix image purge.
Nov 20, 2023
231d3fd
Attach click and drag/drop handlers to canvas, not drawingArea
Nov 20, 2023
dfd96b1
Add tests for image upload tool.
Nov 20, 2023
05d0ea2
Merge branch 'master' into master
atomdmac Nov 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client-data/board.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
<script src="../tools/grid/grid.js"></script>
<script src="../tools/download/download.js"></script>
<script src="../tools/zoom/zoom.js"></script>
<script src="../tools/image/image.js"></script>
{{#moderator}}<script src="../tools/clear/clear.js"></script>{{/moderator}}
<script src="../js/canvascolor.js"></script>
</body>
Expand Down
39 changes: 39 additions & 0 deletions client-data/tools/image/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
276 changes: 276 additions & 0 deletions client-data/tools/image/image.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@

/**
* WHITEBOPHIR
*********************************************************
* @licstart The following is the entire license notice for the
* JavaScript code in this page.
*
* Copyright (C) 2013 Ophir LOJKINE
*
*
* The JavaScript code in this page is free software: you can
* redistribute it and/or modify it under the terms of the GNU
* General Public License (GNU GPL) as published by the Free Software
* Foundation, either version 3 of the License, or (at your option)
* any later version. The code is distributed WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
*
* As additional permission under GNU GPL version 3 section 7, you
* may distribute non-source (e.g., minimized or compacted) forms of
* that code without the copy of the GNU GPL normally required by
* section 4, provided you include this license notice and a URL
* through which recipients can access the Corresponding Source.
*
* @licend
*/

(function () { //Code isolation
const drawingArea = document.getElementById('board');
const canvas = document.getElementById('canvas');
const newImageDropPoint = {
x: 0,
y: 0,
};

const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';

function onFileInputChange(event) {
uploadImage(event.target.files[0], {
x: newImageDropPoint.x,
y: newImageDropPoint.y,
});
}

function promptForImage(x, y, event) {
// Get the position of the click on the canvas so when the user uploads
// an image, we can draw it at the same position.
newImageDropPoint.x = x;
newImageDropPoint.y = y;
fileInput.click();
}

function draw(data) {
Tools.drawingEvent = true;
switch (data.type) {
case "image":
createImageElement(data);
break;
case "update":
var image = svg.getElementById(data['id']);
if (!image) {
console.error("Image: No image provided!", data['id']);
}
updateImageElement(image, data);
break;
default:
console.error("Image: Draw instruction with unknown type. ", data);
break;
}
}

var svg = Tools.svg;
/**
* Creates a new image element on the canvas, or updates an existing image
* with new information.
* @param {Object} data - The data to use to create the image.
*/
function createImageElement(data) {
var img = svg.getElementById(data.id) || Tools.createSVGElement("image");
img.setAttribute("id", data.id);
img.setAttribute("href", data.src);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big security vulnerability, isn't it ?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyone can send an image event with the src attribute they want

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the concern that a user of Board A would be able to load images from Board B (which may be a "private" board)?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bigger concern is that they may have all users make requests to their own server

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah OK - so the concern is that an attacker could inject a URL that points to a server that they control, and that serves files with malicious content. Is that right?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they could serve whatever they want, but also log the ip addresses of everyone connected

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this has been addressed with the latest commit 👍 Let me know if you disagree.

img.setAttribute("x", data.x);
img.setAttribute("y", data.y);

updateImageElement(img, data);
Tools.drawingArea.appendChild(img);
return img;
}

/**
* Updates the image element with new data.
*/
function updateImageElement(imageElement, data) {
imageElement.x.baseVal.value = Math.min(data['x2'], data['x']);
imageElement.y.baseVal.value = Math.min(data['y2'], data['y']);
imageElement.width.baseVal.value = Math.abs(data['x2'] - data['x']);
imageElement.height.baseVal.value = Math.abs(data['y2'] - data['y']);
}

/**
* Get the name of the current board based on the current URL.
* @returns {string} - The name of the current board.
*/
function getCurrentBoardName() {
let boardName = window.location.pathname.split('/');
boardName = boardName[boardName.length - 1];
boardName = boardName.split('#').shift();
return boardName;
}

/**
* Loads the image from the filesystem to generate a preview while uploading
* occurs as well as to get the dimensions of the image.
* @param {File} image - The image to preview.
* @returns {Promise} - A promise that resolves with the image preview.
*/
async function previewImage(image) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
const img = new Image();
img.src = event.target.result;
img.onload = function () {
resolve(img);
}
img.onerror = function (error) {
reject(error);
}
}
reader.readAsDataURL(image);
});
}

/**
* Uploads an image to the server, draws it on the canvas optimistically.
* @param {File} image - The image to upload.
* @param {Object} position - The position to draw the image on the canvas.
* @param {number} position.x - The x coordinate of the image.
* @param {number} position.y - The y coordinate of the image.
* @returns {Promise} - A promise that resolves when the image has been uploaded.
*/
async function uploadImage(image, position) {
const id = Tools.generateUID();
const ImageTool = Tools.list["Image"];

// Get a preview of the image
const previewElement = await previewImage(image);

const dimensions = {
x: previewElement.width,
y: previewElement.height
};

// Optimistically draw the image on the canvas before uploading.
ImageTool.draw({
id,
type: "image",
src: previewElement.src,
opacity: 0.5,
x: position.x,
y: position.y,
x2: position.x + dimensions.x,
y2: position.y + dimensions.y,
});

// Upload the image to the server
const formData = new FormData();
formData.append('image', image);
formData.append('id', id);
formData.append('position', JSON.stringify(position));
formData.append('dimensions', JSON.stringify(dimensions));

return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();

function onError(error) {
alert('Failed to upload image :`(')
console.log('error: ', error);
reject(error);
}

function onProgress(event) {
// TODO: Show a loading indicator while the image is uploading.
console.log('progress: ', event);
}

function onLoad(response) {
if (xhr.status >= 400) {
alert('Failed to upload image :`(')
reject(response);
console.log('onLoad: ', response);
}
resolve(response);
}

xhr.open('POST', `/image-upload/${getCurrentBoardName()}`, true);
xhr.onerror = onError;
xhr.onprogress = onProgress;
xhr.onload = onLoad;
xhr.send(formData);
});
}

/**
* Handles the drop event on the canvas.
* @param {Event} event - The drop event.
*/
function onUploadEvent(event) {
const scale = Tools.getScale();
const position = {
x: event.clientX / scale,
y: event.clientY / scale
};
uploadImage(event.dataTransfer.files[0], position);
}

function onDrop(event) {
onUploadEvent(event);
}

/**
* Called when the tool is selected.
*/
function onStart() {
fileInput.addEventListener('change', onFileInputChange);
}

/**
* Called when the tool is deselected.
*/
function onQuit() {
fileInput.removeEventListener('change', onFileInputChange);
}

// List of all drag/drop events.
const events = [
"drag",
"dragend",
"dragenter",
"dragleave",
"dragover",
"dragstart",
"drop"
];

// Ignore all default handling of drag/drop events on the canvas.
function preventDefault(e) {
e.preventDefault();
e.stopPropagation();
}

events.forEach((eventName) => {
drawingArea.addEventListener(eventName, preventDefault, false);
});

drawingArea.addEventListener("drop", onDrop, false);

var imageTool = {
"name": "Image",
"shortcut": "i",
"listeners": {
"press": promptForImage,
},
"onstart": onStart,
"onquit": onQuit,
"secondary": null,
"draw": draw,
"mouseCursor": "crosshair",
"icon": "tools/image/icon.svg",
"stylesheet": ""
};
Tools.add(imageTool);

})(); //End of code isolation
Loading
Loading