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:
parent
a73ae5c03e
commit
9f0bac5783
85
db.go
85
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)
|
||||
|
|
|
|||
69
main.go
69
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue