package httpapi import ( "archive/zip" "fmt" "io" "mime" "net/http" "os" "path" "path/filepath" "sort" "strings" "time" "github.com/go-chi/chi/v5" "github.com/kristianwind/verdande/internal/store" ) // Notes. // // 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. // 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. func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool { user := userFrom(r.Context()) // The owner and the project path, exactly as before. if n.ProjectID == "" { 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 } // 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 } 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 } writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))}) 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 } writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))}) return } // 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 } writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))}) return } // 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) if err != nil { s.internal(w, r, "list notes", err) return } writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))}) } // 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 } // 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) } // 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. 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 } // 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) s.shareWithMentioned(r, n, "") } writeJSON(w, http.StatusCreated, n) } // 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) } } // shareWithMentioned deler noten med dem, der er skrevet ind i den med et @. // // At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var // det to handlinger — skriv navnet, åbn panelet, find personen, del — og den // anden blev glemt, så noten stod med en besked til en, der ikke kunne se den. // // Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde // som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville // hver eneste gemning være en ny deling og en ny besked om det samme navn. // // Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den // videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele // ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet // følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver // kun besked til en, der kan se noten i forvejen. // // Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der // opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet, // og en rolle, der er hævet dér, sænkes ikke igen af en omtale. // Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også // fortæller dem, at noten er blevet rettet. func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool { told := map[string]bool{} if n == nil || n.CreatedBy == "" { return told } // Slået op mod alle konti på instansen — det er dem, noten kan deles med, og // derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke // deles med den, der har den. people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy) if err != nil { s.log.Warn("people for mentions", "err", err, "note", n.ID) return told } was := map[string]bool{} for _, id := range store.MentionsIn(before, people) { was[id] = true } fresh := []string{} for _, id := range store.MentionsIn(n.Body, people) { if !was[id] { fresh = append(fresh, id) } } if len(fresh) == 0 { return told } actor := userFrom(r.Context()) owner := actor.ID == n.CreatedBy shared := false for _, id := range fresh { if !owner { continue } // En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der // ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren // skrev deres navn i teksten. if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil { s.log.Warn("mention share role", "err", err, "note", n.ID) continue } else if has { continue } if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil { s.log.Warn("share with mentioned", "err", err, "note", n.ID) continue } shared = true } if shared { // Og det, noten peger på, følger med delingen — samme vej som en deling // sat i panelet. s.followLinks(r, n) } // Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har // givet adgang, er med — og kun til dem, så en medredaktørs omtale af en // fremmed ikke bliver en besked om en note, de ikke kan åbne. audience, err := s.db.NoteAudience(r.Context(), n.ID) if err != nil { s.log.Warn("note audience for mention", "err", err, "note", n.ID) return told } canSee := map[string]bool{} for _, id := range audience { canSee[id] = true } for _, id := range fresh { if !canSee[id] { continue } s.notify(r, &store.Notification{ UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID, Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title, }) told[id] = true } return told } 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 } 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 } 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 } before := n.Body if body.Body != nil { n.Body = *body.Body } if body.Pinned != nil { n.Pinned = *body.Pinned } // 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 if err := s.db.SaveNote(r.Context(), n); err != nil { s.internal(w, r, "update note", err) return } // 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) } // Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller // lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om // det ville lære folk at overse dem alle sammen. if before != n.Body { // Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og // ikke også "rettede en note" om den samme gemning — det er én handling, og // den ene sætning siger mere end den anden. mentioned := s.shareWithMentioned(r, n, before) s.notifyNoteChanged(r, n, mentioned) } writeJSON(w, http.StatusOK, n) } 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 } 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 } // 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 { 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 } // 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) 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)}) } // 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 } // 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) }) 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, " .") } // 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 // 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 ) // 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. // 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) 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 } // 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 } rc, err := f.Open() if err != nil { continue } hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1)) rc.Close() if err != nil { s.log.Warn("import note file", "err", err, "file", name) continue } 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 stored[name] = hashed files++ } for _, f := range zr.File { name := f.Name 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++ 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 } text, wrote, changed := readFrontMatter(string(body)) n := &store.Note{CreatedBy: user.ID, Body: text} if err := s.db.SaveNote(r.Context(), n); err != nil { s.log.Warn("import note", "err", err, "file", name) skipped++ continue } // 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) } } // 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 { if strings.HasPrefix(name, "__MACOSX/") || strings.HasPrefix(path.Base(name), "._") || 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 } // 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) // 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. relative := strings.TrimPrefix(path.Join(dir, base), "./") refs := []string{f.Name, relative, base} if !strings.Contains(body, f.Name) && !strings.Contains(body, relative) && !strings.Contains(body, base) { 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 } // 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. url := "/api/v1/attachments/" + a.ID 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) } } attached++ } return body, attached } // 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{} } // 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" } // 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)) } // --- 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}) }