diff --git a/website/snap/README.md b/website/snap/README.md index d50371aa85..618b7d57f8 100644 --- a/website/snap/README.md +++ b/website/snap/README.md @@ -4,12 +4,12 @@ - a public, App Store-style catalog for CapOS users; - Snap Store compatible `/v2/*` federation endpoints and proxied upstream downloads; -- a PIN-protected `/admin` console for repository versions, upstream order, local packages and CapOS metadata. +- a Cloudflare Access-protected `/admin` console for repository versions, upstream order, local packages and CapOS metadata. ## Architecture -- **Cloudflare Worker**: API, authentication, upstream federation, streaming proxy and authenticated package upload controller. -- **D1**: repository versions, ordered upstreams, local Snap metadata, sessions and audit log. +- **Cloudflare Worker**: API, Access JWT verification, upstream federation, streaming proxy and authenticated package upload controller. +- **D1**: repository versions, ordered upstreams, local Snap metadata and audit log. - **Cloudflare R2 / `repo.capos.top`**: the existing `capos` bucket stores local artifacts at `//snaps/*.snap`. - **Canonical/other upstreams**: queried in priority order. Their Snap downloads are proxied in real time and are never mirrored into R2. @@ -24,7 +24,7 @@ npm install npm run dev ``` -Vite development mode uses representative catalog/admin data so the UI can be developed without production credentials. Any non-empty PIN unlocks the local admin preview. +Vite development mode uses representative catalog/admin data so the UI can be developed without production credentials. ## Production setup @@ -34,19 +34,11 @@ The production bootstrap is intentionally one command: npm run setup:production ``` -It authenticates Wrangler if necessary, reuses or creates the `capos-snap-store` D1 database in APAC, writes its non-secret database ID into `wrangler.toml`, applies migrations, prompts for the administrator PIN, generates `SESSION_SECRET` and `ADMIN_PIN_HASH`, builds and deploys the Worker, binds the existing `capos` R2 bucket, creates the `snap.capos.top` custom domain, and verifies the public endpoints. +It authenticates Wrangler if necessary, reuses or creates the `capos-snap-store` D1 database in APAC, writes its non-secret database ID into `wrangler.toml`, applies migrations, builds and deploys the Worker, binds the existing `capos` R2 bucket, creates the `snap.capos.top` custom domain, and verifies the public endpoints. -The only interactive steps are Cloudflare authorization in the browser and choosing the administrator PIN. The generated session secret and PIN hash are sent to Cloudflare through Wrangler and are never written to the repository. +Production admin access is enforced by the `CapOS Snap Admin` Cloudflare Access application. It protects `/admin`, `/admin/*`, `/api/admin` and `/api/admin/*`; the Worker also validates the Access JWT audience before serving admin APIs. The public storefront and `/embed` stay outside Access. -The resulting D1 database ID is not secret and should be committed after the first production setup. - -Generate the administrator hash without putting the PIN into the repository: - -```sh -node -e "const c=require('crypto'); const pin=process.env.PIN; const secret=process.env.SESSION_SECRET; console.log(c.createHash('sha256').update(pin+':'+secret).digest('hex'))" -``` - -The admin session is an HttpOnly/Secure/SameSite=Strict cookie. Five failed PIN attempts from one IP lock authentication for 15 minutes. +The resulting D1 database ID and Access audience ID are not secrets and should be committed after the first production setup. ## R2 uploads diff --git a/website/snap/migrations/0002_drop_legacy_admin_auth.sql b/website/snap/migrations/0002_drop_legacy_admin_auth.sql new file mode 100644 index 0000000000..420257ec89 --- /dev/null +++ b/website/snap/migrations/0002_drop_legacy_admin_auth.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS admin_sessions; +DROP TABLE IF EXISTS login_failures; diff --git a/website/snap/scripts/setup-production.sh b/website/snap/scripts/setup-production.sh index ba311bafa8..f985388912 100755 --- a/website/snap/scripts/setup-production.sh +++ b/website/snap/scripts/setup-production.sh @@ -62,36 +62,12 @@ echo "D1 database: $DB_ID" say "Applying D1 migrations" npx wrangler d1 migrations apply capos-snap-store --remote -say "Preparing administrator credentials" -read -rsp "Choose the administrator PIN: " ADMIN_PIN -echo -read -rsp "Confirm the administrator PIN: " ADMIN_PIN_CONFIRM -echo -if [[ -z "$ADMIN_PIN" || "$ADMIN_PIN" != "$ADMIN_PIN_CONFIRM" ]]; then - echo "PINs did not match or were empty." >&2 - exit 1 -fi - -SESSION_SECRET="$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" -ADMIN_PIN_HASH="$(PIN="$ADMIN_PIN" SECRET="$SESSION_SECRET" node -e 'console.log(require("crypto").createHash("sha256").update(process.env.PIN+":"+process.env.SECRET).digest("hex"))')" -unset ADMIN_PIN ADMIN_PIN_CONFIRM - -SECRETS_FILE="$(mktemp)" -trap 'rm -f "$SECRETS_FILE"' EXIT -chmod 600 "$SECRETS_FILE" -SESSION_SECRET="$SESSION_SECRET" ADMIN_PIN_HASH="$ADMIN_PIN_HASH" node >"$SECRETS_FILE" <<'NODE' -process.stdout.write(JSON.stringify({ - SESSION_SECRET: process.env.SESSION_SECRET, - ADMIN_PIN_HASH: process.env.ADMIN_PIN_HASH, -})); -NODE - say "Building the Snap Store" npm ci --no-audit --no-fund npm run build say "Deploying capos-snap" -npx wrangler deploy --secrets-file "$SECRETS_FILE" +npx wrangler deploy if [[ "${SKIP_VERIFY:-0}" != "1" ]]; then say "Verifying public endpoints" @@ -134,6 +110,6 @@ Production setup is complete. Admin: https://snap.capos.top/admin D1 ID: $DB_ID -The generated SESSION_SECRET and PIN hash were uploaded directly to Cloudflare and were not saved in the repository. +The admin UI and API are protected by Cloudflare Access; no application-level PIN secret is required. The D1 database ID was written to website/snap/wrangler.toml; it is not a secret and should be committed. EOF diff --git a/website/snap/src/App.tsx b/website/snap/src/App.tsx index e8b0f6bea6..f545b077f6 100644 --- a/website/snap/src/App.tsx +++ b/website/snap/src/App.tsx @@ -7,7 +7,7 @@ import { LayoutGrid, LockKeyhole, LogOut, Menu, Package, Plus, Search, Server, Settings, ShieldCheck, Sparkles, Star, Store, UploadCloud, Users, X } from 'lucide-react'; -import { abortPackageUpload, createUpstream, createVersion, finalizePackage, getAdminState, getAppDetails, getCatalog, getStorefront, login, logout, requestPackageUpload, saveUpstreams, searchApps, uploadPackagePart } from './api'; +import { abortPackageUpload, createUpstream, createVersion, finalizePackage, getAdminState, getAppDetails, getCatalog, getStorefront, requestPackageUpload, saveUpstreams, searchApps, uploadPackagePart } from './api'; import type { AdminState, StoreApp, StorefrontData, Upstream } from './types'; import { useWebDesktopBridge } from './webdesktop'; @@ -276,20 +276,15 @@ function Storefront() { {webdesktop.error &&
{webdesktop.error}
}{selected && void performAction(selected, true)}/>}; } -function AdminLogin({ onSuccess }: { onSuccess: () => void }) { - const [pin, setPin] = useState(''); const [error, setError] = useState(''); const [busy,setBusy] = useState(false); - async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(''); try { await login(pin); onSuccess(); } catch (err) { setError(err instanceof Error ? err.message : 'Authentication failed'); } finally { setBusy(false); } } - return
CapOS Store Admin

