package handlers import ( "context" "database/sql" "net/url" "regexp" "strings" "github.com/andrew/go-computer/internal/db" ) // Connected pages (Notion-like upgrades phase 3). // // Page hierarchy, internal links and embedded collection views are all // expressed in the page's Markdown, which stays the single source of truth: // // - [Any label](computer:page/) links to another page. /pages?id= // and page/ are accepted as aliases so hand-written links work too. // - {{collection:}} embeds a live collection view; an optional // {{collection:|view=}} pins a saved view's filters/sorts. // // On every content save the resolved PageLink and PageEmbed rows are rebuilt // inside the same transaction. Only targets owned by the author are stored: // backlinks therefore cannot enumerate or reveal titles from another account. // Embedding displays data through the normal collection endpoints, so the // underlying bookmark-visibility checks still apply and nothing is published. var ( markdownLinkRE = regexp.MustCompile(`\[[^\]]*\]\(\s*]+)(?:\s+"[^"]*")?\s*>?\)`) pageEmbedRE = regexp.MustCompile(`\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}`) pageRefRE = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) ) // internalPageID normalizes a Markdown link target to a page id. It returns // false for ordinary web links and malformed ids. func internalPageID(href string) (string, bool) { href = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(href, "<"), ">")) raw := "" switch { case strings.HasPrefix(href, "computer://page/"): raw = strings.TrimPrefix(href, "computer://page/") case strings.HasPrefix(href, "computer:page/"): raw = strings.TrimPrefix(href, "computer:page/") case strings.HasPrefix(href, "page/"): raw = strings.TrimPrefix(href, "page/") case strings.HasPrefix(href, "/pages/"): raw = strings.TrimPrefix(href, "/pages/") case strings.HasPrefix(href, "/pages?"): if u, err := url.Parse(href); err == nil { raw = u.Query().Get("id") } case strings.HasPrefix(href, "computer:page?id="): if u, err := url.Parse(href); err == nil { raw = u.Query().Get("id") } case strings.HasPrefix(href, "computer://page?id="): if u, err := url.Parse(href); err == nil { raw = u.Query().Get("id") } } raw = strings.TrimSuffix(strings.TrimSpace(raw), "/") if !pageRefRE.MatchString(raw) { return "", false } return raw, true } type pageEmbedRef struct { collectionID string viewID string } // pageGraph extracts the distinct page links and embeds from Markdown. func pageGraph(content, selfID string) ([]string, []pageEmbedRef) { links := make([]string, 0) seenLinks := map[string]bool{} for _, m := range markdownLinkRE.FindAllStringSubmatchIndex(content, -1) { // Skip images: ![alt](target) is not an internal link. if m[0] > 0 && content[m[0]-1] == '!' { continue } id, ok := internalPageID(content[m[2]:m[3]]) if !ok || id == selfID || seenLinks[id] { continue } seenLinks[id] = true links = append(links, id) } embeds := make([]pageEmbedRef, 0) seenEmbeds := map[string]bool{} for _, m := range pageEmbedRE.FindAllStringSubmatch(content, -1) { ref := pageEmbedRef{collectionID: m[1], viewID: m[2]} if seenEmbeds[ref.collectionID] { continue } seenEmbeds[ref.collectionID] = true embeds = append(embeds, ref) } return links, embeds } // syncPageGraph replaces one page's resolved links and embeds. It runs inside // the content-save transaction, so the derived rows and the Markdown commit // together (a failed save never leaves stale backlinks). func syncPageGraph(ctx context.Context, tx *sql.Tx, pageID, uid, content string) error { links, embeds := pageGraph(content, pageID) if _, err := tx.ExecContext(ctx, `DELETE FROM "PageLink" WHERE "sourcePageId"=$1`, pageID); err != nil { return err } for _, target := range links { var visible bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, target, uid).Scan(&visible); err != nil { return err } // A foreign/missing target is ignored rather than stored. The link // text remains in the author's Markdown, but it can never become a // cross-account backlink. if !visible { continue } if _, err := tx.ExecContext(ctx, `INSERT INTO "PageLink"("sourcePageId","targetPageId") VALUES($1,$2) ON CONFLICT DO NOTHING`, pageID, target); err != nil { return err } } if _, err := tx.ExecContext(ctx, `DELETE FROM "PageEmbed" WHERE "pageId"=$1`, pageID); err != nil { return err } for i, ref := range embeds { var visible bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, ref.collectionID, uid).Scan(&visible); err != nil { return err } if !visible { continue } viewID := any(nil) if ref.viewID != "" { var valid bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "CollectionView" WHERE id=$1 AND "collectionId"=$2)`, ref.viewID, ref.collectionID).Scan(&valid); err != nil { return err } // An unknown view falls back to the collection's default view // instead of dropping the embed entirely. if valid { viewID = ref.viewID } } if _, err := tx.ExecContext(ctx, `INSERT INTO "PageEmbed"("pageId","collectionId","viewId",position) VALUES($1,$2,$3,$4)`, pageID, ref.collectionID, viewID, i); err != nil { return err } } return nil } // pageParent validates a requested parent and rejects cycles. The walk stays // within the owner's pages and is bounded by the seen set, so a corrupt // pre-existing cycle cannot loop forever. func pageParent(ctx context.Context, q db.Queryer, pageID, uid string, raw any) (any, error) { if raw == nil { return nil, nil } parent, ok := raw.(string) if !ok { return nil, bad("parentId must be a page id or null") } parent = strings.TrimSpace(parent) if parent == "" { return nil, nil } if !pageRefRE.MatchString(parent) { return nil, bad("Invalid parentId") } if parent == pageID { return nil, bad("A page cannot be its own parent") } seen := map[string]bool{pageID: true} for current := parent; current != ""; { if seen[current] { return nil, bad("Page hierarchy cannot contain a cycle") } seen[current] = true var next sql.NullString err := q.QueryRowContext(ctx, `SELECT "parentId" FROM "Page" WHERE id=$1 AND "ownerId"=$2`, current, uid).Scan(&next) if err == sql.ErrNoRows { return nil, bad("Parent page not found") } if err != nil { return nil, err } current = next.String } return parent, nil }