Skip to content

Commit 992efb5

Browse files
committed
feat(public): Public/Open spaces — fast, embeddable raw media URLs
A new space type for hosting public images/videos with direct URLs that load as fast as possible (e.g. to paste into other sites that want an image/video URL). - Model: EncMode gains PUBLIC (plaintext at rest); Folder.isPublic marks the space; FileObject.publicSlug is the stable URL token. Public and ZK are mutually exclusive; subfolders inherit the space type. - Ingest: ingestPublic stores plaintext (still antivirus-scanned) instead of AES-GCM, so bytes are range-servable and load without decryption. - Serving: GET /p/<slug>[/name] — unauthenticated, with HTTP range (206) for video/audio seeking, ETag revalidation (304), long immutable caching, and permissive CORS + Cross-Origin-Resource-Policy: cross-origin so the media embeds on any site (overrides Helmet's same-origin CORP). Mounted outside the browser CORS scope. Storage driver gains range reads. - Upload: files dropped into a Public space are stored plaintext and get a slug; the authenticated download path serves PUBLIC files raw too. - Web: "Public / Open" space type at creation, a Globe badge, a public notice banner, and per-file "Copy public URL" / "Open in a tab". - Docs (in-app + README) and tests (plaintext storage, headers, range, suffix range, ETag/304, bad range/416, trashed→404, ZK+public rejected).
1 parent f09aa06 commit 992efb5

19 files changed

Lines changed: 424 additions & 16 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ the source.
3737
- **Hybrid encryption** — AES-256-GCM at rest by default (so files can be scanned), plus an
3838
opt-in zero-knowledge vault whose contents are encrypted in the browser and never
3939
readable by the server.
40+
- **Public / Open spaces** — a space whose files are stored unencrypted and served at a
41+
direct, stable URL (`/p/<code>`) with range requests and long caching — for hosting public
42+
images/videos to embed on other sites, loading as fast as possible.
4043
- **Administration** — create users, set per-user quotas, define the global cap, manage
4144
Quick-Upload codes and read the audit log. One-click (or **automatic**) self-update from
4245
GitHub, with a "What's new" dialog that shows each user the release notes once per update.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- AlterEnum
2+
ALTER TYPE "EncMode" ADD VALUE 'PUBLIC';
3+
4+
-- AlterTable
5+
ALTER TABLE "Folder" ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false;
6+
7+
-- AlterTable
8+
ALTER TABLE "FileObject" ADD COLUMN "publicSlug" TEXT;
9+
10+
-- CreateIndex
11+
CREATE UNIQUE INDEX "FileObject_publicSlug_key" ON "FileObject"("publicSlug");

apps/api/prisma/schema.prisma

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ enum Role {
1818
enum EncMode {
1919
SERVER // server-side envelope encryption at rest (scannable)
2020
ZK // zero-knowledge: encrypted client-side, server is blind
21+
PUBLIC // stored as plaintext, served publicly & fast (no encryption) — for public media hosting
2122
}
2223

2324
enum AvStatus {
@@ -168,6 +169,9 @@ model Folder {
168169
space SharedSpace? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
169170
name String
170171
isZeroKnowledge Boolean @default(false)
172+
// When set, this is a Public/Open space: files are stored as plaintext and served publicly at
173+
// /p/<slug> for fast, embeddable media hosting (no encryption). Mutually exclusive with ZK.
174+
isPublic Boolean @default(false)
171175
// Per-vault random salt for the client's PBKDF2 key derivation (ZK folders only).
172176
// Not secret; storing it per vault defeats cross-vault precomputation. null = legacy.
173177
zkSalt String?
@@ -206,6 +210,11 @@ model FileObject {
206210
storageKey String @unique
207211
encMode EncMode @default(SERVER)
208212
213+
// ── PUBLIC mode ──
214+
// Stable, opaque slug for the public URL /p/<publicSlug>. Set only for PUBLIC files (files in a
215+
// Public space); null otherwise. The stored blob is plaintext so it can be range-served fast.
216+
publicSlug String? @unique
217+
209218
// ── SERVER mode crypto material ──
210219
// base64 DEK wrapped by the deployment MASTER_KEY; null for ZK files.
211220
wrappedKey String?

apps/api/src/lib/serialize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function toPublicFolder(f: Folder): PublicFolder {
6868
name: f.name,
6969
parentId: f.parentId,
7070
isZeroKnowledge: f.isZeroKnowledge,
71+
isPublic: f.isPublic,
7172
zkSalt: f.zkSalt,
7273
zkVerifier: f.zkVerifier,
7374
createdAt: f.createdAt.toISOString(),
@@ -86,6 +87,7 @@ export function toPublicFile(f: FileObject): PublicFile {
8687
encMode: f.encMode,
8788
avStatus: f.avStatus,
8889
sha256: f.sha256,
90+
publicSlug: f.publicSlug,
8991
createdAt: f.createdAt.toISOString(),
9092
};
9193
}

apps/api/src/routes/files.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
4545
return { files: files.map(toPublicFile) };
4646
});
4747

48-
// POST /files?folderId= — streaming, server-side-encrypted upload.
48+
// POST /files?folderId= — streaming upload. Server-side encrypted normally; stored plaintext
49+
// (and given a public URL) when the target folder is a Public/Open space.
4950
app.post('/', async (req, reply) => {
5051
const { folderId } = req.query as { folderId?: string };
5152

53+
let isPublicFolder = false;
5254
if (folderId) {
5355
const folder = await prisma.folder.findFirst({
5456
where: { id: folderId, ownerId: req.user!.id, spaceId: null },
@@ -59,20 +61,22 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
5961
.code(400)
6062
.send({ error: 'Use the Zero-Knowledge upload endpoint for vault folders' });
6163
}
64+
isPublicFolder = folder.isPublic;
6265
}
6366

6467
const part = await req.file();
6568
if (!part) return reply.code(400).send({ error: 'No file provided' });
6669

6770
try {
68-
// Shared pipeline: quota + antivirus + server-side encryption + text-file versioning,
69-
// plus outgoing-webhook dispatch — identical to the REST API and WebDAV.
71+
// Shared pipeline: quota + antivirus + (encryption or plaintext-for-public) + text-file
72+
// versioning, plus outgoing-webhook dispatch — identical to the REST API and WebDAV.
7073
const { file, versioned } = await storeUserFile(app.ctx, {
7174
ownerId: req.user!.id,
7275
folderId: folderId ?? null,
7376
stream: part.file,
7477
filename: part.filename,
7578
mimetype: part.mimetype,
79+
public: isPublicFolder,
7680
});
7781
await audit(req, versioned ? 'file.version' : 'file.upload', { target: file.id });
7882
return reply.code(201).send({ file: toPublicFile(file) });
@@ -110,7 +114,12 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
110114
'Content-Disposition',
111115
`attachment; filename="${encodeURIComponent(file.name)}"`,
112116
);
113-
return reply.send(decryptServerFile(app.ctx, file));
117+
// PUBLIC files are stored plaintext (Public/Open space); everything else is decrypted.
118+
return reply.send(
119+
file.encMode === 'PUBLIC'
120+
? app.ctx.storage.createReadStream(file.storageKey)
121+
: decryptServerFile(app.ctx, file),
122+
);
114123
});
115124

116125
// PATCH /files/:id — rename and/or move a SERVER-mode file.

apps/api/src/routes/folders.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,31 @@ export const folderRoutes: FastifyPluginAsync = async (app) => {
3737
const body = parseOr400(reply, createFolderSchema, req.body);
3838
if (!body) return;
3939

40+
// A subfolder inherits its parent's space type (ZK / Public), so you can't mix modes in a tree.
41+
let inheritZk = false;
42+
let inheritPublic = false;
4043
if (body.parentId) {
4144
const parent = await prisma.folder.findFirst({
4245
where: { id: body.parentId, ownerId: req.user!.id, spaceId: null },
4346
});
4447
if (!parent) return reply.code(404).send({ error: 'Parent folder not found' });
48+
inheritZk = parent.isZeroKnowledge;
49+
inheritPublic = parent.isPublic;
4550
}
4651

47-
const isZk = body.isZeroKnowledge ?? false;
52+
const isZk = inheritPublic ? false : (body.isZeroKnowledge ?? false) || inheritZk;
53+
const isPublic = inheritZk ? false : (body.isPublic ?? false) || inheritPublic;
54+
if (isZk && isPublic) {
55+
return reply.code(400).send({ error: 'A space cannot be both Zero-Knowledge and Public' });
56+
}
4857
try {
4958
const folder = await prisma.folder.create({
5059
data: {
5160
ownerId: req.user!.id,
5261
parentId: body.parentId ?? null,
5362
name: body.name,
5463
isZeroKnowledge: isZk,
64+
isPublic,
5565
// Per-vault salt: prefer the client's (so it can derive the key before the round-trip),
5666
// else a fresh server one. Independent per vault either way.
5767
zkSalt: isZk ? (body.zkSalt ?? randomToken(16)) : null,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Public media hosting for Public/Open spaces. Serves the PLAINTEXT bytes of a file at a stable,
3+
* unauthenticated URL — /p/<slug> (an optional trailing /<name> is cosmetic, so consumers that
4+
* sniff the extension are happy). Built for embedding raw images/videos on other sites:
5+
*
6+
* - no auth, no decryption → fast;
7+
* - HTTP range requests (206) so <video>/<audio> seeking works;
8+
* - long immutable caching (each slug maps to one, never-rewritten blob) + ETag revalidation;
9+
* - permissive CORS (Access-Control-Allow-Origin: *) so cross-origin fetch/canvas use works too.
10+
*
11+
* Mounted OUTSIDE the browser CORS scope (like WebDAV) so the wildcard CORS here isn't overridden.
12+
*/
13+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
14+
import { prisma } from '../db.js';
15+
16+
const ONE_YEAR = 31_536_000;
17+
18+
/** Parse a single-range `Range: bytes=…` header against a known size. Returns null if absent,
19+
* or 'invalid' if present but unsatisfiable. */
20+
function parseRange(header: string | undefined, size: number): { start: number; end: number } | null | 'invalid' {
21+
if (!header) return null;
22+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
23+
if (!m) return 'invalid';
24+
const [, rawStart, rawEnd] = m;
25+
let start: number;
26+
let end: number;
27+
if (rawStart === '') {
28+
// Suffix range: last N bytes.
29+
const n = Number(rawEnd);
30+
if (!n) return 'invalid';
31+
start = Math.max(0, size - n);
32+
end = size - 1;
33+
} else {
34+
start = Number(rawStart);
35+
end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
36+
}
37+
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) return 'invalid';
38+
return { start, end };
39+
}
40+
41+
export const publicMediaRoutes: FastifyPluginAsync = async (app) => {
42+
async function serve(req: FastifyRequest, reply: FastifyReply) {
43+
const { slug } = req.params as { slug: string };
44+
const file = await prisma.fileObject.findFirst({
45+
where: { publicSlug: slug, encMode: 'PUBLIC', deletedAt: null },
46+
});
47+
48+
// Common headers (also on 404 so cross-origin callers get a clean answer). CORP must be
49+
// overridden to cross-origin, otherwise Helmet's global same-origin value blocks other sites
50+
// from embedding the media in <img>/<video>.
51+
reply.header('Access-Control-Allow-Origin', '*').header('Cross-Origin-Resource-Policy', 'cross-origin');
52+
if (!file) return reply.code(404).header('Cache-Control', 'no-store').send('Not found');
53+
54+
const size = Number(file.sizeBytes);
55+
const etag = file.sha256 ? `"${file.sha256}"` : undefined;
56+
57+
reply
58+
.header('Content-Type', file.mimeType)
59+
.header('Accept-Ranges', 'bytes')
60+
.header('Cache-Control', `public, max-age=${ONE_YEAR}, immutable`)
61+
.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.name)}"`);
62+
if (etag) reply.header('ETag', etag);
63+
64+
// Conditional request: unchanged → 304 (no body).
65+
if (etag && req.headers['if-none-match'] === etag) {
66+
return reply.code(304).send();
67+
}
68+
69+
const range = parseRange(req.headers.range, size);
70+
if (range === 'invalid') {
71+
return reply.code(416).header('Content-Range', `bytes */${size}`).send('Range Not Satisfiable');
72+
}
73+
74+
if (range) {
75+
const length = range.end - range.start + 1;
76+
reply
77+
.code(206)
78+
.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`)
79+
.header('Content-Length', length);
80+
if (req.method === 'HEAD') return reply.send();
81+
return reply.send(app.ctx.storage.createReadStream(file.storageKey, { start: range.start, end: range.end }));
82+
}
83+
84+
reply.header('Content-Length', size);
85+
if (req.method === 'HEAD') return reply.send();
86+
return reply.send(app.ctx.storage.createReadStream(file.storageKey));
87+
}
88+
89+
// Preflight for cross-origin fetch/XHR use (plain <img>/<video> loads need no preflight).
90+
const preflight = (_req: FastifyRequest, reply: FastifyReply) =>
91+
reply
92+
.header('Access-Control-Allow-Origin', '*')
93+
.header('Cross-Origin-Resource-Policy', 'cross-origin')
94+
.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
95+
.header('Access-Control-Max-Age', '86400')
96+
.code(204)
97+
.send();
98+
99+
app.get('/:slug', serve);
100+
app.get('/:slug/:name', serve); // cosmetic filename (nice extension in the URL)
101+
app.options('/:slug', preflight);
102+
app.options('/:slug/:name', preflight);
103+
};

apps/api/src/server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { shareRoutes } from './routes/shares.js';
1919
import { spaceRoutes } from './routes/spaces.js';
2020
import { versionRoutes } from './routes/version.js';
2121
import { sharePublicRoutes } from './routes/share-public.js';
22+
import { publicMediaRoutes } from './routes/public-media.js';
2223
import { twoFactorRoutes } from './routes/twofa.js';
2324
import { accountRoutes } from './routes/account.js';
2425
import { adminRoutes } from './routes/admin.js';
@@ -73,6 +74,10 @@ export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
7374
// answer every WebDAV OPTIONS with 400/204, hiding the DAV capability headers clients need.
7475
await app.register(webdavRoutes, { prefix: '/dav' });
7576

77+
// Public media (Public/Open spaces) is also mounted outside the browser CORS scope so its own
78+
// wildcard `Access-Control-Allow-Origin: *` (for embedding on any site) isn't overridden.
79+
await app.register(publicMediaRoutes, { prefix: '/p' });
80+
7681
// Everything browser-facing is wrapped so CORS applies only here (not to WebDAV).
7782
await app.register(async (web) => {
7883
await web.register(cors, { origin: ctx.env.APP_URL, credentials: true });

apps/api/src/services/ingest.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,34 @@ export async function ingestPlaintext(
111111
await cleanup();
112112
}
113113
}
114+
115+
export interface PublicIngestResult {
116+
storageKey: string;
117+
sizeBytes: number;
118+
sha256: string;
119+
avStatus: AvStatus;
120+
}
121+
122+
/**
123+
* Ingest for a Public/Open space: spool + antivirus-scan, then store the bytes as PLAINTEXT (no
124+
* encryption). This is deliberate — public media must be range-servable and load fast, which
125+
* AES-GCM ciphertext (non-seekable, verify-on-final) can't do. Still scanned so a public URL
126+
* can't be turned into malware hosting.
127+
*/
128+
export async function ingestPublic(
129+
ctx: AppContext,
130+
source: Readable,
131+
opts: { maxBytes: number; storageKey: string },
132+
): Promise<PublicIngestResult> {
133+
const { path, cleanup, sizeBytes, sha256 } = await spool(source, opts.maxBytes);
134+
try {
135+
const scan = await ctx.scanner.scanStream(createReadStream(path));
136+
if (scan.status === 'INFECTED') {
137+
throw new InfectedFileError(scan.signature ?? 'unknown');
138+
}
139+
await ctx.storage.write(opts.storageKey, createReadStream(path));
140+
return { storageKey: opts.storageKey, sizeBytes, sha256, avStatus: scan.status };
141+
} finally {
142+
await cleanup();
143+
}
144+
}

apps/api/src/services/upload.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import type { Readable } from 'node:stream';
77
import type { FileObject } from '@prisma/client';
88
import { prisma } from '../db.js';
99
import type { AppContext } from '../context.js';
10+
import { randomToken } from '@opencoperlock/shared';
1011
import { newStorageKey } from '../storage/index.js';
11-
import { ingestPlaintext } from './ingest.js';
12+
import { ingestPlaintext, ingestPublic } from './ingest.js';
1213
import { adjustUsage, remainingAllowance } from './quota.js';
1314
import { findVersionTarget, isVersionable, pruneVersions, snapshotVersion } from './versioning.js';
1415
import { dispatchFileEvent } from './webhooks.js';
@@ -27,6 +28,8 @@ export interface StoreFileOpts {
2728
mimetype: string;
2829
/** When set, the file belongs to a Shared Space (ownerId is the space owner, who is billed). */
2930
spaceId?: string | null;
31+
/** When true, store PLAINTEXT (Public/Open space) and mint a public URL slug. */
32+
public?: boolean;
3033
}
3134

3235
export interface StoreFileResult {
@@ -41,6 +44,29 @@ export async function storeUserFile(ctx: AppContext, opts: StoreFileOpts): Promi
4144

4245
const storageKey = newStorageKey();
4346
try {
47+
// Public/Open space: store plaintext, no versioning, mint a stable URL slug.
48+
if (opts.public) {
49+
const result = await ingestPublic(ctx, opts.stream, { maxBytes: allowance, storageKey });
50+
const file = await prisma.fileObject.create({
51+
data: {
52+
ownerId: opts.ownerId,
53+
folderId: opts.folderId,
54+
spaceId: opts.spaceId ?? null,
55+
name: opts.filename,
56+
sizeBytes: BigInt(result.sizeBytes),
57+
mimeType: opts.mimetype,
58+
storageKey: result.storageKey,
59+
encMode: 'PUBLIC',
60+
publicSlug: randomToken(9), // ~12 url-safe chars
61+
sha256: result.sha256,
62+
avStatus: result.avStatus,
63+
},
64+
});
65+
await adjustUsage(opts.ownerId, result.sizeBytes);
66+
void dispatchFileEvent(opts.ownerId, file, 'file.created');
67+
return { file, versioned: false };
68+
}
69+
4470
const result = await ingestPlaintext(ctx, opts.stream, { maxBytes: allowance, storageKey });
4571

4672
const spaceId = opts.spaceId ?? null;

0 commit comments

Comments
 (0)