diff --git a/docs/openapi.yaml b/docs/openapi.yaml index fe2f645..97056a7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1396,6 +1396,45 @@ paths: description: The environment variables that are not set. "403": { $ref: "#/components/responses/Forbidden" } + /api/v1/notes/{noteID}/attachments: + post: + tags: [notes] + summary: Put a file into a note. + description: | + A picture pasted or dropped into a note. Until this existed the import + could bring files in and nothing else could, so the most ordinary act in + a notes app — paste a screenshot — did nothing at all. + + Editor rights on the note. The same answer for "no such note" and "not + yours", so an id cannot be probed. + + The response carries the attachment's URL; the editor writes that into the + note's text as a Markdown image. The file is never inlined as a data URL — + a photograph as base64 would make the note itself megabytes of text. + parameters: + - name: noteID + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + "201": + description: Stored, and hung on the note. + "404": + description: No such note, or not yours to write in. + "413": + description: Larger than 25 MB. + /api/v1/beacon: post: tags: [auth] diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index ec9f42d..0615784 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -6,7 +6,9 @@ import ( "io" "mime" "net/http" + "os" "path" + "path/filepath" "sort" "strings" "time" @@ -579,3 +581,68 @@ func mimeOf(name string) string { } return "application/octet-stream" } + +// handleUploadNoteAttachment takes a file into a note. +// +// The import could already do this, and nothing else could: a note brought in +// from Apple Notes arrived with its pictures, and a note written here could not +// be given one. Pasting a screenshot into a note is the most ordinary thing there +// is, and it did nothing at all. +// +// Editor rights on the note, checked the same way every other write to it is. The +// answer for "no such note" and "not yours" is the same, so an id cannot be +// probed by watching which one comes back. +func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) { + n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID")) + if err != nil { + s.internal(w, r, "get note", err) + return + } + if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) { + writeError(w, http.StatusNotFound, CodeNotFound, "no such note") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20)) + if err := r.ParseMultipartForm(8 << 20); err != nil { + writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, + "that file is too large (25 MB maximum)") + return + } + defer r.MultipartForm.RemoveAll() + + file, header, err := r.FormFile("file") + if err != nil { + writeFieldErrors(w, map[string]string{"file": "required"}) + return + } + defer file.Close() + + if header.Size > maxUploadBytes { + writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, + "that file is too large (25 MB maximum)") + return + } + + stored, size, err := s.storeUpload(file) + if err != nil { + s.internal(w, r, "store upload", err) + return + } + + user := userFrom(r.Context()) + a := &store.Attachment{ + NoteID: n.ID, + Filename: safeFilename(header.Filename), + MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")), + Size: size, + Path: stored, + UploadedBy: user.ID, + } + if err := s.db.CreateAttachment(r.Context(), a); err != nil { + os.Remove(filepath.Join(s.cfg.FilesDir(), stored)) + s.internal(w, r, "record attachment", err) + return + } + writeJSON(w, http.StatusCreated, toAttachmentJSON(*a)) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7d36520..5c00c29 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -301,6 +301,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/{noteID}", s.handleGetNote) r.Patch("/{noteID}", s.handleUpdateNote) r.Delete("/{noteID}", s.handleDeleteNote) + // A picture pasted or dropped into a note. The import could bring + // files in and nothing else could, which made the most ordinary act + // in a notes app — paste a screenshot — do nothing at all. + r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment) }) r.Route("/mailboxes", func(r chi.Router) { diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 56b566b..2dd4b5c 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2434,3 +2434,68 @@ test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async // andet skal stadig være stille. expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]); }); + +test('et billede kan indsættes og trækkes ind i en note', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/noter'); + + const id = await page.evaluate(async () => { + const r = await fetch('/api/v1/notes', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, + body: JSON.stringify({ body: '# Med billeder\n\nEn linje.\n' }) + }); + return (await r.json()).id; + }); + await page.goto('/noter?note=' + id); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + await expect(ed).toBeVisible(); + + // Et rigtigt lille PNG, bygget i browseren, så prøven ikke har brug for en fil + // på disken — og lagt i både en paste og et drop, som er de to veje ind. + const png = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + + for (const kind of ['paste', 'drop']) { + await ed.click(); + await page.evaluate( + async ({ kind, png }) => { + const bytes = Uint8Array.from(atob(png), (c) => c.charCodeAt(0)); + const file = new File([bytes], `${kind}.png`, { type: 'image/png' }); + const dt = new DataTransfer(); + dt.items.add(file); + const ed = document.querySelector('.page[contenteditable]'); + ed.focus(); + ed.dispatchEvent( + kind === 'paste' + ? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }) + : new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }) + ); + }, + { kind, png } + ); + await page.waitForTimeout(1200); + } + + // To billeder, begge hentet og tegnet — ikke to stykker tekst med ![]( i. + await expect(ed.locator('img')).toHaveCount(2); + const loaded = await ed.locator('img').evaluateAll((els) => + els.filter((e) => e.complete && e.naturalWidth > 0).length + ); + expect(loaded).toBe(2); + + // Og de overlever turen til Markdown: filen skal pege på et bilag, ikke på en + // data-URL, som ville lægge fotografiet i noteteksten. + const body = await page.evaluate(async (noteId) => { + const r = await fetch('/api/v1/notes/' + noteId, { + credentials: 'include', + headers: { 'Sec-Fetch-Site': 'same-origin' } + }); + return (await r.json()).body; + }, id); + expect(body.match(/!\[\]\(\/api\/v1\/attachments\/[0-9a-f-]+\)/g)).toHaveLength(2); + expect(body).not.toContain('data:image'); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index ede95f9..96f988e 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -337,6 +337,7 @@ export const api = { deleteComment: (id) => del(`/comments/${id}`), uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file), + uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file), deleteAttachment: (id) => del(`/attachments/${id}`), attachmentURL: (id) => `/api/v1/attachments/${id}`, diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index 9a58860..5278e8d 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -17,6 +17,7 @@ import { highlight, guessLanguage } from '$lib/highlight.js'; import { t } from '$lib/i18n.svelte.js'; import { app } from '$lib/stores.svelte.js'; + import { api, humanMessage } from '$lib/api.js'; import { colorVar } from '$lib/colors.js'; let { note, onchange, onsave } = $props(); @@ -399,12 +400,92 @@ // Pasted text arrives as whatever the source was — a web page brings its fonts, // its colours and its layout with it. Only the words are wanted. + /** + * Filer ind i noten — indsat eller trukket. + * + * Importen kunne tage billeder med, og intet andet kunne. At indsætte et + * skærmbillede i en note er det mest almindelige, der findes i et noteprogram, + * og det gjorde ingenting overhovedet: indsætningen tog kun `text/plain` og + * smed resten væk, og der var slet ingen håndtering af et træk. + * + * Filen lægges op og skrives ind som `![](…)`. Ikke som en data-URL: en note + * er Markdown i en database, og et fotografi fra en telefon som base64 midt i + * teksten ville gøre noten til fire megabyte tekst, som skal læses hver gang + * listen tegnes. + * + * En note, der ikke er gemt endnu, kan ikke bære en fil — den har intet id at + * hænge den på. Derfor gemmes den først, og det siges ikke til nogen, fordi + * det er en teknisk detalje og ikke noget, brugeren har bedt om at vide. + */ + async function insertFiles(files) { + const list = [...files].filter((f) => f && f.size > 0); + if (!list.length || !note?.id) return false; + + uploading = true; + try { + for (const file of list) { + const saved = await api.uploadNoteFile(note.id, file); + // execCommand frem for at bygge noden selv: det er det, der lægger + // handlingen i browserens egen fortryd-historik, så ⌘Z tager billedet + // ud igen ligesom det tager et ord ud. + editor?.focus(); + document.execCommand('insertHTML', false, + ``); + } + changed(); + onsave?.(); + return true; + } catch (e) { + app.toast(humanMessage(e)); + return false; + } finally { + uploading = false; + } + } + + let uploading = $state(false); + function onpaste(event) { + // Filer først. Et skærmbillede i udklipsholderen kommer med som en fil og + // som ingenting andet, så hvis der er en, er det den, der skal ind. + const files = event.clipboardData?.files ?? []; + if (files.length) { + event.preventDefault(); + insertFiles(files); + return; + } + event.preventDefault(); const text = event.clipboardData?.getData('text/plain') ?? ''; document.execCommand('insertText', false, text); changed(); } + + /** + * Trukket ind udefra. + * + * `preventDefault` på begge: uden den på dragover kommer der ingen drop, og + * uden den på drop åbner browseren filen i stedet — man mister det, man var i + * gang med at skrive, til en fane med et billede i. + * + * Kun filer. Et træk inde fra appen selv — en opgave, en note — bærer vores + * egen nyttelast og skal falde igennem til den, der lyttede efter den. + */ + function ondragover(event) { + if (!event.dataTransfer?.types?.includes('Files')) return; + event.preventDefault(); + dropping = true; + } + + function ondrop(event) { + const files = event.dataTransfer?.files ?? []; + dropping = false; + if (!files.length) return; + event.preventDefault(); + insertFiles(files); + } + + let dropping = $state(false);
@@ -508,6 +589,10 @@ onmouseup={readState} {onkeydown} {onpaste} + {ondragover} + {ondrop} + ondragleave={() => (dropping = false)} + class:dropping >
{#if suggestions.length} @@ -737,6 +822,14 @@ display: none; } + /* Mens en fil holdes over noten. En ramme frem for en farvet flade: teksten + under skal stadig kunne læses, så man kan se hvor billedet lander. */ + .page.dropping { + outline: 2px dashed var(--accent); + outline-offset: 4px; + border-radius: var(--radius-sm); + } + /* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en liste er indhold her og ikke pynt: den skal kunne tælles. */ .source { diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 980a125..c23caf6 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -30,7 +30,7 @@ export const da = { 'notes.sortCreated': 'Oprettet', 'notes.sortTitle': 'Navn', 'beacon.title': 'Tælling af installationer', - 'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes **kun** de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.', + 'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.', 'beacon.send': 'Send en anonym daglig melding', 'beacon.exactly': 'Præcis det her — ikke mere:', 'beacon.lastPing': 'Sidst sendt: {when}', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index faeb4f8..6d61c36 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -26,7 +26,7 @@ export const en = { 'notes.sortCreated': 'Created', 'notes.sortTitle': 'Name', 'beacon.title': 'Counting installations', - 'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends **only** the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.', + 'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.', 'beacon.send': 'Send an anonymous daily report', 'beacon.exactly': 'Exactly this — nothing more:', 'beacon.lastPing': 'Last sent: {when}',