package web import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/m/projax/store" ) // handleLinksAdd processes POST /i/{path}/links/add. Accepts ref_type, ref_id, // note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the // trust model is Tailscale-only + cookie auth. func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) { it, err := s.Items.GetByPath(r.Context(), path) if err != nil { s.fail(w, r, err) return } if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } refType := strings.TrimSpace(r.FormValue("ref_type")) refID := strings.TrimSpace(r.FormValue("ref_id")) noteVal := strings.TrimSpace(r.FormValue("note")) dateStr := strings.TrimSpace(r.FormValue("event_date")) banner := "" if refType == "" || refID == "" { banner = "ref_type and ref_id are required." } var date *time.Time if banner == "" && dateStr != "" { t, err := time.Parse("2006-01-02", dateStr) if err != nil { banner = "event_date must be YYYY-MM-DD." } else { date = &t } } if banner == "" { var notePtr *string if noteVal != "" { notePtr = ¬eVal } if _, err := s.Store.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil { banner = fmt.Sprintf("Could not add link: %v", err) } } // New dated link → bust the timeline cache so the row surfaces on next view. s.timeline.InvalidateAll() s.renderDocumentsSection(w, r, it, nil, banner) } // handleLinksRemove processes POST /i/{path}/links/remove. func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) { it, err := s.Items.GetByPath(r.Context(), path) if err != nil { s.fail(w, r, err) return } if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } linkID := strings.TrimSpace(r.FormValue("link_id")) banner := "" if linkID == "" { banner = "link_id required" } else { // Belt-and-braces: ensure the link belongs to this item before // deleting, so a crafted form can't snipe an unrelated row. owns, err := s.linkBelongsToItem(r.Context(), linkID, it.ID) if err != nil { s.fail(w, r, err) return } if !owns { banner = "Link does not belong to this item." } else if err := s.Store.DeleteLink(r.Context(), linkID); err != nil { banner = fmt.Sprintf("Could not remove link: %v", err) } } // Bust the dashboard + timeline caches: a removed dated link should // disappear from both surfaces on next render. s.dashboard.InvalidateAll() s.timeline.InvalidateAll() // When the delete came from the timeline (HX-Target = timeline-section), // re-render the timeline so the row vanishes in place instead of trying to // swap a Documents fragment into it. if r.Header.Get("HX-Target") == "timeline-section" { s.handleTimeline(w, r) return } s.renderDocumentsSection(w, r, it, nil, banner) } // renderDocumentsSection re-pulls dated links, computes PERs, and renders the // Documents fragment for HTMX swaps. Non-HTMX requests fall back to a full // detail-page redirect. func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request, it *store.Item, highlight *time.Time, banner string) { if r.Header.Get("HX-Request") != "true" { http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther) return } docs, err := s.Items.DatedLinks(r.Context(), it.ID) if err != nil { s.fail(w, r, err) return } documents := computePERs(it.PrimaryPath(), docs) s.render(w, r, "documents_section", map[string]any{ "Item": it, "Documents": documents, "HighlightDate": highlight, "DocBanner": banner, }) } // linkBelongsToItem returns true when the link's item_id equals the supplied // item id. Used as an anti-forgery check before delete. func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) { var owner string err := s.Store.Pool.QueryRow(ctx, `select item_id from projax.item_links where id = $1`, linkID).Scan(&owner) if err != nil { if isNoRows(err) { return false, nil } return false, err } return owner == itemID, nil } func isNoRows(err error) bool { return err != nil && (errors.Is(err, store.ErrNotFound) || err.Error() == "no rows in result set") }