Add document sharing with expiring links

- Share table with random tokens and optional expiry (default 7 days)
- Public /s/{token} endpoint serves PDF directly
- Share/revoke UI on document page with copy-to-clipboard
- Caddy reverse proxy configured at docs.jongsma.me
This commit is contained in:
James 2026-02-09 11:28:21 -05:00
parent a73ae5c03e
commit 9f0bac5783
3 changed files with 262 additions and 0 deletions

85
db.go
View File

@ -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)

69
main.go
View File

@ -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)

View File

@ -32,6 +32,12 @@
</svg>
Edit
</button>
<button onclick="openShareModal()" class="inline-flex items-center px-3 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path>
</svg>
Share
</button>
{{if .Document.PDFPath}}
<a href="/pdf/{{.Document.ID}}" target="_blank" class="inline-flex items-center px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-all">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -202,6 +208,57 @@
</div>
</div>
<!-- Share Modal -->
<div id="share-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-gray-500/75 dark:bg-gray-900/80" onclick="closeShareModal()"></div>
<div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl max-w-lg w-full p-6 animate-fade-in">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Share Document</h3>
<button onclick="closeShareModal()" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Create new share -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Expiry</label>
<div class="flex gap-2">
<select id="share-days" class="flex-1 px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 border-0 focus:ring-2 focus:ring-brand-500 text-sm">
<option value="1">1 day</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="0">Permanent</option>
</select>
<button onclick="createShare()" class="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium rounded-lg transition-colors">
Create Link
</button>
</div>
</div>
<!-- New share result -->
<div id="share-result" class="hidden mb-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<p class="text-sm text-green-700 dark:text-green-300 mb-2">Share link created!</p>
<div class="flex gap-2">
<input id="share-url" type="text" readonly class="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<button onclick="copyShareUrl()" class="px-3 py-2 bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-500 text-sm rounded-lg transition-colors">Copy</button>
</div>
</div>
<!-- Existing shares -->
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Active Shares</h4>
<div id="shares-list" class="space-y-2">
<p class="text-sm text-gray-400 italic">Loading...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div id="edit-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
@ -347,6 +404,57 @@
}
});
// Share functions
function openShareModal() {
document.getElementById('share-modal').classList.remove('hidden');
document.getElementById('share-result').classList.add('hidden');
loadShares();
}
function closeShareModal() {
document.getElementById('share-modal').classList.add('hidden');
}
async function createShare() {
const days = parseInt(document.getElementById('share-days').value);
const res = await fetch('/api/share/{{.Document.ID}}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({days: days})
});
if (res.ok) {
const data = await res.json();
const fullUrl = window.location.origin + data.url;
document.getElementById('share-url').value = fullUrl;
document.getElementById('share-result').classList.remove('hidden');
loadShares();
}
}
function copyShareUrl() {
const input = document.getElementById('share-url');
navigator.clipboard.writeText(input.value);
}
async function loadShares() {
const res = await fetch('/api/shares/{{.Document.ID}}');
const shares = await res.json();
const list = document.getElementById('shares-list');
if (!shares || shares.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400 italic">No active shares</p>';
return;
}
list.innerHTML = shares.map(s => `
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-sm">
<span class="font-mono text-gray-600 dark:text-gray-300">${window.location.origin}/s/${s.Token}</span>
<span class="text-gray-400 ml-2">${s.ExpiresAt ? 'expires ' + s.ExpiresAt : 'permanent'}</span>
</div>
<button onclick="revokeShare('${s.Token}')" class="text-red-500 hover:text-red-700 text-sm">Revoke</button>
</div>
`).join('');
}
async function revokeShare(token) {
await fetch('/api/share/' + token, {method: 'DELETE'});
loadShares();
}
async function deleteDocument() {
if (!confirm('Are you sure you want to delete this document?')) return;