···7788## [Unreleased]
991010+### Fixed
1111+- Share links: enforce expiry server-side on document, snapshot, and save endpoints. Client surfaces a blocking "link has expired" overlay; owners are never gated. (#673)
1212+1013## [0.48.0] — 2026-04-15
11141215### Added
+1-1
server/db.ts
···169169 getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'),
170170 getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'),
171171 getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'),
172172- getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'),
172172+ getSnapshot: db.prepare('SELECT snapshot, expires_at, owner FROM documents WHERE id = ?'),
173173 putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"),
174174 putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"),
175175 trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"),
+50
server/expiry.ts
···11+/**
22+ * Share link expiry logic.
33+ *
44+ * The document's `expires_at` timestamp gates anonymous and non-owner access.
55+ * The owner of a document is never subject to the expiry — this lets them
66+ * regenerate, extend, or clear the expiry even after it has passed.
77+ *
88+ * Issue #673 — enforce expiry server-side so UI controls are honored.
99+ */
1010+1111+import type { TailscaleUser } from './types.js';
1212+1313+/** Parse a SQLite-stored timestamp as UTC. */
1414+export function parseExpiresAt(raw: string | null | undefined): Date | null {
1515+ if (!raw) return null;
1616+ const iso = raw.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(raw) ? raw : raw + 'Z';
1717+ const d = new Date(iso);
1818+ if (isNaN(d.getTime())) return null;
1919+ return d;
2020+}
2121+2222+/** True when `expires_at` is set and in the past relative to `now`. */
2323+export function isExpired(
2424+ expiresAt: string | null | undefined,
2525+ now: Date = new Date(),
2626+): boolean {
2727+ const parsed = parseExpiresAt(expiresAt);
2828+ if (!parsed) return false;
2929+ return parsed.getTime() <= now.getTime();
3030+}
3131+3232+/**
3333+ * True when the document is expired AND the requesting user is not its owner.
3434+ *
3535+ * Non-owners include: anonymous/tailnet-free requests, and authenticated
3636+ * users whose login differs from the document's owner. Owners always pass.
3737+ */
3838+export function isExpiredForUser(
3939+ expiresAt: string | null | undefined,
4040+ docOwner: string | null | undefined,
4141+ user: TailscaleUser | null | undefined,
4242+ now: Date = new Date(),
4343+): boolean {
4444+ if (!isExpired(expiresAt, now)) return false;
4545+ // Anonymous owner docs (no owner recorded) are treated as expired to anyone
4646+ // — without an owner column, there is no one to bypass the gate.
4747+ if (!docOwner) return true;
4848+ if (!user) return true;
4949+ return user.login !== docOwner;
5050+}
+16-8
server/routes/documents.ts
···77import { randomUUID } from 'crypto';
88import { db, stmts } from '../db.js';
99import { isValidDocType, RateLimiter } from '../validation.js';
1010+import { isExpiredForUser } from '../expiry.js';
1011import type {
1112 TailscaleUser,
1213 UserRow,
···133134 res.json(stmts.getTrash.all() as DocumentListRow[]);
134135});
135136136136-router.get('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => {
137137+router.get('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
137138 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined;
138139 if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
140140+ if (isExpiredForUser(doc.expires_at, doc.owner, req.tsUser)) {
141141+ res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: doc.expires_at });
142142+ return;
143143+ }
139144 res.json(doc);
140145});
141146···228233 return;
229234 }
230235236236+ // Expiry check: non-owners cannot write to an expired share
237237+ if (existing && isExpiredForUser(existing.expires_at, existing.owner, req.tsUser)) {
238238+ res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: existing.expires_at });
239239+ return;
240240+ }
241241+231242 try {
232243 const result = stmts.putSnapshot.run(req.body, req.params.id);
233244 if (result.changes === 0) {
···250261router.put('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler);
251262router.post('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler);
252263253253-router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }>, res: Response) => {
264264+router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
254265 const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined;
255266 if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; }
256267257257- if (row.expires_at) {
258258- const expiresAt = new Date(row.expires_at.endsWith('Z') ? row.expires_at : row.expires_at + 'Z');
259259- if (expiresAt <= new Date()) {
260260- res.status(410).json({ error: 'Document link has expired' });
261261- return;
262262- }
268268+ if (isExpiredForUser(row.expires_at, row.owner, req.tsUser)) {
269269+ res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: row.expires_at });
270270+ return;
263271 }
264272265273 res.type('application/octet-stream').send(row.snapshot);