diff --git a/.github/workflows/build-frontend.yml b/.github/workflows/build-frontend.yml index 235ef6a..e395c98 100644 --- a/.github/workflows/build-frontend.yml +++ b/.github/workflows/build-frontend.yml @@ -25,4 +25,3 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build - - run: npm run assets-browser:deploy:dry-run diff --git a/package-lock.json b/package-lock.json index efb9a65..502b254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,12 +19,10 @@ "console-stamp": "^3.0.6", "cron": "^4.4.0", "dotenv": "^17.3.1", - "fast-xml-parser": "^5.11.1", "ical-generator": "^11.1.1", "jsonpath-plus": "^10.4.0", "lodash": "^4.17.21", "masto": "^8.0.0", - "mustache": "^4.2.0", "nxapi": "1.6.1-next.254", "p-filter": "^4.1.0", "p-limit": "^7.3.0", @@ -8767,15 +8765,6 @@ "integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==", "license": "MIT" }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", diff --git a/package.json b/package.json index 9ad0be3..707c2ca 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "assets-browser:dev": "wrangler dev --local --var LOCAL_DEVELOPMENT:true --persist-to .wrangler/state --cwd workers/assets-directory-browser", - "assets-browser:test": "vitest run --config workers/assets-directory-browser/vitest.config.mjs", - "assets-browser:deploy": "wrangler deploy --cwd workers/assets-directory-browser", - "assets-browser:deploy:dry-run": "wrangler deploy --dry-run --cwd workers/assets-directory-browser", "preview": "vite preview --port 5050", "lint": "eslint .", "lint-fix": "npm run lint -- --fix", - "test": "vitest run && npm run assets-browser:test", + "test": "vitest run", "test:watch": "vitest", "cron": "node app/index.mjs cron", "start": "npm run sync:download && npm run splatnet:quick && npm run social && npm run cron", @@ -50,12 +46,10 @@ "console-stamp": "^3.0.6", "cron": "^4.4.0", "dotenv": "^17.3.1", - "fast-xml-parser": "^5.11.1", "ical-generator": "^11.1.1", "jsonpath-plus": "^10.4.0", "lodash": "^4.17.21", "masto": "^8.0.0", - "mustache": "^4.2.0", "nxapi": "1.6.1-next.254", "p-filter": "^4.1.0", "p-limit": "^7.3.0", diff --git a/workers/assets-directory-browser/README.md b/workers/assets-directory-browser/README.md deleted file mode 100644 index 7da7622..0000000 --- a/workers/assets-directory-browser/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# Assets directory browser - -This Worker renders read-only directory listings for the public R2 bucket without -running on ordinary object requests. It also mounts the public DigitalOcean -Spaces data archive at `/data/archive/`. - -## Virtual archive directory - -The `/data/` R2 listing includes a synthetic `archive/` directory. Listings -under `/data/archive/` use anonymous S3 `ListObjectsV2` requests against the -public archive origin instead of R2. The mount prefix is removed from S3 -requests and restored on browser-visible directory links. - -Archive file links point directly to `https://data-archive.splatoon3.ink`, so -downloads do not pass through the Worker. Directory and pagination links remain -under `https://assets.splatoon3.ink/data/archive/`. The public S3 listing API on -the archive domain is unchanged. - -## Request routing - -The browser-visible directory URLs end in `/`, for example: - -- `https://assets.splatoon3.ink/` -- `https://assets.splatoon3.ink/data/` -- `https://assets.splatoon3.ink/splatnet/` - -A Cloudflare URL Rewrite Rule rewrites only those requests to an internal prefix. -The rewrite is not visible to the client. - -Filter expression: - -```text -(http.host eq "assets.splatoon3.ink" and ends_with(http.request.uri.path, "/")) -``` - -Dynamic path rewrite: - -```text -concat("/__directory", http.request.uri.path) -``` - -The Wrangler configuration routes only `assets.splatoon3.ink/__directory/*` to -the Worker. Every JSON file, calendar, image, and screenshot object continues to -go directly to the R2 custom domain. - -## JSON listings - -Each directory has a machine-readable representation at the same public URL: - -```text -https://assets.splatoon3.ink/splatnet/images/?format=json -``` - -The response contains immediate child `directories`, immediate child `files`, -and an absolute `next` URL when another page is available. Listings are not -recursive. Clients can walk the bucket by following directory URLs and must -follow `next` verbatim rather than interpreting its opaque R2 cursor. - -File records include their canonical URL, byte size, ETag, and R2 upload time. -The HTML listing displays that same upload time in its **Last modified** column. -JSON listing and error responses allow anonymous cross-origin reads with -`Access-Control-Allow-Origin: *`. - -## Local preview - -Start the Worker with its explicit local-development flag: - -```sh -npm run assets-browser:dev -``` - -Then open `http://localhost:8787/`. Local mode accepts the natural directory -paths and serves linked R2 objects from Wrangler's simulated bucket. It never -connects to the production R2 bucket. - -The archive mount uses the anonymously readable production Spaces listing in -local mode, so it can be previewed at: - -```text -http://localhost:8787/data/archive/ -``` - -Archive file links in that preview still point to the public archive domain. - -## Validate without deploying - -```sh -npm run assets-browser:test -npm run assets-browser:deploy:dry-run -``` - -`npm run assets-browser:deploy` performs the production deployment. Production -deployments are handled by Cloudflare Workers Builds, configured from the -repository root with `main` as the production branch, -`npm run assets-browser:test` as the build command, and -`npm run assets-browser:deploy` as the deploy command. - -## Production rollout - -Do not create the URL Rewrite Rule until the Worker route has been deployed and -verified directly at an internal URL such as: - -```text -https://assets.splatoon3.ink/__directory/data/ -``` - -After that direct check succeeds, create the URL Rewrite Rule and verify the -browser-visible `/`, `/data/`, and `/splatnet/` directory URLs. To roll back, -disable the URL Rewrite Rule first, then remove the Worker route. diff --git a/workers/assets-directory-browser/src/index.js b/workers/assets-directory-browser/src/index.js deleted file mode 100644 index 556f622..0000000 --- a/workers/assets-directory-browser/src/index.js +++ /dev/null @@ -1,455 +0,0 @@ -import Mustache from 'mustache'; -import { XMLParser } from 'fast-xml-parser'; -import template from './template.html'; - -const internalPrefix = '/__directory'; -const pageSize = 1000; -const archiveMountPrefix = 'data/archive/'; - -Mustache.parse(template); - -const xmlParser = new XMLParser({ - parseTagValue: false, - trimValues: true, -}); - -/** - * @param {unknown} value - * @returns {unknown[]} The value as an array. - */ -function asArray(value) { - if (value === undefined) { - return []; - } - - return Array.isArray(value) ? value : [value]; -} - -/** - * @param {Env} env - * @param {string} prefix - * @param {string | undefined} cursor - * @returns {Promise} The normalized archive directory listing. - */ -async function listArchive(env, prefix, cursor) { - let archivePrefix = prefix.slice(archiveMountPrefix.length); - let archiveOrigin = new URL(env.ARCHIVE_ORIGIN); - let listUrl = new URL('/', archiveOrigin); - listUrl.searchParams.set('list-type', '2'); - listUrl.searchParams.set('delimiter', '/'); - listUrl.searchParams.set('max-keys', String(pageSize)); - listUrl.searchParams.set('prefix', archivePrefix); - if (cursor) { - listUrl.searchParams.set('continuation-token', cursor); - } - - let response = await fetch(new Request(listUrl, { - headers: { Accept: 'application/xml' }, - })); - if (!response.ok) { - throw new Error(`Archive listing returned ${response.status}`); - } - - let document = xmlParser.parse(await response.text()); - let result = document?.ListBucketResult; - if (!result || typeof result !== 'object') { - throw new Error('Archive listing returned invalid XML'); - } - - return { - cursor: result.NextContinuationToken || undefined, - delimitedPrefixes: asArray(result.CommonPrefixes).map(item => ( - `${archiveMountPrefix}${item.Prefix}` - )), - objects: asArray(result.Contents).map(item => ({ - browserKey: `${archiveMountPrefix}${item.Key}`, - etag: item.ETag, - key: item.Key, - size: Number(item.Size), - uploaded: new Date(item.LastModified), - url: new URL(publicPath(item.Key), archiveOrigin).href, - })), - truncated: result.IsTruncated === 'true', - }; -} - -/** - * @param {string} prefix - * @param {string | undefined} cursor - * @param {R2Objects} listing - * @returns {R2Objects} The listing with any virtual directories added. - */ -function withVirtualDirectories(prefix, cursor, listing) { - if (prefix !== 'data/' || cursor || listing.delimitedPrefixes.includes(archiveMountPrefix)) { - return listing; - } - - return { - ...listing, - delimitedPrefixes: [...listing.delimitedPrefixes, archiveMountPrefix].sort(), - }; -} - -/** @param {string} key */ -function publicPath(key) { - return `/${key.split('/').map(encodeURIComponent).join('/')}`; -} - -/** @param {string} prefix */ -function parentPath(prefix) { - let parts = prefix.split('/').filter(Boolean); - parts.pop(); - return parts.length ? publicPath(`${parts.join('/')}/`) : '/'; -} - -/** - * @param {string} key - * @param {string} prefix - */ -function displayName(key, prefix) { - return key.slice(prefix.length); -} - -/** - * @typedef {object} Breadcrumb - * @property {boolean} [current] Whether this is the current directory. - * @property {string} [href] Link destination. - * @property {boolean} [link] Whether this breadcrumb is a link. - * @property {string} [name] Visible directory name. - * @property {boolean} [separator] Whether this is a path separator. - */ - -/** - * @param {string} prefix - * @returns {Breadcrumb[]} Breadcrumb presentation model. - */ -function breadcrumbs(prefix) { - let parts = prefix.split('/').filter(Boolean); - if (!parts.length) { - return [{ current: true, name: 'assets' }]; - } - - /** @type {Breadcrumb[]} */ - let result = [{ href: '/', link: true, name: 'assets' }]; - for (let index = 0; index < parts.length; index++) { - result.push({ separator: true }); - if (index === parts.length - 1) { - result.push({ current: true, name: parts[index] }); - } else { - let path = `${parts.slice(0, index + 1).join('/')}/`; - result.push({ href: publicPath(path), link: true, name: parts[index] }); - } - } - return result; -} - -/** @param {number} bytes */ -function formatSize(bytes) { - if (bytes < 1024) { - return `${bytes} B`; - } - - let units = ['KB', 'MB', 'GB', 'TB']; - let value = bytes; - let unit = 'B'; - for (let candidate of units) { - value /= 1024; - unit = candidate; - if (value < 1024) { - break; - } - } - - return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; -} - -/** @param {Date} date */ -function formatDate(date) { - return `${date.toISOString().slice(0, 19).replace('T', ' ')} UTC`; -} - -/** - * @param {string} origin - * @param {string} prefix - * @param {R2Objects} listing - * @param {string | undefined} cursor - */ -function directoryPage(origin, prefix, listing, cursor) { - let pathname = publicPath(prefix); - - return { - directories: listing.delimitedPrefixes.map(directoryPrefix => ({ - name: displayName(directoryPrefix, prefix), - pathname: publicPath(directoryPrefix), - prefix: directoryPrefix, - url: new URL(publicPath(directoryPrefix), origin).href, - })), - files: listing.objects.map(object => { - let pathname = publicPath(object.browserKey ?? object.key); - return { - etag: object.etag, - href: object.url ?? pathname, - key: object.key, - name: displayName(object.browserKey ?? object.key, prefix), - pathname, - size: object.size, - uploaded: object.uploaded, - url: object.url ?? new URL(publicPath(object.key), origin).href, - }; - }), - nextCursor: listing.truncated ? listing.cursor : undefined, - pathname, - prefix, - requestedCursor: cursor, - url: new URL(pathname, origin).href, - }; -} - -/** - * @param {Request} request - * @param {R2Bucket} bucket - * @param {string} key - */ -async function localObjectResponse(request, bucket, key) { - let object = await bucket.get(key); - if (!object) { - return errorResponse(404, 'Object not found'); - } - - let headers = new Headers; - object.writeHttpMetadata(headers); - headers.set('Cache-Control', 'no-store'); - headers.set('ETag', object.httpEtag); - - return new Response(request.method === 'HEAD' ? null : object.body, { - headers, - }); -} - -/** - * @param {ReturnType} page - * @param {string} styleNonce - */ -function renderListing(page, styleNonce) { - let rows = [ - ...(page.prefix ? [{ - href: parentPath(page.prefix), - isParent: true, - name: 'Parent directory', - }] : []), - ...page.directories.map(directory => ({ - href: directory.pathname, - isFolder: true, - name: directory.name, - })), - ...page.files.map(file => ({ - date: formatDate(file.uploaded), - datetime: file.uploaded.toISOString(), - href: file.href, - isFile: true, - name: file.name, - size: formatSize(file.size), - })), - ]; - let folderCount = page.directories.length; - let fileCount = page.files.length; - - return Mustache.render(template, { - breadcrumbs: breadcrumbs(page.prefix), - canonicalUrl: page.url, - hasRows: rows.length > 0, - jsonCursor: page.requestedCursor ? encodeURIComponent(page.requestedCursor) : false, - jsonPath: page.pathname, - next: page.nextCursor ? { - href: `${page.pathname}?cursor=${encodeURIComponent(page.nextCursor)}`, - } : false, - rows, - styleNonce, - summary: `${folderCount} ${folderCount === 1 ? 'folder' : 'folders'} ยท ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, - title: `Asset browser โ€” ${page.pathname}`, - }); -} - -/** - * @param {Request} request - * @param {ReturnType} page - */ -function jsonListingResponse(request, page) { - let next = page.nextCursor - ? new URL(`${page.pathname}?format=json&cursor=${encodeURIComponent(page.nextCursor)}`, page.url).href - : null; - return jsonResponse(request, { - schemaVersion: 1, - directory: { - prefix: page.prefix, - url: page.url, - }, - directories: page.directories.map(directory => ({ - name: directory.name, - prefix: directory.prefix, - url: directory.url, - })), - files: page.files.map(file => ({ - name: file.name, - key: file.key, - url: file.url, - size: file.size, - uploaded: file.uploaded.toISOString(), - etag: file.etag, - })), - next, - }); -} - -/** - * @param {Request} request - * @param {unknown} value - * @param {number} status - * @param {HeadersInit} headers - */ -function jsonResponse(request, value, status = 200, headers = {}) { - let body = JSON.stringify(value); - - return new Response(request.method === 'HEAD' ? null : body, { - status, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-store', - 'Content-Type': 'application/json; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - ...headers, - }, - }); -} - -/** - * @param {number} status - * @param {string} message - * @param {HeadersInit} headers - */ -function errorResponse(status, message, headers = {}) { - return new Response(message, { - status, - headers: { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/plain; charset=utf-8', - ...headers, - }, - }); -} - -/** - * @param {Request} request - * @param {number} status - * @param {string} code - * @param {string} message - * @param {HeadersInit} headers - */ -function listingErrorResponse(request, status, code, message, headers = {}) { - if (new URL(request.url).searchParams.get('format') !== 'json') { - return errorResponse(status, message, headers); - } - - return jsonErrorResponse(request, status, code, message, headers); -} - -/** - * @param {Request} request - * @param {number} status - * @param {string} code - * @param {string} message - * @param {HeadersInit} headers - */ -function jsonErrorResponse(request, status, code, message, headers = {}) { - return jsonResponse(request, { - schemaVersion: 1, - error: { code, message }, - }, status, headers); -} - -/** @type {ExportedHandler} */ -export default { - async fetch(request, env) { - let url = new URL(request.url); - let localDevelopment = env.LOCAL_DEVELOPMENT === 'true'; - - if (!['GET', 'HEAD'].includes(request.method)) { - return listingErrorResponse(request, 405, 'method_not_allowed', 'Method not allowed', { Allow: 'GET, HEAD' }); - } - - let encodedPath; - if (url.pathname.startsWith(`${internalPrefix}/`)) { - encodedPath = url.pathname.slice(internalPrefix.length + 1); - } else if (localDevelopment && url.pathname.endsWith('/')) { - encodedPath = url.pathname.slice(1); - } else if (localDevelopment) { - let key; - try { - key = decodeURIComponent(url.pathname.slice(1)); - } catch { - return listingErrorResponse(request, 400, 'invalid_path', 'Invalid path'); - } - return localObjectResponse(request, env.ASSETS, key); - } else { - return listingErrorResponse(request, 404, 'not_found', 'Not found'); - } - - let prefix; - try { - prefix = decodeURIComponent(encodedPath); - } catch { - return listingErrorResponse(request, 400, 'invalid_path', 'Invalid path'); - } - - let format = url.searchParams.get('format'); - if (format && format !== 'json') { - return jsonErrorResponse(request, 400, 'unsupported_format', 'Unsupported format'); - } - - let cursor = url.searchParams.get('cursor') || undefined; - let archiveListing = prefix.startsWith(archiveMountPrefix); - /** @type {R2Objects} */ - let listing; - try { - listing = archiveListing - ? await listArchive(env, prefix, cursor) - : await env.ASSETS.list({ - cursor, - delimiter: '/', - limit: pageSize, - prefix, - }); - } catch (error) { - console.error(JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - message: archiveListing - ? 'Archive directory listing failed' - : 'R2 directory listing failed', - prefix, - })); - return listingErrorResponse(request, 500, 'listing_unavailable', 'Directory listing unavailable'); - } - if (prefix && !listing.objects.length && !listing.delimitedPrefixes.length) { - return listingErrorResponse(request, 404, 'directory_not_found', 'Directory not found'); - } - - listing = withVirtualDirectories(prefix, cursor, listing); - let page = directoryPage(url.origin, prefix, listing, cursor); - if (url.searchParams.get('format') === 'json') { - return jsonListingResponse(request, page); - } - - let styleNonce = crypto.randomUUID().replaceAll('-', ''); - let html = renderListing(page, styleNonce); - - return new Response(request.method === 'HEAD' ? null : html, { - headers: { - 'Cache-Control': 'no-store', - 'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${styleNonce}'; base-uri 'none'; frame-ancestors 'none'`, - 'Content-Type': 'text/html; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - 'X-Robots-Tag': 'noindex', - }, - }); - }, -}; diff --git a/workers/assets-directory-browser/src/index.test.mjs b/workers/assets-directory-browser/src/index.test.mjs deleted file mode 100644 index d448da4..0000000 --- a/workers/assets-directory-browser/src/index.test.mjs +++ /dev/null @@ -1,370 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { env } from 'cloudflare:workers'; -import worker from './index.js'; - -class FakeBucket -{ - calls = []; - - constructor(result = {}) { - this.result = { - cursor: 'next page', - delimitedPrefixes: ['data/locale/'], - objects: [{ - key: 'data/schedules.json', - size: 54861, - uploaded: new Date('2026-08-30T17:30:26Z'), - }], - truncated: true, - ...result, - }; - } - - async list(options) { - this.calls.push(options); - return this.result; - } -} - -describe('assets directory browser', () => { - it('uses natural directory paths only when local development is enabled', async () => { - await env.ASSETS.put('local/example.json', '{}'); - let localEnv = { - ASSETS: env.ASSETS, - LOCAL_DEVELOPMENT: 'true', - }; - - let localResponse = await worker.fetch( - new Request('http://localhost:8787/local/'), - localEnv, - ); - let productionResponse = await worker.fetch( - new Request('https://assets.splatoon3.ink/local/'), - env, - ); - - expect(localResponse.status).toBe(200); - expect(await localResponse.text()).toContain('href="/local/example.json"'); - expect(productionResponse.status).toBe(404); - }); - - it('serves linked objects from local R2 only in local development', async () => { - await env.ASSETS.put('local/example.json', '{"local":true}', { - httpMetadata: { contentType: 'application/json' }, - }); - let localEnv = { - ASSETS: env.ASSETS, - LOCAL_DEVELOPMENT: 'true', - }; - - let response = await worker.fetch( - new Request('http://localhost:8787/local/example.json'), - localEnv, - ); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); - expect(await response.text()).toBe('{"local":true}'); - }); - - it('lists objects from the configured R2 binding using canonical public links', async () => { - await env.ASSETS.put('data/schedules.json', 'schedule data'); - await env.ASSETS.put('data/locale/en-US.json', 'locale data'); - - let request = new Request( - 'https://assets.splatoon3.ink/__directory/data/', - ); - - let response = await worker.fetch(request, env); - let html = await response.text(); - - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8'); - expect(html).not.toContain('

