diff --git a/internal/httpapi/comments_handlers.go b/internal/httpapi/comments_handlers.go index 9b33790..acded49 100644 --- a/internal/httpapi/comments_handlers.go +++ b/internal/httpapi/comments_handlers.go @@ -394,12 +394,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request } defer f.Close() - // Everything is served as a download rather than rendered. An uploaded SVG or - // HTML file displayed inline would run its own script on this origin, with the - // session cookie attached — attachments are the one place a user supplies - // content that another user opens. - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+ + // Almost everything is served as a download rather than rendered. An uploaded + // SVG or HTML file displayed inline would run its own script on this origin, + // with the session cookie attached — attachments are the one place a user + // supplies content that another user opens. + // + // The exception is a short list of raster images, and it is not a softening of + // that rule but what makes a note work: a note carries its pictures in the text + // as `![](…)`, and served as octet-stream every one of them is a broken image. + // The whole Apple Notes import exists to bring those pictures along. + // + // Safe because it is an allowlist of formats that cannot carry script, sent as + // the exact type with `nosniff` — so the browser must treat it as that image or + // not at all, and can never be talked into reading it as HTML. SVG is + // deliberately absent: it is a document, not a picture, and it is the one image + // type that can run script. + disposition := "attachment" + contentType := "application/octet-stream" + if inlineImage[a.MimeType] { + disposition = "inline" + contentType = a.MimeType + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+ strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20")) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Length", fmt.Sprint(a.Size)) @@ -408,6 +426,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request http.ServeContent(w, r, a.Filename, a.CreatedAt, f) } +// inlineImage is what may be shown rather than downloaded. +// +// Raster formats only, and named one by one rather than matched on an "image/" +// prefix: the prefix would let image/svg+xml through, which is a document that +// can run script, and a list that grows by accident is how that happens. +var inlineImage = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/avif": true, + // Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig + // som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem, + // virker de, uden at nogen skal huske hvorfor de ikke gjorde. + "image/heic": true, + "image/heif": true, +} + func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) { attachmentID := chi.URLParam(r, "attachmentID") diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 576eb11..77fd8d7 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -7,6 +7,7 @@ import ( "mime" "net/http" "path" + "sort" "strings" "time" @@ -421,10 +422,20 @@ func (s *Server) attachToNote( continue } base := path.Base(f.Name) - // The two ways a Markdown file names a neighbouring image: by the path it - // was written with, and by its bare 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), "./") - if !strings.Contains(body, base) && !strings.Contains(body, relative) { + refs := []string{f.Name, relative, base} + if !strings.Contains(body, f.Name) && + !strings.Contains(body, relative) && + !strings.Contains(body, base) { continue } @@ -441,9 +452,15 @@ func (s *Server) attachToNote( 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 - body = strings.ReplaceAll(body, relative, url) - body = strings.ReplaceAll(body, base, url) + 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 diff --git a/tools/apple-notes-til-markdown.py b/tools/apple-notes-til-markdown.py index 6361793..f0cb598 100755 --- a/tools/apple-notes-til-markdown.py +++ b/tools/apple-notes-til-markdown.py @@ -255,11 +255,36 @@ def main() -> int: # De tomme linjer skal forbi først:

ligger inde i en
, hvis # starttag tømmer linjen før den — så første linje er tom, og # sammenligningen så på ingenting. + # Titlen kan være delt over flere overskrifter. + # + # Noter kalder noten det, der står på de første linjer, sat sammen med + # mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder + # "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og + # titlen stod bagefter to gange: én gang som overskrift og én gang som de + # stumper, den var lavet af. Fjorten noter ud af tolv hundrede. + # + # Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er + # begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen. lines = text.split("\n") while lines and not lines[0].strip(): lines.pop(0) - if lines and lines[0].lstrip("# ").strip() == title.strip(): - lines = lines[1:] + + taken, joined = 0, "" + for line in lines: + if not line.strip(): + taken += 1 + continue + if not line.lstrip().startswith("#"): + break + candidate = (joined + " " + line.lstrip("# ").strip()).strip() + if not title.strip().startswith(candidate): + break + joined, taken = candidate, taken + 1 + if joined == title.strip(): + break + + if joined == title.strip(): + lines = lines[taken:] while lines and not lines[0].strip(): lines.pop(0) text = "\n".join(lines)