Store administration

Enter the administrator PIN to manage packages, upstreams and releases.

{error &&
{error}
}
Protected by rate limiting and secure sessions
; -} - type AdminTab = 'overview'|'packages'|'upstreams'|'versions'|'apps'|'settings'; -function AdminDashboard({ onLock }: { onLock: () => void }) { - const [state,setState] = useState(null); const [tab,setTab] = useState('overview'); const [version,setVersion] = useState('rolling'); const [notice,setNotice] = useState(''); - useEffect(() => { getAdminState(version).then(setState).catch(() => onLock()); }, [version]); +function AdminDashboard() { + const [state,setState] = useState(null); const [tab,setTab] = useState('overview'); const [version,setVersion] = useState('rolling'); const [notice,setNotice] = useState(''); const [error,setError] = useState(''); + useEffect(() => { setState(null); setError(''); getAdminState(version).then(setState).catch(err => setError(err instanceof Error ? err.message : 'Could not load store state.')); }, [version]); + if (error) return
Cloudflare Access is active, but the store state could not be loaded: {error}
; if (!state) return
Loading store state…
; const navigation: [AdminTab,string,React.ReactNode][] = [['overview','Overview',],['packages','Local packages',],['upstreams','Upstreams',],['versions','Versions',],['apps','App metadata',],['settings','Settings',]]; - return
+ return
Store / {navigation.find(n=>n[0]===tab)?.[1]}
View store
{notice &&
{notice}
}{tab==='overview'&&} {tab==='packages'&&} {tab==='upstreams'&&} {tab==='versions'&&} {tab==='apps'&&} {tab==='settings'&&}
; } @@ -310,8 +305,8 @@ function Versions({state,setState,version,setVersion,onNotice}:{state:AdminState async function add(){if(!name||!label)return;setBusy(true);setError('');try{const created=await createVersion({name,label,copyFrom:version});setState({...state,versions:[created,...state.versions],stats:{...state.stats,versions:state.stats.versions+1}});setVersion(created.name);setShow(false);setName('');setLabel('');onNotice(`${created.label} created and selected`);}catch(err){setError(err instanceof Error?err.message:'Could not create repository version');}finally{setBusy(false)}} return <>setShow(true)}>New version}/>
{state.versions.map(v=>
{v.frozen?'Frozen':v.name===version?'Selected':'Active'}

{v.label}

{v.name}
{v.appCount} local apps{v.frozen?'Read only':'Accepting changes'}
)}
{show&&

Create repository version

Create an independent Snap repository view. Upstream source order is copied from {version}; local packages start empty.

{error&&
{error}
}
} } function Metadata({state}:{state:AdminState}) { return <>
{}} compact/>
{state.localPackages.map(app=>)}
AppIntegrationPublisherSourceVisibility
{app.displayName}{app.summary}
{app.webdesktop||'Unknown'}{app.publisher}{app.sourceName}Visible
} -function StoreSettings(){return <>

General

Security

} +function StoreSettings(){return <>

General

Security

} -function AdminApp(){const [authed,setAuthed]=useState(import.meta.env.DEV&&sessionStorage.getItem('capos-admin-dev')==='1'); return authed?setAuthed(false)}/>:setAuthed(true)}/>} +function AdminApp(){return } export function App(){const path=window.location.pathname;return path.startsWith('/admin')?:} diff --git a/website/snap/src/api.ts b/website/snap/src/api.ts index 99c7041ec0..29f30988b0 100644 --- a/website/snap/src/api.ts +++ b/website/snap/src/api.ts @@ -35,20 +35,6 @@ export async function searchApps(query: string, version = 'rolling'): Promise { - if (import.meta.env.DEV) { - if (!pin.trim()) throw new Error('Enter an administrator PIN.'); - sessionStorage.setItem('capos-admin-dev', '1'); - return; - } - await request('/api/admin/auth', { method: 'POST', body: JSON.stringify({ pin }) }); -} - -export async function logout(): Promise { - if (import.meta.env.DEV) sessionStorage.removeItem('capos-admin-dev'); - else await request('/api/admin/logout', { method: 'POST', body: '{}' }); -} - export async function getAdminState(version = 'rolling'): Promise { if (import.meta.env.DEV) return mockAdmin; return request(`/api/admin/state?version=${encodeURIComponent(version)}`); diff --git a/website/snap/src/styles.css b/website/snap/src/styles.css index a47ce6a067..e6f1f2b9dc 100644 --- a/website/snap/src/styles.css +++ b/website/snap/src/styles.css @@ -6,7 +6,7 @@ .detail-about-loading{min-height:110px;display:flex;align-items:center;justify-content:center;gap:8px;color:#888;font-size:12px}.detail-about-loading .spinner{width:16px;height:16px;border-color:#d7e4f3;border-top-color:#087cf0} /* Admin */ .admin-shell{--admin-bg:#f5f6f8;--admin-panel:#fff;--admin-ink:#18202b;--admin-muted:#707987;min-height:100vh;background:var(--admin-bg);color:var(--admin-ink)}.admin-layout{display:grid;grid-template-columns:238px 1fr;min-height:100vh}.admin-sidebar{position:fixed;inset:0 auto 0 0;width:238px;background:#111923;color:#d8dee7;padding:22px 14px;display:flex;flex-direction:column;border-right:1px solid #202b38}.admin-brand{height:38px;padding:0 8px;margin-bottom:27px;color:#fff}.admin-brand span:last-child{display:flex;gap:4px}.admin-brand b{font-weight:500;color:#7f8b9b}.admin-sidebar nav{display:grid;gap:4px}.admin-sidebar nav button,.sidebar-bottom>button{height:42px;border:0;border-radius:8px;background:transparent;color:#9da8b7;display:flex;align-items:center;gap:11px;padding:0 11px;font-size:13px;text-align:left}.admin-sidebar nav button svg,.sidebar-bottom>button svg{width:18px}.admin-sidebar nav button:hover,.admin-sidebar nav button.active{background:#1c2835;color:#fff}.admin-sidebar nav button.active{box-shadow:inset 2px 0 #2c8df4}.sidebar-bottom{margin-top:auto;border-top:1px solid #28323f;padding-top:15px}.system-status{padding:8px 11px 14px;font-size:11px;display:grid;grid-template-columns:10px 1fr;align-items:center;color:#a6b0bd}.system-status small{grid-column:2;color:#667282;margin-top:2px}.status-dot{width:7px;height:7px;border-radius:50%;background:#41c77a;box-shadow:0 0 0 3px rgba(65,199,122,.1)}.admin-main{grid-column:2;min-width:0}.admin-topbar{height:62px;background:#fff;border-bottom:1px solid #e3e6ea;padding:0 28px;display:flex;align-items:center;justify-content:space-between;font-size:12px;position:sticky;top:0;z-index:20}.admin-breadcrumb{color:#9ca3ad}.admin-top-actions{display:flex;align-items:center;gap:10px}.version-picker{height:34px;display:flex;align-items:center;gap:7px;border:1px solid #e0e3e7;border-radius:8px;padding:0 8px;color:#88909b}.version-picker select{border:0;appearance:none;background:transparent;color:#252d38;font-size:12px;font-weight:600;padding-right:12px;outline:0}.version-picker svg{width:13px}.preview-link{height:34px;border:1px solid #e0e3e7;border-radius:8px;display:flex;align-items:center;gap:6px;padding:0 10px;font-weight:600}.preview-link svg{width:13px}.admin-content{max-width:1320px;margin:auto;padding:36px 34px 70px}.admin-page-title{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:28px}.admin-page-title h1{font-size:29px;letter-spacing:-.035em;margin:5px 0 6px}.admin-page-title p{font-size:13px;color:var(--admin-muted);margin:0}.admin-primary{border:0;background:#1678e5;color:#fff;border-radius:8px;height:38px;padding:0 13px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:7px;box-shadow:0 4px 12px rgba(22,120,229,.16)}.admin-primary:hover{background:#0d6fd7}.admin-primary svg{width:16px}.admin-primary:disabled{opacity:.45}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin-bottom:15px}.stat-card{background:#fff;border:1px solid #e2e5e9;border-radius:12px;padding:18px;display:grid;grid-template-columns:auto 1fr;column-gap:12px}.stat-icon{width:36px;height:36px;border-radius:9px;background:#edf5ff;color:#1779e6;display:grid;place-items:center;grid-row:1/3}.stat-icon svg{width:18px}.stat-card strong{font-size:22px;letter-spacing:-.03em}.stat-card>span:last-child{font-size:11px;color:#7c8591}.admin-two-col{display:grid;grid-template-columns:1.3fr 1fr;gap:15px}.admin-panel{background:#fff;border:1px solid #e2e5e9;border-radius:12px}.panel-heading{padding:16px 18px;border-bottom:1px solid #e7e9ec;display:flex;justify-content:space-between;align-items:center}.panel-heading h2,.settings-section h2{font-size:14px;margin:0}.panel-heading span{color:#9299a2;font-size:10px}.resolution-flow{padding:10px 18px 17px}.resolution-local,.resolution-item{display:flex;align-items:center;gap:10px;min-height:58px;border-bottom:1px solid #eef0f2}.resolution-flow>div:last-child{border:0}.resolution-local>svg,.resolution-item>svg{width:17px;color:#6a7684}.resolution-local>span:nth-child(2),.resolution-item>span:nth-child(2){display:flex;flex-direction:column;flex:1}.resolution-flow b{font-size:12px}.resolution-flow small{font-size:10px;color:#89929e;margin-top:3px}.priority-badge{font-size:9px;color:#707a86;background:#f1f3f5;padding:4px 7px;border-radius:10px}.health-list{padding:7px 18px 12px}.health-list>div{height:49px;border-bottom:1px solid #edf0f2;display:flex;align-items:center;justify-content:space-between;font-size:11px}.health-list>div:last-child{border:0}.health-list span{display:flex;align-items:center;gap:8px;color:#5d6875}.health-list svg{width:15px;color:#8993a0}.healthy{color:#28a265;font-size:10px}.table-panel{overflow:hidden}.admin-table{width:100%;border-collapse:collapse;font-size:11px}.admin-table th{text-align:left;color:#89919c;background:#fafbfc;font-size:9px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;padding:11px 14px;border-bottom:1px solid #e6e9ec}.admin-table td{padding:12px 14px;border-bottom:1px solid #edf0f2;color:#4b5562}.admin-table tbody tr:last-child td{border:0}.table-app{display:flex;align-items:center;gap:10px}.table-app>span{display:flex;flex-direction:column;max-width:360px}.table-app b{color:#27313d;font-size:11px}.table-app small{color:#8b949e;font-size:9px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.channel-chip,.source-local,.visible-chip{display:inline-block;padding:4px 7px;border-radius:10px;background:#f0f3f5;font-size:9px}.source-local,.visible-chip{background:#e8f5ee;color:#248857}.row-menu{border:0;background:transparent;color:#8c949e;border-radius:5px;padding:4px 7px}.row-menu:hover{background:#f1f3f5}.empty-table{padding:50px;display:flex;flex-direction:column;align-items:center;gap:7px;color:#949ca6}.empty-table svg{width:30px}.empty-table b{color:#515b67}.local-priority-banner{height:68px;border:1px solid #b9d9ff;background:linear-gradient(90deg,#f2f8ff,#fbfdff);border-radius:11px;display:flex;align-items:center;gap:13px;padding:0 18px;margin-bottom:12px;color:#1d6fc9}.local-priority-banner>svg:first-child{width:21px}.local-priority-banner>div{display:flex;flex-direction:column;flex:1}.local-priority-banner b{font-size:12px;color:#24415f}.local-priority-banner span{font-size:10px;color:#7690aa;margin-top:3px}.local-priority-banner>svg:last-child{width:17px}.upstream-list{display:grid;gap:9px}.upstream-card{min-height:78px;background:#fff;border:1px solid #e1e5e9;border-radius:11px;display:flex;align-items:center;padding:11px 14px;gap:11px}.upstream-card.disabled{opacity:.55}.drag-handle{width:15px;color:#bbc1c8}.upstream-logo{width:40px;height:40px;border-radius:10px;background:#eef4fb;color:#48739f;display:grid;place-items:center}.upstream-logo svg{width:19px}.upstream-info{display:flex;flex-direction:column;flex:1;min-width:0}.upstream-info>div{display:flex;align-items:center;gap:7px}.upstream-info b{font-size:12px}.upstream-info>span{font-size:10px;color:#6c7682;margin:3px 0;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.upstream-info small{font-size:9px;color:#9aa1aa}.health-dot{width:6px;height:6px;border-radius:50%;background:#aaa}.health-dot.online{background:#2db36e}.health-dot.offline{background:#df6570}.priority-label{font-size:9px;padding:5px 8px;background:#f1f3f5;border-radius:12px;color:#707a86}.upstream-actions{display:flex;align-items:center;gap:4px;margin-left:5px}.upstream-actions>button{width:27px;height:27px;border:1px solid #e0e4e8;border-radius:6px;background:#fff;color:#77818d}.upstream-actions>button:disabled{opacity:.3}.switch{position:relative;width:34px;height:20px;margin-left:6px;display:inline-block}.switch input{display:none}.switch>span{position:absolute;inset:0;border-radius:12px;background:#c9ced4;transition:.2s}.switch>span:before{content:"";position:absolute;width:16px;height:16px;left:2px;top:2px;background:#fff;border-radius:50%;box-shadow:0 1px 3px #0003;transition:.2s}.switch input:checked+span{background:#2181e9}.switch input:checked+span:before{transform:translateX(14px)}.version-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}.version-card{background:#fff;border:1px solid #e2e5e9;border-radius:12px;padding:18px}.version-card-top{display:flex;justify-content:space-between}.version-icon{width:37px;height:37px;border-radius:9px;background:#eef5fd;color:#277cca;display:grid;place-items:center}.version-icon svg{width:18px}.version-state{height:21px;padding:4px 7px;border-radius:10px;background:#e7f6ee;color:#248857;font-size:9px}.version-state.frozen{background:#f1f2f3;color:#79818a}.version-card h2{margin:17px 0 2px;font-size:16px}.version-card code{color:#9299a2;font-size:10px}.version-meta{display:flex;gap:16px;font-size:10px;color:#727c88;margin:18px 0;border-top:1px solid #edf0f2;padding-top:14px}.version-card>button{border:0;background:transparent;color:#1678e5;font-size:10px;padding:0;display:flex;align-items:center;gap:2px}.version-card>button svg{width:13px}.metadata-toolbar{display:flex;gap:8px;margin-bottom:12px}.metadata-toolbar button{border:1px solid #e0e4e8;border-radius:8px;background:#fff;height:38px;padding:0 10px;display:flex;align-items:center;gap:6px;color:#626c78;font-size:10px}.metadata-toolbar button svg{width:14px}.settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.settings-section{padding:18px}.settings-section h2{margin-bottom:18px}.settings-section>label:not(.setting-toggle),.form-grid label,.form-stack label{display:flex;flex-direction:column;gap:6px;font-size:10px;font-weight:600;color:#626c78;margin-bottom:13px}.settings-section input,.settings-section select,.form-grid input,.form-grid select,.form-stack input{height:38px;border:1px solid #dfe3e8;border-radius:7px;padding:0 10px;outline:0;background:#fff;color:#27313d;font-size:11px}.settings-section input:focus,.form-grid input:focus,.form-stack input:focus{border-color:#74adee;box-shadow:0 0 0 3px #e8f3ff}.setting-toggle{min-height:63px;display:flex;align-items:center;justify-content:space-between;border-top:1px solid #edf0f2}.setting-toggle>span:first-child{display:flex;flex-direction:column}.setting-toggle b{font-size:11px}.setting-toggle small{font-size:9px;color:#8b949f;margin-top:3px}.secondary-danger{border:1px solid #f0c5c9;background:#fff8f8;color:#bc3e49;border-radius:7px;height:35px;padding:0 10px;font-size:10px;margin-top:12px}.success-toast{position:fixed;z-index:40;right:26px;top:76px;background:#173b2a;color:#dff7ea;border:1px solid #326b4d;border-radius:8px;padding:10px 12px;display:flex;align-items:center;gap:7px;font-size:11px;box-shadow:0 10px 25px #0002}.success-toast>svg{width:15px}.success-toast button{border:0;background:transparent;color:#b5d9c5;display:grid;place-items:center}.success-toast button svg{width:13px} -.login-page{min-height:100vh;background:#111923;display:grid;place-items:center;position:relative;overflow:hidden}.login-glow{position:absolute;width:700px;height:500px;background:radial-gradient(ellipse,#1374d53d,transparent 65%);top:-250px;left:50%;transform:translateX(-50%)}.login-wordmark{position:absolute;left:28px;top:24px;color:#fff;display:flex;align-items:center;gap:7px;font-size:13px}.login-wordmark b{color:#7f8b9b;font-weight:500}.login-card{width:min(410px,calc(100% - 34px));background:#fff;border-radius:15px;padding:30px;box-shadow:0 28px 80px #0005;position:relative}.login-lock{width:45px;height:45px;border-radius:12px;background:#eaf4ff;color:#1678e5;display:grid;place-items:center}.login-lock svg{width:21px}.login-card h1{font-size:22px;letter-spacing:-.03em;margin:20px 0 7px}.login-card>p{font-size:12px;line-height:1.55;color:#7b8490;margin:0 0 21px}.login-card>label{font-size:10px;font-weight:700;color:#56616e;display:flex;flex-direction:column;gap:7px}.login-card input{height:46px;border:1px solid #d9dee4;border-radius:8px;padding:0 12px;font-size:18px;letter-spacing:.15em;outline:0}.login-card input:focus{border-color:#70aef0;box-shadow:0 0 0 3px #e9f3ff}.login-submit{width:100%;height:43px;border:0;border-radius:8px;background:#1678e5;color:#fff;font-weight:700;font-size:11px;margin-top:15px;display:flex;align-items:center;justify-content:center;gap:7px}.login-submit:disabled{opacity:.45}.login-security{display:flex;align-items:center;justify-content:center;gap:5px;color:#9ba2ab;font-size:9px;margin-top:14px}.login-security svg{width:13px}.error-banner{font-size:10px;color:#b73b47;background:#fff0f1;border:1px solid #f5c7cb;border-radius:7px;padding:8px 10px;margin-top:10px}.admin-loading{min-height:100vh;background:#111923;color:#aab4c1;display:flex;align-items:center;justify-content:center;gap:10px;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #425064;border-top-color:#4d9ef7;border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} +.error-banner{font-size:10px;color:#b73b47;background:#fff0f1;border:1px solid #f5c7cb;border-radius:7px;padding:8px 10px;margin-top:10px}.admin-loading{min-height:100vh;background:#111923;color:#aab4c1;display:flex;align-items:center;justify-content:center;gap:10px;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #425064;border-top-color:#4d9ef7;border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} .modal-backdrop{position:fixed;z-index:80;inset:0;background:#10151d70;backdrop-filter:blur(4px);display:grid;place-items:center}.admin-modal{width:min(570px,calc(100% - 32px));background:#fff;border-radius:14px;padding:25px;position:relative;box-shadow:0 30px 90px #0004}.admin-modal.small{width:min(470px,calc(100% - 32px))}.modal-close{position:absolute;right:14px;top:14px;border:0;background:#f1f3f5;border-radius:50%;width:30px;height:30px;display:grid;place-items:center}.modal-close svg{width:15px}.modal-icon{width:42px;height:42px;border-radius:10px;background:#eaf4ff;color:#1678e5;display:grid;place-items:center}.modal-icon svg{width:20px}.admin-modal h2{font-size:18px;margin:16px 0 5px}.admin-modal>p{font-size:11px;color:#7b8490;line-height:1.5;margin:0 0 17px}.dropzone{height:120px;border:1.5px dashed #c8d4e2;background:#f8fbff;border-radius:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;color:#6e7c8c;position:relative;margin-bottom:15px}.dropzone input{position:absolute;inset:0;opacity:0;cursor:pointer}.dropzone svg{width:22px;color:#438fdc}.dropzone b{font-size:11px;color:#455363}.dropzone span{font-size:9px;color:#98a1ab}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.form-stack{display:grid}.modal-submit{width:100%;justify-content:center;margin-top:4px} @media(max-width:900px){.featured-grid{grid-template-columns:1fr}.three-column-list{grid-template-columns:repeat(2,1fr)}.category-grid{grid-template-columns:repeat(2,1fr)}.app-list.broad{grid-template-columns:1fr}.admin-layout{grid-template-columns:70px 1fr}.admin-sidebar{width:70px}.admin-brand>span:last-child,.admin-sidebar nav button span,.system-status,.sidebar-bottom>button span{display:none}.admin-sidebar nav button,.sidebar-bottom>button{justify-content:center}.admin-main{grid-column:2}.stats-grid{grid-template-columns:repeat(2,1fr)}.admin-two-col,.settings-grid{grid-template-columns:1fr}.version-grid{grid-template-columns:repeat(2,1fr)}} @media(max-width:650px){.store-container,.header-inner{width:min(100% - 28px,1180px)}.top-nav{display:none;position:absolute;left:14px;right:14px;top:52px;background:#fff;border:1px solid #ddd;border-radius:12px;padding:8px;box-shadow:0 13px 30px #0002}.top-nav.mobile-open{display:grid}.top-nav a{padding:10px}.admin-link{display:none}.menu-button{display:block}.header-inner{justify-content:space-between}.wordmark b{display:none}.hero-content{padding:62px 0 52px}.hero-content h1{font-size:44px}.hero-content>p{font-size:16px}.featured-card{height:220px;padding:22px}.featured-copy{width:62%}.featured-art{right:18px}.app-icon-lg{width:86px;height:86px}.three-column-list,.category-grid{grid-template-columns:1fr}.section-block{padding-top:42px}.section-heading{align-items:center}.section-heading h2{font-size:23px}.search-box.compact{width:160px}.store-footer>.store-container{align-items:flex-start;gap:20px}.store-footer div>div:last-child{flex-direction:column;gap:6px}.detail-panel{padding:38px 22px}.detail-hero{grid-template-columns:auto 1fr}.detail-hero>.primary-action{grid-column:1/-1}.detail-stats{grid-template-columns:repeat(2,1fr)}.detail-stats div:nth-child(2){border-right:0}.admin-layout{display:block}.admin-sidebar{position:fixed;width:100%;height:58px;inset:auto 0 0 0;z-index:50;display:block;padding:6px;background:#111923}.admin-brand,.sidebar-bottom{display:none}.admin-sidebar nav{height:100%;display:flex;justify-content:space-around}.admin-sidebar nav button{height:46px;width:50px;padding:0}.admin-main{padding-bottom:58px}.admin-topbar{padding:0 15px}.admin-topbar>div:first-child,.version-picker>span,.preview-link{display:none}.admin-top-actions{margin-left:auto}.admin-content{padding:27px 15px 45px}.admin-page-title{align-items:flex-start;gap:18px}.admin-page-title h1{font-size:24px}.admin-page-title p{max-width:280px}.stats-grid,.version-grid{grid-template-columns:1fr}.admin-two-col{grid-template-columns:1fr}.admin-table{min-width:680px}.table-panel{overflow:auto}.upstream-card{flex-wrap:wrap}.upstream-info{min-width:180px}.priority-label{display:none}.metadata-toolbar{overflow:auto}.settings-grid{grid-template-columns:1fr}.form-grid{grid-template-columns:1fr}} diff --git a/website/snap/worker/index.ts b/website/snap/worker/index.ts index 8faacb7d4c..0725869092 100644 --- a/website/snap/worker/index.ts +++ b/website/snap/worker/index.ts @@ -4,21 +4,51 @@ interface Env { DEFAULT_VERSION: string; REPO_PUBLIC_BASE: string; ARTIFACTS: R2Bucket; - ADMIN_PIN_HASH?: string; - SESSION_SECRET?: string; + ACCESS_TEAM_DOMAIN: string; + ACCESS_AUD: string; } type JsonValue = Record | unknown[]; const encoder = new TextEncoder(); const json = (value: JsonValue, status = 200, headers: HeadersInit = {}) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers } }); const now = () => Math.floor(Date.now() / 1000); -const hex = (bytes: ArrayBuffer) => [...new Uint8Array(bytes)].map(b => b.toString(16).padStart(2, '0')).join(''); -async function sha256(value: string) { return hex(await crypto.subtle.digest('SHA-256', encoder.encode(value))); } function ipOf(request: Request) { return request.headers.get('CF-Connecting-IP') || 'unknown'; } function safeVersion(value: string | null, env: Env) { const v = value || env.DEFAULT_VERSION || 'rolling'; return /^[A-Za-z0-9._-]{1,64}$/.test(v) ? v : env.DEFAULT_VERSION || 'rolling'; } -function cookie(request: Request, name: string) { const raw = request.headers.get('cookie') || ''; for (const part of raw.split(';')) { const [k,...rest] = part.trim().split('='); if (k === name) return decodeURIComponent(rest.join('=')); } return null; } async function body(request: Request): Promise { return request.json() as Promise; } +function base64UrlBytes(value: string) { + const base64 = value.replaceAll('-', '+').replaceAll('_', '/').padEnd(Math.ceil(value.length / 4) * 4, '='); + return Uint8Array.from(atob(base64), char => char.charCodeAt(0)); +} + +function base64UrlJson(value: string): T { + return JSON.parse(new TextDecoder().decode(base64UrlBytes(value))) as T; +} + +async function verifyAccess(request: Request, env: Env) { + const token = request.headers.get('Cf-Access-Jwt-Assertion'); + if (!token || !env.ACCESS_TEAM_DOMAIN || !env.ACCESS_AUD) return false; + const parts = token.split('.'); + if (parts.length !== 3) return false; + try { + const header = base64UrlJson<{ alg?: string; kid?: string }>(parts[0]); + const payload = base64UrlJson<{ aud?: string | string[]; exp?: number; iss?: string }>(parts[1]); + if (header.alg !== 'RS256' || !header.kid || !payload.exp || payload.exp <= now()) return false; + const audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!audience.includes(env.ACCESS_AUD)) return false; + if ((payload.iss || '').replace(/\/$/, '') !== `https://${env.ACCESS_TEAM_DOMAIN}`) return false; + const certs = await fetch(`https://${env.ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs`, { cf: { cacheTtl: 3600, cacheEverything: true } }); + if (!certs.ok) return false; + const result = await certs.json<{ keys?: Array }>(); + const jwk = result.keys?.find(key => key.kid === header.kid); + if (!jwk) return false; + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']); + return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, base64UrlBytes(parts[2]), encoder.encode(`${parts[0]}.${parts[1]}`)); + } catch { + return false; + } +} + function publicCacheKey(request: Request, env: Env) { const source = new URL(request.url); const key = new URL(source.origin + source.pathname); @@ -67,40 +97,6 @@ async function audit(env: Env, request: Request, action: string, detail: Record< await env.DB.prepare('INSERT INTO audit_log(action,detail_json,ip) VALUES(?,?,?)').bind(action, JSON.stringify(detail), ipOf(request)).run(); } -async function authenticate(request: Request, env: Env) { - const token = cookie(request, 'capos_store_session'); - if (!token) return false; - const tokenHash = await sha256(token); - const row = await env.DB.prepare('SELECT expires_at FROM admin_sessions WHERE token_hash=?').bind(tokenHash).first<{ expires_at: number }>(); - if (!row || row.expires_at <= now()) return false; - return true; -} - -async function login(request: Request, env: Env) { - if (!env.ADMIN_PIN_HASH || !env.SESSION_SECRET) return json({ error: 'Administrator authentication is not configured.' }, 503); - const ip = ipOf(request); const t = now(); - const failure = await env.DB.prepare('SELECT attempts,window_start,locked_until FROM login_failures WHERE ip=?').bind(ip).first<{attempts:number;window_start:number;locked_until:number}>(); - if (failure?.locked_until && failure.locked_until > t) return json({ error: 'Too many attempts. Try again later.' }, 429); - const input = await body<{ pin?: string }>(request); - const candidate = await sha256(`${input.pin || ''}:${env.SESSION_SECRET}`); - if (candidate !== env.ADMIN_PIN_HASH) { - const inWindow = failure && t - failure.window_start < 300; const attempts = inWindow ? failure!.attempts + 1 : 1; const locked = attempts >= 5 ? t + 900 : 0; - await env.DB.prepare(`INSERT INTO login_failures(ip,attempts,window_start,locked_until) VALUES(?,?,?,?) ON CONFLICT(ip) DO UPDATE SET attempts=excluded.attempts,window_start=excluded.window_start,locked_until=excluded.locked_until`).bind(ip, attempts, inWindow ? failure!.window_start : t, locked).run(); - return json({ error: attempts >= 5 ? 'Too many attempts. Try again later.' : 'Incorrect administrator PIN.' }, attempts >= 5 ? 429 : 401); - } - await env.DB.prepare('DELETE FROM login_failures WHERE ip=?').bind(ip).run(); - const raw = crypto.getRandomValues(new Uint8Array(32)); const token = btoa(String.fromCharCode(...raw)).replaceAll('+','-').replaceAll('/','_').replaceAll('=',''); const tokenHash = await sha256(token); const expires = t + 12 * 3600; - await env.DB.prepare('DELETE FROM admin_sessions WHERE expires_at <= ?').bind(t).run(); - await env.DB.prepare('INSERT INTO admin_sessions(token_hash,expires_at,created_at,ip) VALUES(?,?,?,?)').bind(tokenHash, expires, t, ip).run(); - await audit(env, request, 'admin.login'); - return json({ ok: true }, 200, { 'set-cookie': `capos_store_session=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${12*3600}` }); -} - -async function logout(request: Request, env: Env) { - const token = cookie(request, 'capos_store_session'); if (token) await env.DB.prepare('DELETE FROM admin_sessions WHERE token_hash=?').bind(await sha256(token)).run(); - return json({ ok: true }, 200, { 'set-cookie': 'capos_store_session=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0' }); -} - function mediaIcon(media: unknown) { if (!Array.isArray(media)) return undefined; const icon = media.find(item => typeof item === 'object' && item && (item as {type?:string}).type === 'icon') as {url?:string}|undefined; @@ -418,10 +414,8 @@ async function api(request:Request,env:Env,ctx:ExecutionContext){const url=new U if(p==='/api/catalog'&&request.method==='GET')return edgeCached(request,env,ctx,300,30,()=>richCatalog(request,env,ctx)); if(p==='/api/search'&&request.method==='GET')return edgeCached(request,env,ctx,60,10,()=>searchStore(request,env)); if(p==='/api/app'&&request.method==='GET')return edgeCached(request,env,ctx,1800,60,()=>appDetail(request,env)); - if(p==='/api/admin/auth'&&request.method==='POST')return login(request,env); - if(p==='/api/admin/logout'&&request.method==='POST')return logout(request,env); if(p==='/download/upstream'&&request.method==='GET')return proxyDownload(request); - if(p.startsWith('/api/admin/')){if(!(await authenticate(request,env)))return json({error:'Authentication required.'},401);if(p==='/api/admin/state'&&request.method==='GET')return adminState(request,env);if(p==='/api/admin/versions'&&request.method==='POST')return postVersion(request,env);if(p==='/api/admin/upstreams'&&request.method==='PUT')return putUpstreams(request,env);if(p==='/api/admin/upstreams'&&request.method==='POST')return postUpstream(request,env);if(p==='/api/admin/packages/uploads'&&request.method==='POST')return startPackageUpload(request,env);if(p==='/api/admin/packages/upload-part'&&request.method==='PUT')return uploadPackagePart(request,env);if(p==='/api/admin/packages/abort'&&request.method==='POST')return abortPackageUpload(request,env);if(p==='/api/admin/packages/finalize'&&request.method==='POST')return finalizePackage(request,env);} + if(p==='/api/admin'||p.startsWith('/api/admin/')){if(!(await verifyAccess(request,env)))return json({error:'Cloudflare Access authentication required.'},401);if(p==='/api/admin/state'&&request.method==='GET')return adminState(request,env);if(p==='/api/admin/versions'&&request.method==='POST')return postVersion(request,env);if(p==='/api/admin/upstreams'&&request.method==='PUT')return putUpstreams(request,env);if(p==='/api/admin/upstreams'&&request.method==='POST')return postUpstream(request,env);if(p==='/api/admin/packages/uploads'&&request.method==='POST')return startPackageUpload(request,env);if(p==='/api/admin/packages/upload-part'&&request.method==='PUT')return uploadPackagePart(request,env);if(p==='/api/admin/packages/abort'&&request.method==='POST')return abortPackageUpload(request,env);if(p==='/api/admin/packages/finalize'&&request.method==='POST')return finalizePackage(request,env);} if(p.startsWith('/v2/')){const version=safeVersion(request.headers.get('X-CapOS-Version'),env);const sources=await upstreams(env,version);const first=sources.find(s=>s.enabled);if(!first)return json({'error-list':[{'code':'no-upstream','message':'No enabled Snap upstream.'}]},503);const target=new URL(first.apiUrl);target.pathname=p;target.search=url.search;const headers=new Headers(request.headers);headers.set('Snap-Device-Series',headers.get('Snap-Device-Series')||'16');headers.set('User-Agent','CapOS-snapd/1');headers.delete('host');const response=await fetch(new Request(target,{method:request.method,headers,body:['GET','HEAD'].includes(request.method)?undefined:request.body,redirect:'manual'}));return response;} return json({error:'Not found.'},404); } diff --git a/website/snap/wrangler.toml b/website/snap/wrangler.toml index 8c937d2308..5886df0165 100644 --- a/website/snap/wrangler.toml +++ b/website/snap/wrangler.toml @@ -2,6 +2,8 @@ name = "capos-snap" main = "worker/index.ts" compatibility_date = "2026-08-18" compatibility_flags = ["nodejs_compat"] +workers_dev = false +preview_urls = false [assets] directory = "./dist" @@ -28,4 +30,5 @@ custom_domain = true [vars] DEFAULT_VERSION = "rolling" REPO_PUBLIC_BASE = "https://repo.capos.top" -# Secrets: ADMIN_PIN_HASH, SESSION_SECRET +ACCESS_TEAM_DOMAIN = "fwerkor.cloudflareaccess.com" +ACCESS_AUD = "c35348d6e3f59bc8857131672c0c59096083cdaaed23536cecee3d9921c2e2a5"