Asset browser

'); - expect(html).not.toContain('Public Splatoon 3 data and media'); - expect(html).toContain('data'); - expect(html).toContain('href="/"'); - expect(html).toContain('href="/data/locale/"'); - expect(html).toContain('href="/data/schedules.json"'); - expect(html).not.toContain('__directory'); - }); - - it('shows the data archive as a directory in the R2 data listing', async () => { - let bucket = new FakeBucket({ - cursor: undefined, - delimitedPrefixes: ['data/locale/'], - objects: [], - truncated: false, - }); - - let response = await worker.fetch(new Request( - 'https://assets.splatoon3.ink/__directory/data/', - ), { ASSETS: bucket }); - let html = await response.text(); - - expect(response.status).toBe(200); - expect(html).toContain('href="/data/archive/"'); - expect(html).toContain('archive/'); - }); - - it('browses archive directories while linking files to the public archive origin', async () => { - let fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(` - - - 2026/08/ - true - - 2026/08/2026-08-01.tar.zst - 2026-08-31T01:02:03.000Z - "archive-etag" - 1536 - - 2026/08/31/ - next archive page - - `, { headers: { 'Content-Type': 'application/xml' } })); - - let response = await worker.fetch(new Request( - 'http://localhost:8787/data/archive/2026/08/?cursor=current%20archive%20page', - ), { - ARCHIVE_ORIGIN: 'https://data-archive.splatoon3.ink', - ASSETS: new FakeBucket, - LOCAL_DEVELOPMENT: 'true', - }); - let html = await response.text(); - - expect(response.status).toBe(200); - expect(fetchSpy).toHaveBeenCalledOnce(); - expect(fetchSpy.mock.calls[0][0].url).toBe( - 'https://data-archive.splatoon3.ink/?list-type=2&delimiter=%2F&max-keys=1000&prefix=2026%2F08%2F&continuation-token=current+archive+page', - ); - expect(html).toContain('href="/data/archive/2026/08/31/"'); - expect(html).toContain('href="https://data-archive.splatoon3.ink/2026/08/2026-08-01.tar.zst"'); - expect(html).toContain('href="/data/archive/2026/08/?cursor=next%20archive%20page"'); - expect(html).toContain('1.5 KB'); - expect(html).toContain('2026-08-31 01:02:03 UTC'); - - fetchSpy.mockRestore(); - }); - - it('returns a public JSON representation of a directory page', async () => { - let bucket = new FakeBucket({ - cursor: 'next page', - delimitedPrefixes: ['splatnet/images/badges/'], - objects: [{ - etag: 'opaque-etag', - key: 'splatnet/images/example.png', - size: 48123, - uploaded: new Date('2026-08-30T01:02:03Z'), - }], - truncated: true, - }); - let response = await worker.fetch(new Request( - 'https://assets.splatoon3.ink/__directory/splatnet/images/?format=json&cursor=current%20page', - ), { ASSETS: bucket }); - - expect(response.status).toBe(200); - expect(response.headers.get('access-control-allow-origin')).toBe('*'); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8'); - expect(await response.json()).toEqual({ - schemaVersion: 1, - directory: { - prefix: 'splatnet/images/', - url: 'https://assets.splatoon3.ink/splatnet/images/', - }, - directories: [{ - name: 'badges/', - prefix: 'splatnet/images/badges/', - url: 'https://assets.splatoon3.ink/splatnet/images/badges/', - }], - files: [{ - name: 'example.png', - key: 'splatnet/images/example.png', - url: 'https://assets.splatoon3.ink/splatnet/images/example.png', - size: 48123, - uploaded: '2026-08-30T01:02:03.000Z', - etag: 'opaque-etag', - }], - next: 'https://assets.splatoon3.ink/splatnet/images/?format=json&cursor=next%20page', - }); - expect(bucket.calls).toEqual([{ - cursor: 'current page', - delimiter: '/', - limit: 1000, - prefix: 'splatnet/images/', - }]); - }); - - it('renders responsive light and dark themes under a nonce-based CSP', async () => { - let response = await worker.fetch( - new Request('https://assets.splatoon3.ink/__directory/'), - env, - ); - let html = await response.text(); - let policy = response.headers.get('content-security-policy'); - let nonce = policy?.match(/style-src 'nonce-([^']+)'/)?.[1]; - - expect(nonce).toBeTruthy(); - expect(html).toContain(` - - -
-
-
- -
- {{summary}} - JSON -
-
- - -
-
- - diff --git a/workers/assets-directory-browser/vitest.config.mjs b/workers/assets-directory-browser/vitest.config.mjs deleted file mode 100644 index 97a73a7..0000000 --- a/workers/assets-directory-browser/vitest.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { cloudflareTest } from '@cloudflare/vitest-plugin'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [ - cloudflareTest({ - wrangler: { - configPath: './workers/assets-directory-browser/wrangler.jsonc', - }, - }), - ], - test: { - include: ['workers/assets-directory-browser/src/**/*.test.mjs'], - }, -}); diff --git a/workers/assets-directory-browser/wrangler.jsonc b/workers/assets-directory-browser/wrangler.jsonc deleted file mode 100644 index 58a4bb0..0000000 --- a/workers/assets-directory-browser/wrangler.jsonc +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "../../node_modules/wrangler/config-schema.json", - "name": "splatoon3-assets-directory-browser", - "main": "src/index.js", - "compatibility_date": "2026-08-30", - "compatibility_flags": ["nodejs_compat"], - "workers_dev": false, - "preview_urls": false, - "vars": { - "ARCHIVE_ORIGIN": "https://data-archive.splatoon3.ink" - }, - "routes": [ - { - "pattern": "assets.splatoon3.ink/__directory/*", - "zone_name": "splatoon3.ink" - } - ], - "r2_buckets": [ - { - "binding": "ASSETS", - "bucket_name": "splatoon3-ink-assets" - } - ], - "observability": { - "enabled": true, - "logs": { - "head_sampling_rate": 1 - }, - "traces": { - "enabled": true, - "head_sampling_rate": 0.01 - } - } -}