Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 8 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,22 +120,14 @@ __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="siva.otter-hawksbill.ts.net,.100.100.40.
The content proxy preserves the tenant Host header, so the Worker still
enforces its host-routing rules in development.

Production passcode login creates a seven-day, host-only
`__Host-adrive-session` cookie. That cookie is always `Secure`, `HttpOnly`, and
`SameSite=Strict`, so browsers correctly refuse it on plain-HTTP non-localhost
dev URLs (like a Tailscale hostname). Use the generated API key in the
dashboard's “Local HTTP fallback” there. Passcode login works on
`http://localhost` and on any HTTPS dashboard origin.

Authentication bootstrap endpoints are protected by the `AUTH_GUARD` Workers
KV namespace. Login is limited to ten attempts per client every five minutes,
and five incorrect passcodes within fifteen minutes lock that client out for
thirty minutes. Device authorization creation is limited to five requests per
client every ten minutes, while device polling permits 150 requests per client
every ten minutes so the documented five-second polling interval remains valid.
Rate-limit and lockout responses use HTTP 429 with `Retry-After`. KV is
eventually consistent, so these controls are abuse mitigation rather than an
atomic global security boundary.
The dashboard signs in through WorkOS. Device authorization creation and token
polling share the `RL_AUTH` Workers rate limit: 30 requests per client address
in a 60-second window. A device polls every five seconds, leaving room for
creation and another device behind the same address. Refused token polls return
HTTP 429 with `{ "status": "slow_down" }` and `Retry-After: 60`; the CLI waits
for that delay before polling again. Creation remains rate limited and returns
the usual error response. These counters are approximate and scoped to each
Cloudflare location. `AUTH_GUARD` KV now supports caches, not auth counters.

In another shell, configure and exercise the CLI:

Expand Down
15 changes: 15 additions & 0 deletions apps/web/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,18 @@ CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://adrive:adri
# Vite's allowlist is a SHELL setting, not a Worker variable. Start with:
# __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS="siva.otter-hawksbill.ts.net,.100.100.40.20.nip.io" bun --filter @adrive/web dev
# Adjust both allowlist entries to match your dashboard/content hostnames.

# Abuse controls (docs/abuse.md). All optional locally.
# Comma-separated WorkOS user ids allowed on /admin. The WorkOS fake signs
# you in as `user_local`, so that is the id to list here for local work.
ADMIN_USER_IDS="user_local"
# Cloudflare URL Scanner: links inside published HTML are checked against
# it. Leave empty to skip the check (recorded as such on the verdict), or
# set `fake:clean` / `fake:malicious` to exercise the pipeline offline.
# CF_ACCOUNT_ID is the account that owns the token.
URLSCAN_API_KEY=""
CF_ACCOUNT_ID=""
# Zone-level cache purge for the kill switch (Cache Purge permission on the
# content zone). Leave empty to log the purge instead.
CF_API_TOKEN=""
CF_ZONE_ID=""
35 changes: 35 additions & 0 deletions apps/web/migrations-pg/0007_scans.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- migrate:up
-- Content scanning (services/scanner.ts). A publish by a verified org is
-- held (publish_pending) until the scanner clears it; a malicious verdict
-- on any org quarantines the file, which every content route then 404s.
-- One verdict row per (file, version, check).

ALTER TABLE files ADD COLUMN quarantined boolean NOT NULL DEFAULT false;
ALTER TABLE files ADD COLUMN publish_pending boolean NOT NULL DEFAULT false;

CREATE INDEX files_held_idx ON files (org_id, updated_at DESC)
WHERE quarantined OR publish_pending;

CREATE TABLE scan_verdicts (
file_id text NOT NULL REFERENCES files (id) ON DELETE CASCADE,
org_id text NOT NULL REFERENCES orgs (id),
version integer NOT NULL CHECK (version > 0),
verdict text NOT NULL CHECK (verdict IN ('clean', 'suspicious', 'malicious')),
source text NOT NULL,
details jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (file_id, version, source)
);

CREATE INDEX scan_verdicts_review_idx ON scan_verdicts (verdict, created_at DESC)
WHERE verdict <> 'clean';

ALTER TABLE scan_verdicts ENABLE ROW LEVEL SECURITY;
ALTER TABLE scan_verdicts FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON scan_verdicts USING (app_org_visible(org_id));

