Skip to content
Merged
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
47 changes: 46 additions & 1 deletion website/snap/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ async function copyText(value: string) {
return copied;
}

const APP_DETAIL_HISTORY_STATE = 'caposAppDetail';

function appNameFromLocation() {
return new URLSearchParams(window.location.search).get('app')?.trim() || null;
}

function updateAppLocation(name: string | null, mode: 'push' | 'replace' = 'push') {
const url = new URL(window.location.href);
if (name) url.searchParams.set('app', name);
else url.searchParams.delete('app');
const next = `${url.pathname}${url.search}${url.hash}`;
const currentState = window.history.state && typeof window.history.state === 'object' ? window.history.state : {};
if (mode === 'push') window.history.pushState({ ...currentState, [APP_DETAIL_HISTORY_STATE]: true }, '', next);
else window.history.replaceState(currentState, '', next);
}

type AppAction = {
kind: 'get' | 'install' | 'open' | 'installing';
progress?: number;
Expand Down Expand Up @@ -235,6 +251,28 @@ function Storefront() {
const [visibleCatalogCount, setVisibleCatalogCount] = useState(36);
const [catalogLoading, setCatalogLoading] = useState(true);
const [copiedInstall, setCopiedInstall] = useState<string | null>(null);
useEffect(() => {
const openFromLocation = () => {
const name = appNameFromLocation();
if (!name) {
setSelected(null);
setDetailLoadingName(null);
return;
}
setSelected(current => current?.name === name ? current : null);
setDetailLoadingName(name);
void getAppDetails(name).then(detail => {
Comment thread
fwerkor marked this conversation as resolved.
if (appNameFromLocation() === name) setSelected(detail);
}).catch(() => {
if (appNameFromLocation() === name) setSelected(null);
}).finally(() => {
setDetailLoadingName(current => current === name ? null : current);
});
};
openFromLocation();
window.addEventListener('popstate', openFromLocation);
return () => window.removeEventListener('popstate', openFromLocation);
}, []);
useEffect(() => {
let cancelled = false;
let retryTimer: number | undefined;
Expand Down Expand Up @@ -281,6 +319,7 @@ function Storefront() {
setSearchLoading(Boolean(value.trim()));
};
const openApp = (app: StoreApp) => {
updateAppLocation(app.name, appNameFromLocation() === app.name ? 'replace' : 'push');
setSelected(app);
setDetailLoadingName(app.name);
void getAppDetails(app.name).then(detail => {
Expand All @@ -289,7 +328,13 @@ function Storefront() {
setDetailLoadingName(current => current === app.name ? null : current);
});
};
const closeDetail = () => { setSelected(null); setDetailLoadingName(null); };
const closeDetail = () => {
setSelected(null);
setDetailLoadingName(null);
if (!appNameFromLocation()) return;
if (window.history.state?.[APP_DETAIL_HISTORY_STATE]) window.history.back();
else updateAppLocation(null, 'replace');
};
const actionFor = (app: StoreApp, detail = false): AppAction => {
if (!webdesktop.connected) {
return detail
Expand Down
5 changes: 3 additions & 2 deletions website/snap/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type JsonValue = Record<string, unknown> | 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 SNAP_PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9+.-]{0,62}$/;
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'; }
async function body<T>(request: Request): Promise<T> { return request.json() as Promise<T>; }
Expand Down Expand Up @@ -385,7 +386,7 @@ async function searchStore(request: Request, env: Env) {

async function appDetail(request: Request, env: Env) {
const url = new URL(request.url); const version = safeVersion(url.searchParams.get('version'), env); const name = (url.searchParams.get('name') || '').trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(name)) return json({ error: 'Invalid app name.' }, 400);
if (!SNAP_PACKAGE_NAME_RE.test(name)) return json({ error: 'Invalid app name.' }, 400);
const locals = await localApps(env, version); const local = locals.find(app => app.name === name);
if (local) return json({ app: local }, 200, { 'cache-control': 'public, max-age=60' });
const sources = await upstreams(env, version); const first = sources.find(source => source.enabled && source.kind === 'canonical');
Expand Down Expand Up @@ -437,7 +438,7 @@ function validSnapObjectPath(objectPath: string, version: string) {
async function startPackageUpload(request: Request, env: Env) {
const input = await body<{ version: string; name: string; versionString: string; architecture: string; size: number }>(request);
const version = safeVersion(input.version, env);
if (!/^[a-z0-9][a-z0-9+.-]{0,62}$/.test(input.name)) return json({ error: 'Invalid Snap package name.' }, 400);
if (!SNAP_PACKAGE_NAME_RE.test(input.name)) return json({ error: 'Invalid Snap package name.' }, 400);
if (!/^[A-Za-z0-9._+~-]{1,80}$/.test(input.versionString)) return json({ error: 'Invalid version string.' }, 400);
if (!['amd64', 'arm64', 'armhf', 'all'].includes(input.architecture)) return json({ error: 'Invalid architecture.' }, 400);
if (!Number.isSafeInteger(input.size) || input.size <= 0) return json({ error: 'Invalid package size.' }, 400);
Expand Down
26 changes: 25 additions & 1 deletion website/snap/worker/store-proxy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const upstreamRows = [{
enabled: 1,
}];

let localRows = [];

const env = {
DEFAULT_VERSION: 'rolling',
REPO_PUBLIC_BASE: 'https://repo.capos.top',
Expand All @@ -19,7 +21,7 @@ const env = {
if (/FROM apps a JOIN snap_revisions/.test(sql)) {
return {
bind() {
return { all: async () => ({ results: [] }) };
return { all: async () => ({ results: localRows }) };
},
};
}
Expand Down Expand Up @@ -195,6 +197,28 @@ try {
assert.equal(detail.links.find(link => link.label === 'Contact')?.url, 'mailto:support@nextcloud.com');
assert.equal(detail.storeUrl, 'https://snapcraft.io/nextcloud');

localRows = [{
id: 'local-special-name',
name: 'local+tool.test',
display_name: 'Local Tool',
publisher: 'CapOS',
summary: 'Local package with Snap-compatible punctuation.',
description: 'Local package detail.',
category: 'Utilities',
icon_url: null,
accent: '#000000',
verified: 1,
featured: 0,
snap_version: '1.0.0',
channel: 'stable',
architecture: 'amd64',
webdesktop_mode: 'service',
}];
const localDetailResponse = await worker.fetch(new Request('https://snap.capos.top/api/app?version=rolling&name=local%2Btool.test'), env, ctx);
assert.equal(localDetailResponse.status, 200);
assert.equal((await localDetailResponse.json()).app.name, 'local+tool.test');
localRows = [];

assert.equal(new URL(upstreamRequests[0].url).origin, 'https://api.snapcraft.io');
assert.equal(upstreamRequests[0].headers.get('Snap-Device-Series'), '16');
console.log('snap store proxy tests: PASS');
Expand Down
Loading