feat(notes): et billede kan indsættes og trækkes ind i en note

Rapporteret fra brug: man kunne ikke indsætte billeder eller trække filer ind i
noter. Det passede — importen kunne tage billeder med, og intet andet kunne.
Indsætningen tog kun `text/plain` og smed resten væk, og der var slet ingen
håndtering af et træk. At indsætte et skærmbillede er det mest almindelige, der
findes i et noteprogram, og det gjorde ingenting overhovedet.

Der manglede også et sted at lægge filen. Vedhæftninger på en note fandtes i
databasen, siden migration 0021, men kun importen kunne skrive dem; der var ingen
rute. Nu er der en, med editor-rettigheder på noten og samme svar for "findes
ikke" og "ikke din", så et id ikke kan afprøves.

Skrevet ind som `![](…)` og 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, der skal læses hver gang listen tegnes.

`execCommand` frem for at bygge noden selv, fordi det er dét, der lægger
handlingen i browserens egen fortryd-historik — ⌘Z tager billedet ud igen,
ligesom det tager et ord ud.

`preventDefault` på både dragover og drop. Uden den første kommer der intet drop;
uden den anden åbner browseren filen i stedet, og 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 bærer vores egen nyttelast og skal falde igennem til den, der lyttede.

Prøven går begge veje ind, ser at billederne bliver hentet og tegnet, og læser
filen bagefter for at sikre, at der står to bilagsadresser og ingen data-URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 07:32:03 +02:00
co-authored by Claude Opus 5
parent 241c5ed37d
commit 3991ec9445
8 changed files with 271 additions and 2 deletions
+39
View File
@@ -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]
+67
View File
@@ -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))
}
+4
View File
@@ -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) {
+65
View File
@@ -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([]);
});
+1
View File
@@ -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}`,
+93
View File
@@ -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,
`<img src="${saved.url}" alt="">`);
}
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);
</script>
<div class="wrap" bind:this={wrap}>
@@ -508,6 +589,10 @@
onmouseup={readState}
{onkeydown}
{onpaste}
{ondragover}
{ondrop}
ondragleave={() => (dropping = false)}
class:dropping
></div>
{#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 {
+1 -1
View File
@@ -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}',
+1 -1
View File
@@ -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}',