-- migrate:down
DROP TABLE IF EXISTS scan_verdicts;
DROP INDEX IF EXISTS files_held_idx;
ALTER TABLE files DROP COLUMN IF EXISTS publish_pending;
ALTER TABLE files DROP COLUMN IF EXISTS quarantined;
51 changes: 51 additions & 0 deletions apps/web/migrations-pg/0008_abuse.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- migrate:up
-- Abuse controls. blocked_hashes is the first scanner check (a nightly
-- mirror of a known-bad list can append to it; so can an admin).
-- notifications tell an org's members what the scanner or an admin did to
-- their file. reports come from anyone on the content host and are worked
-- from /admin, so they are a platform table with no RLS.

CREATE TABLE blocked_hashes (
sha256 text PRIMARY KEY CHECK (sha256 ~ '^[0-9a-f]{64}$'),
reason text NOT NULL DEFAULT '',
added_by text,
added_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE notifications (
id text PRIMARY KEY,
org_id text NOT NULL REFERENCES orgs (id) ON DELETE CASCADE,
kind text NOT NULL,
message text NOT NULL,
file_id text,
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz
);

CREATE INDEX notifications_unread_idx ON notifications (org_id, created_at DESC)
WHERE read_at IS NULL;

ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE notifications FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON notifications USING (app_org_visible(org_id));

CREATE TABLE reports (
id text PRIMARY KEY,
org_id text NOT NULL,
file_id text NOT NULL,
version integer,
reason text NOT NULL,
reporter_ip_hash text NOT NULL,
details text,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
resolution text
);

CREATE INDEX reports_open_idx ON reports (created_at DESC) WHERE resolved_at IS NULL;
CREATE INDEX reports_file_idx ON reports (file_id, created_at DESC);

-- migrate:down
DROP TABLE IF EXISTS reports;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS blocked_hashes;
13 changes: 13 additions & 0 deletions apps/web/migrations-pg/0009_scan_recovery.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- migrate:up
-- Persist scan work with the version write so a lost post-commit queue
-- send can be recovered. The timestamp also fences a completed scan
-- from clearing a newer request for the same immutable version.
ALTER TABLE file_versions ADD COLUMN scan_next_run_at timestamptz;

CREATE INDEX file_versions_scan_due_idx
ON file_versions (org_id, scan_next_run_at, file_id, version)
WHERE scan_next_run_at IS NOT NULL;

-- migrate:down
DROP INDEX IF EXISTS file_versions_scan_due_idx;
ALTER TABLE file_versions DROP COLUMN IF EXISTS scan_next_run_at;
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"effect": "4.0.0-beta.102",
"markdown-it": "^14.3.0",
"mime": "^4.1.0",
"parse5": "^8.0.1",
"pg": "^8.22.0",
"runed": "^0.37.1",
"zod": "^4.4.3"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ declare global {
// Set by the root layout load for every page.
interface PageData {
session?: {
readonly user: { readonly email: string };
readonly user: { readonly email: string; readonly admin: boolean };
readonly role: AuthContext['role'];
readonly org: { readonly name: string; readonly slug: string };
} | null;
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ declare global {
WORKOS_CLIENT_ID?: string;
WORKOS_COOKIE_PASSWORD?: string;
WORKOS_WEBHOOK_SECRET?: string;
// Abuse controls (docs/abuse.md). All optional: the Null services
// stand in when unset.
ADMIN_USER_IDS?: string;
URLSCAN_API_KEY?: string;
CF_ACCOUNT_ID?: string;
CF_API_TOKEN?: string;
CF_ZONE_ID?: string;
}

namespace Cloudflare {
Expand All @@ -19,6 +26,11 @@ declare global {
WORKOS_CLIENT_ID?: string;
WORKOS_COOKIE_PASSWORD?: string;
WORKOS_WEBHOOK_SECRET?: string;
ADMIN_USER_IDS?: string;
URLSCAN_API_KEY?: string;
CF_ACCOUNT_ID?: string;
CF_API_TOKEN?: string;
CF_ZONE_ID?: string;
}
}
}
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/lib/components/Dashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,26 @@
};

const openFile = async (file: DashboardFile) => {
const opensTab = file.public || file.kind === 'site';
const tab = opensTab ? window.open('about:blank', '_blank') : null;
if (tab) tab.opener = null;
if (opensTab && !tab) {
toasts.info('Allow pop-ups to open this file');
return;
}
try {
const url = await resolveFileLink(
file,
session.token,
files.list.current.contentOrigin
);
if (file.public) {
window.open(url, '_blank', 'noopener');
if (tab) {
tab.location.replace(url);
} else {
download(url, file.displayName);
}
} catch (cause) {
tab?.close();
toasts.error(cause, 'Could not open the file');
}
};
Expand Down
74 changes: 55 additions & 19 deletions apps/web/src/lib/components/FileDetailView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,13 @@
return false;
}
}
toasts.success(result.forcedPublic ? 'HTML files stay public' : success);
toasts.success(
mutation.action === 'visibility' && result.file.publishPending
? 'Pending review before public sharing'
: result.forcedPublic
? 'HTML files stay public'
: success
);
return true;
} catch (cause) {
if (
Expand All @@ -155,17 +161,24 @@

const linkFor = async (version?: number) => {
const current = detail.current;
if (!current) return { url: '', expiresAt: null };
if (!current) return { url: '', expiresAt: null, public: false };
if (current.file.quarantined) {
throw new Error('Sharing is unavailable while this file is quarantined');
}
const fileId = current.file.id;
const expirationTime = current.file.expiresAt
? new Date(current.file.expiresAt).getTime()
: Number.NaN;
const unavailable =
current.file.deletedAt !== null ||
(Number.isFinite(expirationTime) && expirationTime <= Date.now());
// Historical bytes have their own scan clearance. Resolve those
// through the API so an owner grant can cover an unreviewed version.
const currentVersion =
version === undefined || version === current.file.version;
const link =
current.file.public && !unavailable
? { url: fileUrl(version), expiresAt: null }
current.file.public && !unavailable && currentVersion
? { url: fileUrl(version), expiresAt: null, public: true }
: getContentLink(
session.token,
current.file.id,
Expand All @@ -189,7 +202,7 @@
const fileId = current.file.id;
const link = await linkFor(version);
if (detail.current?.file.id !== fileId) return;
if (current.file.public) {
if (link.public || current.file.kind === 'site') {
window.open(link.url, '_blank', 'noopener');
} else {
const anchor = document.createElement('a');
Expand Down Expand Up @@ -274,7 +287,7 @@

const putVersion = async (file: File) => {
const current = detail.current;
if (!current || busy) return;
if (!current || busy || current.file.quarantined) return;
const fileId = current.file.id;
const currentOperation = ++operation;
if (file.size > current.maxUploadBytes) {
Expand Down Expand Up @@ -347,27 +360,50 @@
<Icon name="arrow-left" />
Files
</a>
<FileName
file={detail.current.file}
{busy}
onrename={(displayName) =>
void update({ action: 'rename', displayName }, 'File renamed')}
/>
{#if detail.current.file.quarantined}
<h1
class="min-w-0 truncate px-2 py-1 text-xl font-semibold tracking-tight text-zinc-950"
>
{detail.current.file.displayName}
</h1>
{:else}
<FileName
file={detail.current.file}
{busy}
onrename={(displayName) =>
void update({ action: 'rename', displayName }, 'File renamed')}
/>
{/if}
{#if detail.current.file.deletedAt}
<span class="rounded-full bg-amber-50 px-2 py-1 text-xs text-amber-700">
In trash
</span>
{/if}
<CopyButton variant="ghost" resolve={() => resolveLink()} />
<CopyButton
variant="ghost"
resolve={() => resolveLink()}
disabled={detail.current.file.quarantined}
/>
</header>

<div class="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
<FilePreview
file={detail.current.file}
token={session.token}
contentOrigin={detail.current.contentOrigin}
ondownload={() => void openLink()}
/>
{#if detail.current.file.quarantined}
<section
class="flex min-h-[28rem] items-center justify-center overflow-hidden rounded-xl border border-zinc-200 bg-white p-8 text-center"
aria-label="File preview"
>
<p class="text-sm text-zinc-500">
Preview unavailable while this file is quarantined.
</p>
</section>
{:else}
<FilePreview
file={detail.current.file}
token={session.token}
contentOrigin={detail.current.contentOrigin}
ondownload={() => void openLink()}
/>
{/if}
<FileSidebar
file={detail.current.file}
versions={detail.current.versions}
Expand Down
Loading
Loading