2026-08-19 13:00:41 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-19 19:03:25 +02:00
|
|
|
"archive/zip"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-08-19 19:12:54 +02:00
|
|
|
"mime"
|
2026-08-19 13:00:41 +02:00
|
|
|
"net/http"
|
2026-08-20 07:32:03 +02:00
|
|
|
"os"
|
2026-08-19 19:12:54 +02:00
|
|
|
"path"
|
2026-08-20 07:32:03 +02:00
|
|
|
"path/filepath"
|
2026-08-19 22:02:20 +02:00
|
|
|
"sort"
|
2026-08-19 13:00:41 +02:00
|
|
|
"strings"
|
2026-08-19 19:03:25 +02:00
|
|
|
"time"
|
2026-08-19 13:00:41 +02:00
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Notes.
|
|
|
|
|
//
|
2026-08-24 07:36:55 +02:00
|
|
|
// Access has three ways in, and a note may have any of them at once. A note with
|
|
|
|
|
// no project is its author's alone — the same answer a task in the inbox gets. A
|
|
|
|
|
// note in a project is readable by everyone who can read the project and writable
|
|
|
|
|
// by an editor. And a note can be shared with a person directly, which grants that
|
|
|
|
|
// person a role of its own. The effective role is the most any of the three allow.
|
2026-08-19 13:00:41 +02:00
|
|
|
|
2026-08-24 07:36:55 +02:00
|
|
|
// mayTouchNote reports whether this person may act on the note at the given role —
|
|
|
|
|
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
|
|
|
|
|
// on a shared project or a shared note, may do the first and not the second.
|
2026-08-19 13:00:41 +02:00
|
|
|
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
|
|
|
|
|
user := userFrom(r.Context())
|
2026-08-24 07:36:55 +02:00
|
|
|
// The owner and the project path, exactly as before.
|
2026-08-19 13:00:41 +02:00
|
|
|
if n.ProjectID == "" {
|
2026-08-24 07:36:55 +02:00
|
|
|
if n.CreatedBy == user.ID {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
|
|
|
|
|
return true
|
2026-08-19 13:00:41 +02:00
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
// A direct share grants its role on top of whatever the above allow.
|
|
|
|
|
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
|
|
|
|
|
return shareRole.AtLeast(role)
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ownsNote reports whether this person is the note's owner — its created_by — which
|
|
|
|
|
// is who, and only who, may hand it to other people. An editor on a shared note can
|
|
|
|
|
// change its text; giving away access is the owner's alone, the same shape a
|
|
|
|
|
// project takes where members edit but only the owner invites.
|
|
|
|
|
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
|
|
|
|
|
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
|
2026-08-19 13:00:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user := userFrom(r.Context())
|
|
|
|
|
|
|
|
|
|
// Searching and listing are the same endpoint, because they answer the same
|
|
|
|
|
// question with different words. ?q= searches; ?project= narrows; neither gives
|
|
|
|
|
// the loose ones.
|
|
|
|
|
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
|
|
|
|
|
found, err := s.db.SearchNotes(r.Context(), q, 50)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "search notes", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
|
2026-08-19 13:00:41 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if projectID := r.URL.Query().Get("project"); projectID != "" {
|
|
|
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil {
|
|
|
|
|
s.storeError(w, r, "notes in project", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
found, err := s.db.NotesInProject(r.Context(), projectID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "notes in project", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
2026-08-19 13:00:41 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 11:03:02 +02:00
|
|
|
// The archive is the same list asked the other way round, so it is the same
|
|
|
|
|
// endpoint with a flag rather than a second one that would have to be kept in
|
|
|
|
|
// step with this.
|
|
|
|
|
if r.URL.Query().Get("archived") != "" {
|
|
|
|
|
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "list archived notes", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
2026-08-20 11:03:02 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 17:03:28 +02:00
|
|
|
// Everything they can see, not only the loose ones. Filing a note in a project
|
|
|
|
|
// is how it gets shared, and a list that dropped it at that moment made sharing
|
|
|
|
|
// look like deleting.
|
|
|
|
|
found, err := s.db.AllNotes(r.Context(), user.ID)
|
2026-08-19 13:00:41 +02:00
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "list notes", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
|
2026-08-20 10:36:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// briefly replaces each note's body with the first of it.
|
|
|
|
|
//
|
|
|
|
|
// The list shows a title and about ninety characters. It was being sent whole
|
|
|
|
|
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
|
|
|
|
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
|
|
|
|
// parsed and held in memory on every visit.
|
|
|
|
|
//
|
|
|
|
|
// Body is emptied rather than shortened, and that is the important half. A field
|
|
|
|
|
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
|
|
|
|
// it, the debounce would save it, and the note would be cut down to its own
|
|
|
|
|
// preview by nothing more than being looked at. Empty is a shape the client can
|
|
|
|
|
// check, and it does — see the notes page, which fetches the whole note when it
|
|
|
|
|
// opens one.
|
|
|
|
|
//
|
|
|
|
|
// Four hundred characters rather than ninety: the client strips Markdown before
|
|
|
|
|
// it shows anything, and a heading marker and a bold word are characters that
|
|
|
|
|
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
|
|
|
|
// is meant to fill.
|
|
|
|
|
const previewChars = 400
|
|
|
|
|
|
|
|
|
|
func briefly(notes []store.Note) []store.Note {
|
|
|
|
|
out := make([]store.Note, 0, len(notes))
|
|
|
|
|
for _, n := range notes {
|
|
|
|
|
body := []rune(n.Body)
|
|
|
|
|
if len(body) > previewChars {
|
|
|
|
|
body = body[:previewChars]
|
|
|
|
|
}
|
|
|
|
|
n.Preview = string(body)
|
|
|
|
|
n.Body = ""
|
|
|
|
|
out = append(out, n)
|
|
|
|
|
}
|
|
|
|
|
return out
|
2026-08-19 13:00:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readable drops what this person may not see.
|
|
|
|
|
//
|
|
|
|
|
// Filtered after the search rather than inside it: FTS5 answers about text, and
|
|
|
|
|
// teaching the index about roles would put an access rule in a place nobody would
|
|
|
|
|
// think to look when it was wrong.
|
|
|
|
|
func (s *Server) readable(r *http.Request, notes []store.Note) []store.Note {
|
|
|
|
|
out := make([]store.Note, 0, len(notes))
|
|
|
|
|
for _, n := range notes {
|
|
|
|
|
if s.mayTouchNote(r, &n, store.RoleViewer) {
|
|
|
|
|
out = append(out, n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleGetNote(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
|
|
|
|
|
}
|
|
|
|
|
// The same answer for "no such note" and "not yours", so an id cannot be probed.
|
|
|
|
|
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
|
|
|
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 18:36:31 +02:00
|
|
|
// No title. It is the note's first line and is derived on every save — a field
|
|
|
|
|
// here would be a promise this server cannot keep.
|
2026-08-19 13:00:41 +02:00
|
|
|
type noteBody struct {
|
|
|
|
|
ProjectID *string `json:"project_id"`
|
|
|
|
|
Body *string `json:"body"`
|
|
|
|
|
Pinned *bool `json:"pinned"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var body noteBody
|
|
|
|
|
if err := decodeJSON(w, r, &body); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
user := userFrom(r.Context())
|
|
|
|
|
|
|
|
|
|
n := &store.Note{CreatedBy: user.ID}
|
|
|
|
|
if body.ProjectID != nil && *body.ProjectID != "" {
|
|
|
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID, user.ID, store.RoleEditor); err != nil {
|
|
|
|
|
s.storeError(w, r, "create note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n.ProjectID = *body.ProjectID
|
|
|
|
|
}
|
|
|
|
|
if body.Body != nil {
|
|
|
|
|
n.Body = *body.Body
|
|
|
|
|
}
|
|
|
|
|
if body.Pinned != nil {
|
|
|
|
|
n.Pinned = *body.Pinned
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
|
|
|
|
s.internal(w, r, "create note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
// En ny note kan være den, en delt note hele tiden har peget på: linket er
|
|
|
|
|
// skrevet som en titel, og titlen fandtes ikke før nu.
|
|
|
|
|
//
|
|
|
|
|
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
|
|
|
|
|
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
|
|
|
|
|
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
|
|
|
|
|
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
|
|
|
|
|
// nok til at nå at taste den første linje ind i den forrige note, som så blev
|
|
|
|
|
// tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel.
|
|
|
|
|
if strings.TrimSpace(n.Body) != "" {
|
|
|
|
|
s.followLinks(r, n)
|
|
|
|
|
}
|
2026-08-19 13:00:41 +02:00
|
|
|
writeJSON(w, http.StatusCreated, n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:28:58 +02:00
|
|
|
// followLinks holder de udledte delinger i takt med teksten.
|
|
|
|
|
//
|
|
|
|
|
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
|
|
|
|
|
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
|
|
|
|
|
// samme sted hen for begge.
|
|
|
|
|
//
|
|
|
|
|
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
|
|
|
|
|
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
|
|
|
|
|
// gemning talte med, kunne den, man har delt en note med, skrive
|
|
|
|
|
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
|
|
|
|
|
//
|
|
|
|
|
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
|
|
|
|
|
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
|
|
|
|
|
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
|
|
|
|
|
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
|
|
|
|
|
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
|
|
|
|
|
//
|
|
|
|
|
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
|
|
|
|
|
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
|
|
|
|
|
func (s *Server) followLinks(r *http.Request, n *store.Note) {
|
|
|
|
|
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
|
|
|
|
|
s.log.Error("sync linked shares", "err", err, "note", n.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:00:41 +02:00
|
|
|
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var body noteBody
|
|
|
|
|
if err := decodeJSON(w, r, &body); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "update note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
|
|
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 07:36:55 +02:00
|
|
|
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
|
|
|
|
|
// Filing a note is the owner's call, not an editor's. A shared editor may
|
|
|
|
|
// change the text; letting them also move the note would let them file
|
|
|
|
|
// somebody else's note into a project the owner is not in — and the owner,
|
|
|
|
|
// who is not a member there, would lose their own note. So the move is
|
|
|
|
|
// owner-only, and then only into a project the owner may write to.
|
|
|
|
|
if !s.ownsNote(r, n) {
|
|
|
|
|
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-19 13:00:41 +02:00
|
|
|
if *body.ProjectID != "" {
|
|
|
|
|
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
|
|
|
|
|
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
|
|
|
|
|
s.storeError(w, r, "move note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
n.ProjectID = *body.ProjectID
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
before := n.Body
|
2026-08-19 13:00:41 +02:00
|
|
|
if body.Body != nil {
|
|
|
|
|
n.Body = *body.Body
|
|
|
|
|
}
|
|
|
|
|
if body.Pinned != nil {
|
|
|
|
|
n.Pinned = *body.Pinned
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:28:58 +02:00
|
|
|
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
|
|
|
|
|
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
|
|
|
|
|
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
|
|
|
|
|
// køre, når svaret kan være blevet et andet, og ellers ikke.
|
|
|
|
|
wasLinking := store.LinksIn(before)
|
|
|
|
|
wasTitled := n.Title
|
|
|
|
|
|
2026-08-19 13:00:41 +02:00
|
|
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
|
|
|
|
s.internal(w, r, "update note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
|
|
|
|
|
// det, andres links peger på, uden at et eneste link er rørt.
|
|
|
|
|
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
|
|
|
|
s.followLinks(r, n)
|
|
|
|
|
}
|
2026-08-19 13:00:41 +02:00
|
|
|
writeJSON(w, http.StatusOK, n)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 22:28:58 +02:00
|
|
|
func sameLinks(a, b []store.NoteLink) bool {
|
|
|
|
|
if len(a) != len(b) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
in := make(map[store.NoteLink]bool, len(a))
|
|
|
|
|
for _, l := range a {
|
|
|
|
|
in[l] = true
|
|
|
|
|
}
|
|
|
|
|
for _, l := range b {
|
|
|
|
|
if !in[l] {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:00:41 +02:00
|
|
|
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "delete note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-24 07:36:55 +02:00
|
|
|
// Deleting is not editing. Someone the note was shared with — even as an editor
|
|
|
|
|
// — may change its text, but throwing away the owner's note is the owner's to
|
|
|
|
|
// do. A project editor keeps the reach they have always had over a project's
|
|
|
|
|
// notes; a direct share does not extend that far.
|
|
|
|
|
mayDelete := s.ownsNote(r, n)
|
|
|
|
|
if !mayDelete && n != nil && n.ProjectID != "" {
|
|
|
|
|
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
|
|
|
|
|
mayDelete = perr == nil
|
|
|
|
|
}
|
|
|
|
|
if n == nil || !mayDelete {
|
2026-08-19 13:00:41 +02:00
|
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.db.DeleteNote(r.Context(), n.ID); err != nil {
|
|
|
|
|
s.internal(w, r, "delete note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
|
|
|
|
|
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
|
|
|
|
|
s.followLinks(r, n)
|
2026-08-19 13:00:41 +02:00
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleNotesLinking answers the backwards question for a task or a project:
|
|
|
|
|
// what has somebody written about this.
|
|
|
|
|
func (s *Server) handleNotesLinking(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
kind := chi.URLParam(r, "kind")
|
|
|
|
|
if kind != "task" && kind != "project" && kind != "note" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, CodeValidation, "kind must be task, project or note")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
found, err := s.db.NotesLinking(r.Context(), kind, chi.URLParam(r, "targetID"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "notes linking", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
|
|
|
|
}
|
2026-08-19 19:03:25 +02:00
|
|
|
|
|
|
|
|
// handleExportNotes writes every note this person can see as a zip of Markdown
|
|
|
|
|
// files, one per note.
|
|
|
|
|
//
|
|
|
|
|
// A zip of plain files rather than one bundle in a format of our own: what comes
|
|
|
|
|
// out is what was stored, and it opens in Obsidian, in a text editor, in anything.
|
|
|
|
|
// That is the promise the whole design was arranged around — the note on disk is
|
|
|
|
|
// already the file you would export — and this is where it is either kept or not.
|
|
|
|
|
func (s *Server) handleExportNotes(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user := userFrom(r.Context())
|
|
|
|
|
notes, err := s.db.AllNotes(r.Context(), user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "export notes", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 12:39:11 +02:00
|
|
|
// Oldest first, and the id to break a tie. AllNotes comes back newest-first by
|
|
|
|
|
// the second, so two notes made in the same second have no order between them —
|
|
|
|
|
// and then which of two same-titled notes gets the plain name and which gets
|
|
|
|
|
// "(2)" is a coin toss that lands differently each export. Ordering the whole
|
|
|
|
|
// thing by when it was made settles it: the first note of a name keeps the name.
|
|
|
|
|
sort.SliceStable(notes, func(i, j int) bool {
|
|
|
|
|
if notes[i].CreatedAt.Equal(notes[j].CreatedAt) {
|
|
|
|
|
return notes[i].ID < notes[j].ID
|
|
|
|
|
}
|
|
|
|
|
return notes[i].CreatedAt.Before(notes[j].CreatedAt)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-19 19:03:25 +02:00
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
|
|
|
w.Header().Set("Content-Disposition",
|
|
|
|
|
`attachment; filename="verdande-noter-`+time.Now().Format("2006-01-02")+`.zip"`)
|
|
|
|
|
|
|
|
|
|
zw := zip.NewWriter(w)
|
|
|
|
|
defer zw.Close()
|
|
|
|
|
|
|
|
|
|
// Names collide: two notes called "Møde" are two notes, and a zip with one entry
|
|
|
|
|
// twice is a zip that loses one of them.
|
|
|
|
|
used := map[string]int{}
|
|
|
|
|
for _, n := range notes {
|
|
|
|
|
name := noteFilename(n.Title)
|
|
|
|
|
used[name]++
|
|
|
|
|
if used[name] > 1 {
|
|
|
|
|
name = fmt.Sprintf("%s (%d)", name, used[name])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
f, err := zw.CreateHeader(&zip.FileHeader{
|
|
|
|
|
Name: name + ".md",
|
|
|
|
|
Method: zip.Deflate,
|
|
|
|
|
Modified: n.UpdatedAt,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
// The response is already streaming, so there is no status left to send.
|
|
|
|
|
// Stopping leaves a truncated zip, which a zip tool says is truncated —
|
|
|
|
|
// better than a complete-looking file with a note missing from it.
|
|
|
|
|
s.log.Error("export notes", "err", err, "note", n.ID)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := io.WriteString(f, n.Body); err != nil {
|
|
|
|
|
s.log.Error("export notes", "err", err, "note", n.ID)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// noteFilename makes a title safe to be a filename on any of the three systems
|
|
|
|
|
// people actually use, without turning it into something unrecognisable.
|
|
|
|
|
func noteFilename(title string) string {
|
|
|
|
|
name := strings.TrimSpace(title)
|
|
|
|
|
if name == "" {
|
|
|
|
|
name = "uden titel"
|
|
|
|
|
}
|
|
|
|
|
// Windows refuses these outright; a slash would make directories on the others.
|
|
|
|
|
name = strings.Map(func(r rune) rune {
|
|
|
|
|
if strings.ContainsRune(`/\:*?"<>|`, r) || r < 0x20 {
|
|
|
|
|
return '-'
|
|
|
|
|
}
|
|
|
|
|
return r
|
|
|
|
|
}, name)
|
|
|
|
|
// Long titles are the first line of a note, which can be a whole sentence.
|
|
|
|
|
if len([]rune(name)) > 80 {
|
|
|
|
|
name = string([]rune(name)[:80])
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimRight(name, " .")
|
|
|
|
|
}
|
2026-08-19 19:12:54 +02:00
|
|
|
|
|
|
|
|
// maxNoteImportBytes bounds one upload. Generous for text — a decade of notes is
|
|
|
|
|
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
|
|
|
|
const maxNoteImportBytes = 64 << 20
|
|
|
|
|
|
2026-08-20 08:39:52 +02:00
|
|
|
// What the archive may become once it is unpacked, and how many entries it may
|
|
|
|
|
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
|
|
|
|
|
// nothing at all about what it expands to.
|
|
|
|
|
const (
|
|
|
|
|
maxNoteImportUnpacked = 2 << 30
|
|
|
|
|
maxNoteImportFiles = 20_000
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-19 19:12:54 +02:00
|
|
|
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
|
|
|
|
//
|
|
|
|
|
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
|
|
|
|
// this program goes back into it. It is also the way in from anywhere else, since
|
|
|
|
|
// a folder of Markdown is what Obsidian, Bear, iA Writer and a shell script all
|
|
|
|
|
// produce — and what the Apple Notes exporter in tools/ writes.
|
|
|
|
|
//
|
|
|
|
|
// Filenames are ignored. The title is the first line of the body, everywhere else
|
|
|
|
|
// in this program, and making it the filename here would be a second rule for the
|
|
|
|
|
// same thing that disagrees the moment somebody renames a file.
|
|
|
|
|
func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxNoteImportBytes)
|
|
|
|
|
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
|
|
|
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, "the file is too large")
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
zr, err := zip.NewReader(file.(io.ReaderAt), header.Size)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeFieldErrors(w, map[string]string{"file": "not a zip archive"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
user := userFrom(r.Context())
|
|
|
|
|
created, skipped, files := 0, 0, 0
|
|
|
|
|
|
|
|
|
|
// Two passes. Everything that is not Markdown is stored first, so that when a
|
|
|
|
|
// note is read its pictures already have addresses to be rewritten to — a note
|
|
|
|
|
// and its images arrive in whatever order the archive happens to hold them, and
|
|
|
|
|
// one pass would leave half the links pointing at nothing.
|
2026-08-20 08:39:52 +02:00
|
|
|
// The archive as a whole, not just the upload.
|
|
|
|
|
//
|
|
|
|
|
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
|
|
|
|
|
// MB can become sixty-six gigabytes on the way out — written into the same
|
|
|
|
|
// volume the database lives on. The upload limit says nothing about that, and
|
|
|
|
|
// nothing else did either.
|
|
|
|
|
//
|
|
|
|
|
// Two ceilings: one per file, and one across the archive. A single enormous
|
|
|
|
|
// photograph and ten thousand small ones are different attacks with the same
|
|
|
|
|
// ending.
|
|
|
|
|
if len(zr.File) > maxNoteImportFiles {
|
|
|
|
|
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
|
|
|
|
"that archive holds too many files")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
budget := int64(maxNoteImportUnpacked)
|
|
|
|
|
|
2026-08-19 19:12:54 +02:00
|
|
|
stored := map[string]string{}
|
|
|
|
|
for _, f := range zr.File {
|
|
|
|
|
name := f.Name
|
|
|
|
|
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-20 08:39:52 +02:00
|
|
|
// The header's own claim first, which costs nothing to check and rejects
|
|
|
|
|
// the obvious case before a byte is written. It is only a claim, so the
|
|
|
|
|
// reader below is limited as well.
|
|
|
|
|
if f.UncompressedSize64 > maxUploadBytes {
|
|
|
|
|
s.log.Warn("import: a file in the archive is too large", "file", name)
|
|
|
|
|
skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-19 19:12:54 +02:00
|
|
|
rc, err := f.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-20 08:39:52 +02:00
|
|
|
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
|
2026-08-19 19:12:54 +02:00
|
|
|
rc.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.log.Warn("import note file", "err", err, "file", name)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-20 08:39:52 +02:00
|
|
|
if size > maxUploadBytes || size > budget {
|
|
|
|
|
// Over the line once it was actually read: remove what was written
|
|
|
|
|
// rather than keeping a truncated file that would open as nothing.
|
|
|
|
|
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
|
|
|
|
|
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
|
|
|
|
|
skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
budget -= size
|
2026-08-19 19:12:54 +02:00
|
|
|
stored[name] = hashed
|
|
|
|
|
files++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, f := range zr.File {
|
|
|
|
|
name := f.Name
|
2026-08-20 00:04:08 +02:00
|
|
|
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Counted, not passed over in silence. A Markdown file the filter refuses is
|
|
|
|
|
// a note that did not arrive, and the caller is told the same number either
|
|
|
|
|
// way unless it is said here — which is how one note went missing without
|
|
|
|
|
// anything looking wrong.
|
|
|
|
|
if skipFile(name) {
|
|
|
|
|
s.log.Warn("import skipped a note file", "file", name)
|
|
|
|
|
skipped++
|
2026-08-19 19:12:54 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rc, err := f.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
body, err := io.ReadAll(io.LimitReader(rc, 2<<20))
|
|
|
|
|
rc.Close()
|
|
|
|
|
if err != nil || strings.TrimSpace(string(body)) == "" {
|
|
|
|
|
skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 22:58:19 +02:00
|
|
|
text, wrote, changed := readFrontMatter(string(body))
|
|
|
|
|
|
|
|
|
|
n := &store.Note{CreatedBy: user.ID, Body: text}
|
2026-08-19 19:12:54 +02:00
|
|
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
|
|
|
|
s.log.Warn("import note", "err", err, "file", name)
|
|
|
|
|
skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 22:58:19 +02:00
|
|
|
// After the save, not as part of it: SaveNote owns `updated_at` and is right
|
|
|
|
|
// to, so the dates a note had before this program existed are written over
|
|
|
|
|
// the top rather than passed through it.
|
|
|
|
|
if !wrote.IsZero() || !changed.IsZero() {
|
|
|
|
|
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
|
|
|
|
|
s.log.Warn("import note dates", "err", err, "file", name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 19:12:54 +02:00
|
|
|
// The pictures the note referred to, now that it has an id to hang them on.
|
|
|
|
|
// The text is rewritten to the addresses they were given, so an image that
|
|
|
|
|
// sat in the middle of a paragraph is still in the middle of it.
|
|
|
|
|
if rewritten, attached := s.attachToNote(r, n, path.Dir(name), stored, zr); attached > 0 {
|
|
|
|
|
n.Body = rewritten
|
|
|
|
|
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
|
|
|
|
s.log.Warn("rewrite note links", "err", err, "note", n.ID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
created++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"created": created, "skipped": skipped, "files": files,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// skipFile is the things in an archive that are not content: directories' own
|
|
|
|
|
// entries, macOS resource forks, and anything trying to climb out of the archive.
|
|
|
|
|
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
|
|
|
|
// would double a whole library with each copy full of binary rubbish.
|
|
|
|
|
func skipFile(name string) bool {
|
2026-08-20 00:04:08 +02:00
|
|
|
if strings.HasPrefix(name, "__MACOSX/") ||
|
2026-08-19 19:12:54 +02:00
|
|
|
strings.HasPrefix(path.Base(name), "._") ||
|
2026-08-20 00:04:08 +02:00
|
|
|
strings.HasPrefix(path.Base(name), ".") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Traversal, read as path segments rather than as a substring.
|
|
|
|
|
//
|
|
|
|
|
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
|
|
|
|
|
// wrong thing: it does not describe a path that climbs out of the archive, it
|
|
|
|
|
// describes any name with two dots next to each other anywhere in it. A note
|
|
|
|
|
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
|
|
|
|
|
// silently, and without even counting as skipped, so the number the import
|
|
|
|
|
// reported still looked right. One note in twelve hundred, found only by
|
|
|
|
|
// counting what came out.
|
|
|
|
|
//
|
|
|
|
|
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
|
|
|
|
|
// starts at the root, is not — and those are what a zip uses to write outside
|
|
|
|
|
// the folder it was unpacked into.
|
|
|
|
|
if strings.HasPrefix(name, "/") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
for _, part := range strings.Split(name, "/") {
|
|
|
|
|
if part == ".." {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
2026-08-19 19:12:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// attachToNote records the files a note's text points at and rewrites the text to
|
|
|
|
|
// where they now live. Returns the new body and how many were attached.
|
|
|
|
|
//
|
|
|
|
|
// Only the files this note refers to. An archive is one folder for many notes, and
|
|
|
|
|
// hanging every picture on every note would turn a library of a hundred into ten
|
|
|
|
|
// thousand attachments.
|
|
|
|
|
func (s *Server) attachToNote(
|
|
|
|
|
r *http.Request, n *store.Note, dir string, stored map[string]string, zr *zip.Reader,
|
|
|
|
|
) (string, int) {
|
|
|
|
|
body := n.Body
|
|
|
|
|
attached := 0
|
|
|
|
|
|
|
|
|
|
for _, f := range zr.File {
|
|
|
|
|
hashed, ok := stored[f.Name]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
base := path.Base(f.Name)
|
2026-08-19 22:02:20 +02:00
|
|
|
// The three ways a Markdown file names a neighbouring image: by its path
|
|
|
|
|
// inside the archive, by the path relative to the note, and by its bare name.
|
|
|
|
|
//
|
|
|
|
|
// The archive path was missing, and it is the one an Apple Notes export
|
|
|
|
|
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
|
|
|
|
// root beside the folder. Only the bare name matched, so only the bare name
|
|
|
|
|
// was replaced, and every picture in every imported note ended up pointing at
|
|
|
|
|
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
|
|
|
|
// stored and attached correctly; it was the link that was wrong.
|
2026-08-19 19:12:54 +02:00
|
|
|
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
2026-08-19 22:02:20 +02:00
|
|
|
refs := []string{f.Name, relative, base}
|
|
|
|
|
if !strings.Contains(body, f.Name) &&
|
|
|
|
|
!strings.Contains(body, relative) &&
|
|
|
|
|
!strings.Contains(body, base) {
|
2026-08-19 19:12:54 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a := &store.Attachment{
|
|
|
|
|
NoteID: n.ID,
|
|
|
|
|
Filename: base,
|
|
|
|
|
MimeType: mimeOf(base),
|
|
|
|
|
Size: int64(f.UncompressedSize64),
|
|
|
|
|
Path: hashed,
|
|
|
|
|
UploadedBy: n.CreatedBy,
|
|
|
|
|
}
|
|
|
|
|
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
|
|
|
|
s.log.Warn("attach to note", "err", err, "file", base)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 22:02:20 +02:00
|
|
|
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
|
|
|
|
// stående foran adressen, og linket peger på ingenting.
|
2026-08-19 19:12:54 +02:00
|
|
|
url := "/api/v1/attachments/" + a.ID
|
2026-08-19 22:02:20 +02:00
|
|
|
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
|
|
|
|
for _, ref := range refs {
|
|
|
|
|
if ref != "" {
|
|
|
|
|
body = strings.ReplaceAll(body, ref, url)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-19 19:12:54 +02:00
|
|
|
attached++
|
|
|
|
|
}
|
|
|
|
|
return body, attached
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 22:58:19 +02:00
|
|
|
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
|
|
|
|
|
// the two dates out of it, returning the rest of the text.
|
|
|
|
|
//
|
|
|
|
|
// Not a YAML parser, and deliberately not: the block this reads is the one this
|
|
|
|
|
// program writes, plus whatever Obsidian and Bear write, and all of them use the
|
|
|
|
|
// same handful of `key: value` lines. Pulling in a parser to read two dates would
|
|
|
|
|
// be a dependency that can fail in ways nobody here would recognise.
|
|
|
|
|
//
|
|
|
|
|
// A file with no front matter comes back untouched — that is the normal case, and
|
|
|
|
|
// it must never lose its first line to a rule about a block that is not there.
|
|
|
|
|
func readFrontMatter(body string) (text string, created, modified time.Time) {
|
|
|
|
|
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
|
|
|
|
|
return body, time.Time{}, time.Time{}
|
|
|
|
|
}
|
|
|
|
|
rest := body[strings.Index(body, "\n")+1:]
|
|
|
|
|
|
|
|
|
|
end := strings.Index(rest, "\n---")
|
|
|
|
|
if end < 0 {
|
|
|
|
|
// An opening fence with no close is not front matter; it is a horizontal
|
|
|
|
|
// rule somebody wrote at the top of their note.
|
|
|
|
|
return body, time.Time{}, time.Time{}
|
|
|
|
|
}
|
|
|
|
|
block := rest[:end]
|
|
|
|
|
after := rest[end+len("\n---"):]
|
|
|
|
|
after = strings.TrimPrefix(after, "\r")
|
|
|
|
|
after = strings.TrimPrefix(after, "\n")
|
|
|
|
|
|
|
|
|
|
for _, line := range strings.Split(block, "\n") {
|
|
|
|
|
key, value, ok := strings.Cut(line, ":")
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
|
|
|
|
case "created", "created_at", "date":
|
|
|
|
|
created = parseNoteTime(value)
|
|
|
|
|
case "modified", "updated", "updated_at":
|
|
|
|
|
modified = parseNoteTime(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimLeft(after, "\n"), created, modified
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseNoteTime accepts the shapes these files actually carry. A date without a
|
|
|
|
|
// time is that day at midnight, local — the note was written on that day, and
|
|
|
|
|
// pretending to know the hour would be worse than not having one.
|
|
|
|
|
func parseNoteTime(v string) time.Time {
|
|
|
|
|
for _, layout := range []string{
|
|
|
|
|
time.RFC3339,
|
|
|
|
|
"2006-01-02T15:04:05",
|
|
|
|
|
"2006-01-02 15:04:05",
|
|
|
|
|
"2006-01-02 15:04",
|
|
|
|
|
"2006-01-02",
|
|
|
|
|
} {
|
|
|
|
|
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
|
|
|
|
return t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return time.Time{}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 19:12:54 +02:00
|
|
|
// mimeOf guesses from the extension. The archive does not carry a type, and
|
|
|
|
|
// sniffing the bytes would mean reading every file twice.
|
|
|
|
|
func mimeOf(name string) string {
|
|
|
|
|
if t := mime.TypeByExtension(path.Ext(name)); t != "" {
|
|
|
|
|
return t
|
|
|
|
|
}
|
|
|
|
|
return "application/octet-stream"
|
|
|
|
|
}
|
2026-08-20 07:32:03 +02:00
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
2026-08-20 11:03:02 +02:00
|
|
|
|
|
|
|
|
// --- putting notes away, one or many ---------------------------------------------
|
|
|
|
|
|
|
|
|
|
type noteBulkRequest struct {
|
|
|
|
|
IDs []string `json:"ids"`
|
|
|
|
|
Archived *bool `json:"archived"`
|
|
|
|
|
Delete bool `json:"delete"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
|
|
|
|
|
// and few enough that the loop below cannot become somebody's afternoon.
|
|
|
|
|
const maxNoteBulk = 500
|
|
|
|
|
|
|
|
|
|
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
|
|
|
|
|
//
|
|
|
|
|
// One call rather than one per note, because the thing being asked is one thing:
|
|
|
|
|
// "put these away". Fifty separate requests would be fifty round trips, fifty
|
|
|
|
|
// chances for half of them to fail, and a list that redraws fifty times — and
|
|
|
|
|
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
|
|
|
|
|
// not the extreme one.
|
|
|
|
|
//
|
|
|
|
|
// Each note is still checked on its own. A set is a convenience for the caller
|
|
|
|
|
// and must never become a way to touch something they could not have touched one
|
|
|
|
|
// at a time.
|
|
|
|
|
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req noteBulkRequest
|
|
|
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if len(req.IDs) == 0 {
|
|
|
|
|
writeFieldErrors(w, map[string]string{"ids": "required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if len(req.IDs) > maxNoteBulk {
|
|
|
|
|
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
done, skipped := 0, 0
|
|
|
|
|
for _, id := range req.IDs {
|
|
|
|
|
n, err := s.db.Note(r.Context(), id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "get note", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Not an error, and not a 404 for the whole call: a set can contain a note
|
|
|
|
|
// somebody else deleted a second ago, and the other forty-nine should still
|
|
|
|
|
// be put away. Counted so the answer is honest about it.
|
|
|
|
|
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
|
|
|
|
skipped++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case req.Delete:
|
|
|
|
|
err = s.db.DeleteNote(r.Context(), id)
|
|
|
|
|
case req.Archived != nil:
|
|
|
|
|
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
|
|
|
|
|
default:
|
|
|
|
|
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.internal(w, r, "note bulk", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
done++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
|
|
|
|
|
}
|