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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
|
@ -37,6 +39,14 @@ type Document struct {
|
||||||
Score float64 `json:",omitempty"` // semantic search relevance 0-1
|
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
|
// DocumentUpdate contains fields that can be updated
|
||||||
type DocumentUpdate struct {
|
type DocumentUpdate struct {
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -105,6 +115,16 @@ func initSchema() error {
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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;
|
DROP TABLE IF EXISTS documents_fts;
|
||||||
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
||||||
id UNINDEXED, title, summary, full_text, vendor
|
id UNINDEXED, title, summary, full_text, vendor
|
||||||
|
|
@ -386,6 +406,71 @@ func DeleteDocument(id string) error {
|
||||||
return err
|
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
|
// UpsertDocument inserts or updates a document
|
||||||
func UpsertDocument(doc *Document) error {
|
func UpsertDocument(doc *Document) error {
|
||||||
metaJSON, _ := json.Marshal(doc.Metadata)
|
metaJSON, _ := json.Marshal(doc.Metadata)
|
||||||
|
|
|
||||||
69
main.go
69
main.go
|
|
@ -99,6 +99,10 @@ func main() {
|
||||||
r.Put("/api/document/{id}", updateDocumentHandler)
|
r.Put("/api/document/{id}", updateDocumentHandler)
|
||||||
r.Delete("/api/document/{id}", deleteDocumentHandler)
|
r.Delete("/api/document/{id}", deleteDocumentHandler)
|
||||||
r.Get("/api/export", exportCSVHandler)
|
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.Post("/api/reindex", reindexHandler)
|
||||||
r.Get("/api/debug/stats", debugStatsHandler)
|
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) {
|
func reindexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// DISABLED - this was destructive (wiped all docs without repopulating)
|
// 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)
|
// Old behavior cleared all docs then re-indexed markdown files (which we don't use anymore)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@
|
||||||
</svg>
|
</svg>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</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}}
|
{{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">
|
<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">
|
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -202,6 +208,57 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Edit Modal -->
|
||||||
<div id="edit-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<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">
|
<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() {
|
async function deleteDocument() {
|
||||||
if (!confirm('Are you sure you want to delete this document?')) return;
|
if (!confirm('Are you sure you want to delete this document?')) return;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue