diff --git a/db.go b/db.go index 8d5d00a..349ab80 100644 --- a/db.go +++ b/db.go @@ -1,7 +1,9 @@ package main import ( + "crypto/rand" "database/sql" + "encoding/hex" "encoding/json" "fmt" "math" @@ -37,6 +39,14 @@ type Document struct { Score float64 `json:",omitempty"` // semantic search relevance 0-1 } +// Share represents a document share link +type Share struct { + Token string + DocID string + CreatedAt string + ExpiresAt string +} + // DocumentUpdate contains fields that can be updated type DocumentUpdate struct { Title string @@ -105,6 +115,16 @@ func initSchema() error { created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS shares ( + id TEXT PRIMARY KEY, + doc_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, + FOREIGN KEY (doc_id) REFERENCES documents(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_shares_doc_id ON shares(doc_id); + DROP TABLE IF EXISTS documents_fts; CREATE VIRTUAL TABLE documents_fts USING fts5( id UNINDEXED, title, summary, full_text, vendor @@ -386,6 +406,71 @@ func DeleteDocument(id string) error { return err } +// CreateShare creates a share link for a document, returns the token +func CreateShare(docID string, expiryDays int) (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", err + } + token := hex.EncodeToString(b) + + var expiresAt sql.NullString + if expiryDays > 0 { + expiresAt = sql.NullString{ + String: fmt.Sprintf("datetime('now', '+%d days')", expiryDays), + Valid: true, + } + _, err := db.Exec(`INSERT INTO shares (id, doc_id, expires_at) VALUES (?, ?, datetime('now', ?))`, + token, docID, fmt.Sprintf("+%d days", expiryDays)) + return token, err + } + _ = expiresAt + _, err := db.Exec(`INSERT INTO shares (id, doc_id) VALUES (?, ?)`, token, docID) + return token, err +} + +// GetShare retrieves the document for a share token, returns nil if expired/invalid +func GetShare(token string) (*Document, error) { + var docID string + var expiresAt sql.NullString + err := db.QueryRow(`SELECT doc_id, expires_at FROM shares WHERE id = ?`, token).Scan(&docID, &expiresAt) + if err != nil { + return nil, err + } + if expiresAt.Valid { + var expired bool + db.QueryRow(`SELECT datetime(?) < datetime('now')`, expiresAt.String).Scan(&expired) + if expired { + return nil, nil + } + } + return GetDocument(docID) +} + +// DeleteShare removes a share link +func DeleteShare(token string) error { + _, err := db.Exec(`DELETE FROM shares WHERE id = ?`, token) + return err +} + +// GetSharesByDocument returns active shares for a document +func GetSharesByDocument(docID string) ([]Share, error) { + rows, err := db.Query(`SELECT id, doc_id, created_at, COALESCE(expires_at, '') FROM shares WHERE doc_id = ? AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))`, docID) + if err != nil { + return nil, err + } + defer rows.Close() + var shares []Share + for rows.Next() { + var s Share + if err := rows.Scan(&s.Token, &s.DocID, &s.CreatedAt, &s.ExpiresAt); err != nil { + continue + } + shares = append(shares, s) + } + return shares, nil +} + // UpsertDocument inserts or updates a document func UpsertDocument(doc *Document) error { metaJSON, _ := json.Marshal(doc.Metadata) diff --git a/main.go b/main.go index beeebb6..c2872e3 100644 --- a/main.go +++ b/main.go @@ -99,6 +99,10 @@ func main() { r.Put("/api/document/{id}", updateDocumentHandler) r.Delete("/api/document/{id}", deleteDocumentHandler) r.Get("/api/export", exportCSVHandler) + r.Post("/api/share/{id}", createShareHandler) + r.Delete("/api/share/{token}", deleteShareHandler) + r.Get("/api/shares/{id}", listSharesHandler) + r.Get("/s/{token}", publicShareHandler) r.Post("/api/reindex", reindexHandler) r.Get("/api/debug/stats", debugStatsHandler) @@ -555,6 +559,71 @@ func debugStatsHandler(w http.ResponseWriter, r *http.Request) { }) } +func createShareHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req struct { + Days int `json:"days"` + } + req.Days = 7 // default + json.NewDecoder(r.Body).Decode(&req) + + token, err := CreateShare(id, req.Days) + if err != nil { + http.Error(w, "Failed to create share", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "token": token, + "url": "/s/" + token, + }) +} + +func deleteShareHandler(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + DeleteShare(token) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) +} + +func listSharesHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + shares, err := GetSharesByDocument(id) + if err != nil { + shares = []Share{} + } + if shares == nil { + shares = []Share{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(shares) +} + +func publicShareHandler(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + doc, err := GetShare(token) + if err != nil || doc == nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + // Serve the PDF directly + for _, ext := range []string{".pdf", ".txt", ""} { + path := filepath.Join(storeDir, doc.ID+ext) + if _, err := os.Stat(path); err == nil { + if ext == ".pdf" || ext == "" { + w.Header().Set("Content-Type", "application/pdf") + } else { + w.Header().Set("Content-Type", "text/plain") + } + http.ServeFile(w, r, path) + return + } + } + http.Error(w, "File not found", http.StatusNotFound) +} + func reindexHandler(w http.ResponseWriter, r *http.Request) { // DISABLED - this was destructive (wiped all docs without repopulating) // Old behavior cleared all docs then re-indexed markdown files (which we don't use anymore) diff --git a/templates/document.html b/templates/document.html index 23d432f..c3b5d85 100644 --- a/templates/document.html +++ b/templates/document.html @@ -32,6 +32,12 @@ Edit + {{if .Document.PDFPath}} @@ -202,6 +208,57 @@ + + +