Skip to content

Commit 7c2da1f

Browse files
committed
implement blob store based attachment upload for slate
- this works - still need to make it work for codemirror markdown editor - also need to implement anti-abuse measures
1 parent af6ffa6 commit 7c2da1f

File tree

8 files changed

+226
-116
lines changed

8 files changed

+226
-116
lines changed

src/packages/frontend/editors/slate/upload.tsx

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,10 @@
66
import { Transforms } from "slate";
77
import { SlateEditor } from "./editable-markdown";
88
import { useEffect, useMemo, useRef } from "react";
9-
import { Dropzone, FileUploadWrapper } from "@cocalc/frontend/file-upload";
10-
import { join } from "path";
11-
import { aux_file, path_split } from "@cocalc/util/misc";
12-
const AUX_FILE_EXT = "upload";
9+
import { Dropzone, BlobUpload } from "@cocalc/frontend/file-upload";
1310
import { getFocus } from "./format/commands";
1411
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
15-
16-
function uploadTarget(path: string, file: { name: string }): string {
17-
// path to our upload target, but relative to path.
18-
return join(path_split(aux_file(path, AUX_FILE_EXT)).tail, file.name);
19-
}
12+
import { BASE_URL } from "@cocalc/frontend/misc";
2013

2114
export default function useUpload(
2215
editor: SlateEditor,
@@ -69,22 +62,26 @@ export default function useUpload(
6962
sending: ({ name }) => {
7063
actionsRef.current?.set_status?.(`Uploading ${name}...`);
7164
},
72-
complete: (file: { type: string; name: string; status: string }) => {
65+
complete: (file) => {
7366
actionsRef.current?.set_status?.("");
67+
const { uuid } = JSON.parse(file.xhr.responseText);
68+
const url = `${BASE_URL}/blobs/${encodeURIComponent(
69+
file.upload.filename,
70+
)}?uuid=${uuid}`;
7471
let node;
75-
if (file.type.indexOf("image") == -1) {
72+
if (!file.type.includes("image")) {
7673
node = {
7774
type: "link",
7875
isInline: true,
7976
children: [{ text: file.name }],
80-
url: uploadTarget(pathRef.current, file),
77+
url,
8178
};
8279
} else {
8380
node = {
8481
type: "image",
8582
isInline: true,
8683
isVoid: true,
87-
src: uploadTarget(pathRef.current, file),
84+
src: url,
8885
children: [{ text: "" }],
8986
};
9087
}
@@ -96,16 +93,15 @@ export default function useUpload(
9693
}, []);
9794

9895
return (
99-
<FileUploadWrapper
96+
<BlobUpload
10097
className="smc-vfill"
10198
project_id={project_id}
102-
dest_path={aux_file(path, AUX_FILE_EXT)}
10399
event_handlers={updloadEventHandlers}
104100
style={{ height: "100%", width: "100%" }}
105101
dropzone_ref={dropzoneRef}
106102
show_upload={true}
107103
>
108104
{body}
109-
</FileUploadWrapper>
105+
</BlobUpload>
110106
);
111107
}

src/packages/frontend/file-upload.tsx

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
DropzoneComponent,
1111
DropzoneComponentHandlers,
1212
} from "react-dropzone-component";
13-
1413
import ReactDOMServer from "react-dom/server"; // for dropzone below
1514
import { encode_path, defaults, merge, is_array } from "@cocalc/util/misc";
1615
import {
@@ -26,6 +25,7 @@ import { Icon, Tip } from "@cocalc/frontend/components";
2625
import { join } from "path";
2726
import { useStudentProjectFunctionality } from "@cocalc/frontend/course";
2827
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
28+
import { Button } from "antd";
2929

3030
// 3GB upload limit -- since that's the default filesystem quota
3131
// and it should be plenty?
@@ -37,7 +37,8 @@ const TIMEOUT_S = 100;
3737

3838
const CLOSE_BUTTON_STYLE = {
3939
position: "absolute",
40-
right: 0,
40+
right: "15px",
41+
top: "5px",
4142
zIndex: 1, // so it floats above text/markdown buttons
4243
background: "white",
4344
cursor: "pointer",
@@ -78,15 +79,26 @@ const DROPSTYLE: React.CSSProperties = {
7879
margin: "10px 0",
7980
};
8081

81-
const Header = () => {
82+
const Header = ({ close_preview }: { close_preview?: Function }) => {
8283
return (
8384
<Tip
8485
icon="file"
8586
title="Drag and drop files"
8687
placement="bottom"
8788
tip="Drag and drop files from your computer into the box below to upload them into your project."
8889
>
89-
<h4 style={{ color: "#666" }}>Drag and drop files from your computer</h4>
90+
<h4 style={{ color: "#666", marginLeft: "10px" }}>
91+
Drag and drop files from your computer
92+
{close_preview && (
93+
<Button
94+
size="small"
95+
style={{ marginLeft: "30px" }}
96+
onClick={() => close_preview()}
97+
>
98+
Close
99+
</Button>
100+
)}
101+
</h4>
90102
</Tip>
91103
);
92104
};
@@ -131,14 +143,16 @@ export const FileUpload: React.FC<FileUploadProps> = (props) => {
131143

132144
function render_close_button() {
133145
return (
134-
<div className="close-button" style={CLOSE_BUTTON_STYLE}>
135-
<span
136-
onClick={props.close_button_onclick}
137-
className="close-button-x"
138-
style={{ cursor: "pointer", fontSize: "18px", color: "gray" }}
139-
>
140-
<Icon name={"times"} />
141-
</span>
146+
<div style={{ position: "relative" }}>
147+
<div className="close-button" style={CLOSE_BUTTON_STYLE}>
148+
<span
149+
onClick={props.close_button_onclick}
150+
className="close-button-x"
151+
style={{ cursor: "pointer", fontSize: "18px", color: "gray" }}
152+
>
153+
<Icon name={"times"} />
154+
</span>
155+
</div>
142156
</div>
143157
);
144158
}
@@ -319,23 +333,24 @@ export const FileUploadWrapper: React.FC<FileUploadWrapperProps> = (props) => {
319333

320334
return (
321335
<div style={style}>
322-
<div className="close-button" style={CLOSE_BUTTON_STYLE}>
323-
<span
324-
onClick={() => {
325-
close_preview();
326-
}}
327-
className="close-button-x"
328-
style={{
329-
cursor: "pointer",
330-
fontSize: "18px",
331-
color: "gray",
332-
marginRight: "20px",
333-
}}
334-
>
335-
<Icon name={"times"} />
336-
</span>
336+
<div style={{ position: "relative" }}>
337+
<div className="close-button" style={CLOSE_BUTTON_STYLE}>
338+
<span
339+
onClick={() => {
340+
close_preview();
341+
}}
342+
className="close-button-x"
343+
style={{
344+
cursor: "pointer",
345+
fontSize: "18px",
346+
color: "gray",
347+
}}
348+
>
349+
<Icon name={"times"} />
350+
</span>
351+
</div>
337352
</div>
338-
{<Header />}
353+
{<Header close_preview={close_preview} />}
339354
<div
340355
ref={preview_ref}
341356
className="filepicker dropzone"
@@ -513,3 +528,14 @@ export function UploadLink({
513528
</FileUploadWrapper>
514529
);
515530
}
531+
532+
export function BlobUpload(props) {
533+
const url = `${join(appBasePath, "blobs")}?project_id=${props.project_id}`;
534+
return (
535+
<FileUploadWrapper
536+
{...props}
537+
dest_path={""}
538+
config={{ url, ...props.config }}
539+
/>
540+
);
541+
}

src/packages/hub/blobs.coffee

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ winston = require('./logger').getLogger('blobs')
1010
misc_node = require('@cocalc/backend/misc_node')
1111
misc = require('@cocalc/util/misc')
1212
{defaults, required} = misc
13-
14-
MAX_BLOB_SIZE = 15000000
15-
MAX_BLOB_SIZE_HUMAN = "15MB"
13+
{MAX_BLOB_SIZE} = require('@cocalc/util/db-schema/blobs')
1614

1715
# save a blob in the blobstore database with given misc_node.uuidsha1 hash.
1816
exports.save_blob = (opts) ->
@@ -42,7 +40,7 @@ exports.save_blob = (opts) ->
4240
err = "save_blob: BUG -- error in call to save_blob; received a save_blob request without corresponding project_id"
4341

4442
else if opts.blob.length > MAX_BLOB_SIZE
45-
err = "save_blob: blobs are limited to #{MAX_BLOB_SIZE_HUMAN} and you just tried to save one of size #{opts.blob.length/1000000}MB"
43+
err = "save_blob: blobs are limited to #{misc.human_readable_size(MAX_BLOB_SIZE)} and you just tried to save one of size #{opts.blob.length/1000000}MB"
4644

4745
else if opts.check and opts.uuid != misc_node.uuidsha1(opts.blob)
4846
err = "save_blob: uuid=#{opts.uuid} must be derived from the Sha1 hash of blob, but it is not (possible malicious attack)"

src/packages/hub/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@passport-next/passport-google-oauth2": "^1.0.0",
2222
"@passport-next/passport-oauth2": "^2.1.4",
2323
"@types/express": "^4.17.13",
24+
"@types/formidable": "^3.4.5",
2425
"@types/primus": "^7.3.6",
2526
"@types/react": "^18.0.26",
2627
"@types/uuid": "^8.3.1",
@@ -37,6 +38,7 @@
3738
"debug": "^4.3.2",
3839
"escape-html": "^1.0.3",
3940
"express": "^4.19.2",
41+
"formidable": "^3.5.1",
4042
"http-proxy": "^1.18.1",
4143
"immutable": "^4.3.0",
4244
"jquery": "^3.6.0",

src/packages/hub/servers/app/blob-upload.ts

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,59 +2,105 @@
22
Support user uploading a blob directly from their browser to the CoCalc database,
33
mainly for markdown documents. This is meant to be very similar to how GitHub
44
allows for attaching files to github issue comments.
5-
6-
75
*/
86

9-
// Note that github has a 10MB limit -- https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files
10-
const MAX_BLOB_SIZE_MB = 10;
11-
12-
// some throttling -- note that after a bit, most blobs end up longterm
13-
// cloud storage and are never accessed.
14-
// this is just a limit to prevent abuse, and it's done on a per-hub
15-
// basis using an in-memory data structure.
16-
const MAX_BLOBS_SIZE_MB_PER_USER_PER_DAY = 250;
7+
// See also src/packages/project/upload.ts
178

189
import { Router } from "express";
19-
import { database } from "../database";
20-
import { is_valid_uuid_string } from "@cocalc/util/misc";
21-
import { database_is_working } from "@cocalc/hub/hub_register";
2210
import { callback2 } from "@cocalc/util/async-utils";
11+
import { database } from "../database";
12+
const { save_blob } = require("@cocalc/hub/blobs");
2313
import { getLogger } from "@cocalc/hub/logger";
14+
import {
15+
MAX_BLOB_SIZE,
16+
//MAX_BLOB_SIZE_PER_PROJECT_PER_DAY,
17+
} from "@cocalc/util/db-schema/blobs";
18+
import getAccount from "@cocalc/server/auth/get-account";
19+
import isCollaborator from "@cocalc/server/projects/is-collaborator";
20+
import formidable from "formidable";
21+
import { readFile, unlink } from "fs/promises";
22+
import { uuidsha1 } from "@cocalc/backend/misc_node";
23+
24+
const logger = getLogger("hub:servers:app:blob-upload");
25+
function dbg(...args): void {
26+
logger.debug("upload ", ...args);
27+
}
2428

25-
const logger = getLogger("hub:servers:app:blobs");
2629
export default function init(router: Router) {
27-
// return uuid-indexed blobs (mainly used for graphics)
2830
router.post("/blobs", async (req, res) => {
29-
logger.debug(`${JSON.stringify(req.query)}, ${req.path}`);
30-
const uuid = `${req.query.uuid}`;
31-
if (!is_valid_uuid_string(uuid)) {
32-
res.status(404).send(`invalid uuid=${uuid}`);
31+
const account_id = await getAccount(req);
32+
if (!account_id) {
33+
res.status(500).send("user must be signed in to upload files");
3334
return;
3435
}
35-
if (!database_is_working()) {
36-
res.status(404).send("can't get blob -- not connected to database");
36+
const { project_id, ttl } = req.query;
37+
if (!project_id || typeof project_id != "string") {
38+
res.status(500).send("project_id must be specified");
39+
return;
40+
}
41+
if (!(await isCollaborator({ account_id, project_id }))) {
42+
res.status(500).send("user must be collaborator on project");
3743
return;
3844
}
3945

46+
dbg({ account_id, project_id });
47+
48+
// TODO: check for throttling/limits
4049
try {
41-
const data = await callback2(database.get_blob, { uuid });
42-
if (data == null) {
43-
res.status(404).send(`blob ${uuid} not found`);
44-
} else {
45-
const filename = req.path.slice(req.path.lastIndexOf("/") + 1);
46-
if (req.query.download != null) {
47-
// tell browser to download the link as a file instead
48-
// of displaying it in browser
49-
res.attachment(filename);
50-
} else {
51-
res.type(filename);
50+
const form = formidable({
51+
keepExtensions: true,
52+
maxFileSize: MAX_BLOB_SIZE,
53+
hashAlgorithm: "sha1",
54+
});
55+
56+
dbg("parsing form data...");
57+
// https://github.com/node-formidable/formidable?tab=readme-ov-file#parserequest-callback
58+
const [_, files] = await form.parse(req);
59+
//dbg(`finished parsing form data. ${JSON.stringify({ fields, files })}`);
60+
61+
/* Just for the sake of understanding this, this is how this looks like in the real world (formidable@3):
62+
> files.file[0]
63+
{
64+
size: 80789,
65+
filepath: '/home/hsy/p/cocalc/src/data/projects/c8787b71-a85f-437b-9d1b-29833c3a199e/asdf/asdf/8e3e4367333e45275a8d1aa03.png',
66+
newFilename: '8e3e4367333e45275a8d1aa03.png',
67+
mimetype: 'application/octet-stream',
68+
mtime: '2024-04-23T09:25:53.197Z',
69+
originalFilename: 'Screenshot from 2024-04-23 09-20-40.png'
70+
}
71+
*/
72+
let uuid: string | undefined = undefined;
73+
if (files.file?.[0] != null) {
74+
const { filepath, hash } = files.file[0];
75+
try {
76+
dbg("got", files);
77+
if (typeof hash == "string") {
78+
uuid = uuidsha1("", hash);
79+
}
80+
const blob = await readFile(filepath);
81+
await callback2(save_blob, {
82+
uuid,
83+
blob,
84+
ttl,
85+
project_id,
86+
database,
87+
});
88+
} finally {
89+
try {
90+
await unlink(filepath);
91+
} catch (err) {
92+
dbg("WARNING -- failed to delete uploaded file", err);
93+
}
5294
}
53-
res.send(data);
5495
}
96+
if (!uuid) {
97+
res.status(500).send("no file got uploaded");
98+
return;
99+
}
100+
res.send({ uuid });
55101
} catch (err) {
56-
logger.error(`internal error ${err} getting blob ${uuid}`);
57-
res.status(500).send(`internal error: ${err}`);
102+
dbg("upload failed ", err);
103+
res.status(500).send(`upload failed -- ${err}`);
58104
}
59105
});
60106
}

0 commit comments

Comments
 (0)