From 1513e88019069b4895a9e45634b7c6a8dd37a6e0 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Wed, 26 Aug 2026 05:08:18 -0400 Subject: [PATCH] feat: handle pasted uploads + option to specify destination + various fixes --- backend/api/assets.ts | 25 ++++-- backend/api/schemas/site.ts | 6 ++ backend/api/sites.ts | 13 ++- backend/helpers/common.ts | 39 +++++++++ backend/locales/en.json | 4 + backend/models/assets.ts | 29 +++++-- backend/models/sites.ts | 6 +- frontend/src/components/EditorMarkdown.vue | 82 ++++++++++++++---- frontend/src/components/PageHeader.vue | 85 +++++++++++++++++-- .../components/UploadPendingAssetsDialog.vue | 25 ++++-- frontend/src/helpers/assets.js | 31 +++++++ frontend/src/pages/AdminGeneral.vue | 22 ++++- frontend/src/stores/editor.js | 22 +++++ frontend/src/stores/site.js | 12 +++ 14 files changed, 356 insertions(+), 45 deletions(-) diff --git a/backend/api/assets.ts b/backend/api/assets.ts index b96546906..a0556136f 100644 --- a/backend/api/assets.ts +++ b/backend/api/assets.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' -import { decodeTreePath } from '../helpers/common.ts' +import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts' import { INLINE_EXTS } from '../models/assets.ts' const assetIdParam = { @@ -60,7 +60,7 @@ async function routes(app: FastifyInstance) { */ app.post<{ Params: { siteId: string } - Querystring: { fileName: string; folderId?: string; locale?: string } + Querystring: { fileName: string; folderId?: string; folderPath?: string; locale?: string } }>( '/sites/:siteId/assets', { @@ -94,7 +94,13 @@ async function routes(app: FastifyInstance) { folderId: { type: 'string', format: 'uuid', - description: 'The folder to upload into. The site root when absent.' + description: 'The folder to upload into. Wins over `folderPath`.' + }, + folderPath: { + type: 'string', + maxLength: 2048, + description: + 'Slash-separated path of the folder to upload into, created if it does not exist. The site root when both this and `folderId` are absent.' }, locale: { type: 'string', @@ -132,11 +138,19 @@ async function routes(app: FastifyInstance) { return reply.badRequest('No file was sent.') } + /* + Where this is going, as a path, which is what a rule addresses. An ID has to be looked up to + get one; a path is already one, and is normalized here rather than trusted -- the model would + happily create a folder called `..`. + */ const folder = req.query.folderId ? await WIKI.models.tree.getFolderById(req.query.folderId) : null - const folderPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : '' - const destination = folder ? [folderPath, folder.fileName].filter(Boolean).join('/') : '' + const folderPath = req.query.folderId ? null : normalizeFolderPath(req.query.folderPath) + const parentPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : '' + const destination = folder + ? [parentPath, folder.fileName].filter(Boolean).join('/') + : (folderPath ?? '') if ( !mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName }) ) { @@ -146,6 +160,7 @@ async function routes(app: FastifyInstance) { siteId: req.params.siteId, locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en', folderId: req.query.folderId, + folderPath, fileName: req.query.fileName, mimeType: req.headers['content-type'], data, diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index 6ced3e3e2..1c1f8f4a4 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -116,6 +116,12 @@ export async function registerSchemas(app: FastifyInstance): Promise { description: 'What an upload does about a file already at the name it wants: replace it in place, refuse the upload, or store the arrival as the next free `name-1.ext`.', enum: ['overwrite', 'reject', 'new'] + }, + pastedDestination: { + type: 'string', + maxLength: 2048, + description: + "Where a file pasted or dropped into the editor is filed when the page is saved. Empty is the page's own folder. A relative path is a folder under it — `assets` files them in `/assets`. A path starting with `/` is from the site root, so every page's pasted files land in the one place. Missing folders are created on the first upload. Normalized on save: doubled slashes and `.`/`..` segments go, and a leading slash is kept because it is what tells the two apart." } } }, diff --git a/backend/api/sites.ts b/backend/api/sites.ts index 69aca60f1..ac3e2a320 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -1,5 +1,5 @@ import { validate as uuidValidate } from 'uuid' -import { CustomError } from '../helpers/common.ts' +import { CustomError, normalizePastedDestination } from '../helpers/common.ts' import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts' import { siteAssetKinds } from '../models/sites.ts' import type { SiteAssetKind } from '../models/sites.ts' @@ -476,6 +476,17 @@ async function routes(app: FastifyInstance) { config.features.ratings = config.features.ratingsMode !== 'off' } + /* + The pasted-uploads destination is stored in one form, so that what the admin area reads back is + what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and + the editor should not have to know that. + */ + if (config.uploads?.pastedDestination !== undefined) { + config.uploads.pastedDestination = normalizePastedDestination( + config.uploads.pastedDestination + ) + } + // -> Update site try { await WIKI.models.sites.updateSite(req.params.siteId, { diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index e2c1d2aae..f88419124 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -102,6 +102,45 @@ export function normalizePagePath(input?: string | null): string { .toLowerCase() } +/** + * Reduce a folder path to the segments it actually names. + * + * Wrapping and doubled slashes go, and so do `.` and `..` segments — in a wiki tree those name a + * literal folder rather than a relative path, so a caller asking for `../etc` is asking for a folder + * called `etc` and never for somewhere outside the site. Which is what makes this safe to hand a path + * that came from a request. + * + * Case is left alone: `encodeTreePath` lowercases on the way into the database, so the lookup does not + * care, and the tree is what decides what a new folder ends up called. + */ +export function normalizeFolderPath(input?: string | null): string { + return (input ?? '') + .trim() + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..') + .join('/') +} + +/** + * Reduce the site's pasted-uploads destination to its stored form. + * + * `normalizeFolderPath` plus the one thing that setting carries which a plain folder path does not: a + * LEADING SLASH, which is what distinguishes a path from the site root from one relative to the page + * being edited. So it survives normalization, and `/` on its own stays `/` — the site root, which is a + * real answer and a different one from empty (the page's own folder). + */ +export function normalizePastedDestination(input?: string | null): string { + const raw = (input ?? '').trim() + if (!raw) { + return '' + } + const normalized = normalizeFolderPath(raw) + if (!raw.startsWith('/')) { + return normalized + } + return `/${normalized}` +} + /** * Drop a site's page extension from the end of a URL path. * diff --git a/backend/locales/en.json b/backend/locales/en.json index 2359c0df9..1d73be165 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -346,6 +346,9 @@ "admin.general.logoUploadSuccess": "Site logo uploaded successfully.", "admin.general.pageExtensions": "Page Extensions", "admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar. These extensions are reserved for pages: a file using one cannot be uploaded as an asset.", + "admin.general.pastedDestination": "Pasted Uploads Destination", + "admin.general.pastedDestinationHint": "Where files pasted or dropped into the editor are filed when the page is saved, creating folders as needed. Leave empty for the same folder as the page; a relative path (e.g. assets) is a folder under it, and a path starting with / is from the site root.", + "admin.general.pastedDestinationPlaceholder": "Same folder as the page", "admin.general.ratingsOff": "Off", "admin.general.ratingsStars": "Stars", "admin.general.ratingsThumbs": "Thumbs", @@ -1825,6 +1828,7 @@ "editor.pageRel.title": "Add Page Relation", "editor.pageRel.titleEdit": "Edit Page Relation", "editor.pageScripts.title": "Page Scripts", + "editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.", "editor.pendingAssetsUploading": "Uploading assets...", "editor.props.alias": "Alias", "editor.props.allowComments": "Allow Comments", diff --git a/backend/models/assets.ts b/backend/models/assets.ts index ee5c6b179..f338a35cd 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -286,7 +286,11 @@ class Assets { * `UploadConflictBehavior`. An overwrite returns the existing asset's ID, so a caller that means to * link to what it just uploaded must read the returned name and ID rather than assume its own. * - * @param folderId UUID of the folder to upload into. The site root when absent. + * @param folderId UUID of the folder to upload into. Takes precedence over `folderPath`. + * @param folderPath Slash-separated path of the folder to upload into, created if it does not exist. + * The site root when both are absent. A caller that knows a path and not an ID -- + * the editor uploading what was pasted into a page, which knows the page it is in + * -- addresses the folder this way rather than looking it up first. * @param fileName What to call it. Sanitized, so what comes back may differ from what went in. * @param data The file itself. */ @@ -294,6 +298,7 @@ class Assets { siteId, locale, folderId, + folderPath, fileName, mimeType, data, @@ -302,6 +307,7 @@ class Assets { siteId: string locale: string folderId?: string | null + folderPath?: string | null fileName: string mimeType?: string | null data: Buffer @@ -312,7 +318,14 @@ class Assets { throw new CustomError('assetInvalidFileName', 'This file name cannot be used.') } const fileExt = extensionOf(safeName) - await this.guardAgainstPageCollision({ siteId, locale, folderId, fileName: safeName, fileExt }) + await this.guardAgainstPageCollision({ + siteId, + locale, + folderId, + folderPath, + fileName: safeName, + fileExt + }) // -> The extension decides the type, not the request: the declared one is whatever the client felt // like sending, and this value is what gets served back to a browser later const resolvedMime = mime.getType(safeName) ?? mimeType ?? 'application/octet-stream' @@ -333,6 +346,7 @@ class Assets { siteId, locale, parentId: folderId, + parentPath: folderPath, fileName: safeName }) if (occupant) { @@ -373,6 +387,7 @@ class Assets { // that was actually free, which is not always the one asked for. const entry = await WIKI.models.tree.addAsset({ parentId: folderId, + parentPath: folderPath, fileName: safeName, title: safeName, locale, @@ -384,7 +399,9 @@ class Assets { } }) const storedName = entry.fileName - const folderPath = decodeTreePath(entry.folderPath ?? '') ?? '' + // -> Read off the row rather than from the request: the folder may have just been created, and a + // name that was taken took the next free one + const storedFolderPath = decodeTreePath(entry.folderPath ?? '') ?? '' try { // -> The metadata row goes in before the bytes, since the database target writes them into it @@ -405,7 +422,7 @@ class Assets { siteId, actorId: authorId, locale, - folderPath, + folderPath: storedFolderPath, fileName: storedName, kind, fileSize: data.length @@ -422,7 +439,7 @@ class Assets { WIKI.models.hooks.emit('asset:upload', { id: entry.id, fileName: storedName, - folderPath, + folderPath: storedFolderPath, siteId, authorId, metadata: { fileSize: data.length, mimeType: resolvedMime, kind } @@ -435,7 +452,7 @@ class Assets { kind, mimeType: resolvedMime, fileSize: data.length, - folderPath, + folderPath: storedFolderPath, locale, title: entry.title, hasPreview: Boolean(preview), diff --git a/backend/models/sites.ts b/backend/models/sites.ts index d75fd426e..3f8225c96 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -192,7 +192,8 @@ class Sites { } }, uploads: { - conflictBehavior: 'overwrite' + conflictBehavior: 'overwrite', + pastedDestination: '' }, storage: { largeThreshold: '25MB', @@ -455,7 +456,8 @@ class Sites { contentFont: 'roboto' }, uploads: { - conflictBehavior: 'overwrite' + conflictBehavior: 'overwrite', + pastedDestination: '' }, storage: { largeThreshold: '25MB', diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 67d79de9e..0b40047aa 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -1294,8 +1294,23 @@ function processContent(newContent) { * An image goes in as one, anything else as a link with its file name for text — a dropped PDF is a * link to a PDF, not a broken picture. The name is the image's alt text as well, which is both what the * handler this replaces did and better than nothing for a reader who cannot see it. + * + * Except while suggesting an edit, where files are refused outright. A pending asset is uploaded when + * the page is SAVED, and submitting a suggestion is not a save — nothing would ever send these, so the + * markdown would keep a `blob:` URL that dies with the tab. Nor is that only plumbing: somebody + * suggesting an edit is by definition somebody without write access to this page, and filing their + * files into the wiki beside it is not a decision this flow gets to make. Carrying an attachment on a + * suggestion is a feature, and until there is one, the refusal is said out loud — the paste has already + * been taken off the browser by the time this runs, so a silent return is a paste that vanished. */ function insertFilesAsAssets(files) { + if (editorStore.mode === 'suggest') { + notify({ + type: 'warning', + message: t('editor.pendingAssetsNotInSuggestions') + }) + return + } const markup = files.map((file) => { const blobUrl = editorStore.addPendingAsset(file) return `${file.type.startsWith('image/') ? '!' : ''}[${file.name}](${blobUrl})` @@ -1359,6 +1374,26 @@ function onEditorDrop(event) { insertFilesAsAssets([...event.dataTransfer.files]) } +/** + * The editor's model onto the page store: the source a save sends, and the render made from it. + * + * Debounced because it renders the whole document on every keystroke, and NAMED so that it can also be + * flushed — see `reloadEditorContent`, which needs it to have happened before it returns rather than + * half a second later. + */ +const syncContentToStore = debounce(() => { + editorStore.$patch({ + lastChangeTimestamp: Temporal.Now.instant() + }) + pageStore.$patch({ + content: editor.getValue(), + // -> What the author has typed IS the source, whatever the load did or did not deliver; see + // the guard in `pageSave` + contentLoaded: true + }) + processContent(pageStore.content) +}, 500) + /** * Rewrite text that was already in the editor — the blob URLs of pending assets, once the upload has * given them real paths. @@ -1380,6 +1415,15 @@ function reloadEditorContent({ replacements = [] } = {}) { } if (edits.length > 0) { editor.executeEdits('assets', edits) + /* + And the store follows the model NOW, rather than when the debounce would have got to it. + + This runs from `UploadPendingAssetsDialog`, immediately before the page is saved. Left to the + timer, the sync would land after that save -- so the page would go up with a render still full of + `blob:` URLs, and then be marked dirty half a second later by the very edit that fixed it, + needing a second save to publish. Flushing here is what makes one save enough. + */ + syncContentToStore.flush() } } @@ -1546,29 +1590,29 @@ onMounted(async () => { } }) + /* + Ctrl/Cmd+S, asking for the header's Save button rather than saving anything itself. What that + button does is the header's to know -- which of the three it currently is, whether the + reason-for-change dialog has to be answered first, and that it is disabled with nothing pending -- + so the shortcut goes through the event bus instead of reaching for `pageSave` and getting a + different save from the one on screen. + + A Monaco action rather than a listener because that is what stops the browser offering to save the + page as a file: Monaco takes the keystroke off the event once a keybinding resolves. It has been + registered here, doing nothing, for exactly that reason. + */ editor.addAction({ id: 'save', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], label: 'Save', precondition: '', - run(ed) {} + run(ed) { + EVENT_BUS.emit('savePage') + } }) // -> Handle content change - editor.onDidChangeModelContent( - debounce((ev) => { - editorStore.$patch({ - lastChangeTimestamp: Temporal.Now.instant() - }) - pageStore.$patch({ - content: editor.getValue(), - // -> What the author has typed IS the source, whatever the load did or did not deliver; see - // the guard in `pageSave` - contentLoaded: true - }) - processContent(pageStore.content) - }, 500) - ) + editor.onDidChangeModelContent(syncContentToStore) // -> Handle cursor movement editor.onDidChangeCursorPosition( @@ -1728,6 +1772,14 @@ onBeforeUnmount(() => { // -> Before the editor goes: the binding is holding the model, and leaving the room is what takes // this author's avatar out of everyone else's header stopCollabSession() + /* + Anything pasted but never uploaded goes with the session that held it. This hook is where an + editing session ends, whichever way it ended -- discarded, closed, submitted as a suggestion, or + walked away from by following a link -- because the editor is mounted exactly while + `editorStore.isActive` holds (see `pages/Index.vue`). A save has already emptied this by the time + it gets here: the upload runs before the page goes up, not after. + */ + editorStore.clearPendingAssets() if (editor) { editor.dispose() } diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index 0e445ede2..30e945574 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -328,7 +328,16 @@