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 ).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);