From 87bfd7afc49c08d223a93729c47a52ddc3e34110 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Sun, 13 Sep 2026 12:03:18 -0700 Subject: [PATCH] Remove asset browser after shared Worker migration --- package.json | 8 +- vitest.worker.config.mjs | 15 - worker/README.md | 74 ----- worker/index.js | 631 --------------------------------------- worker/index.spec.mjs | 205 ------------- wrangler.jsonc | 31 -- 6 files changed, 2 insertions(+), 962 deletions(-) delete mode 100644 vitest.worker.config.mjs delete mode 100644 worker/README.md delete mode 100644 worker/index.js delete mode 100644 worker/index.spec.mjs delete mode 100644 wrangler.jsonc diff --git a/package.json b/package.json index 4a1550d..735386b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build --modern --no-clean", - "test": "node --test && npm run worker:test", + "test": "node --test", "lint": "vue-cli-service lint", "cron": "node src/app/cron", "start": "npm run sync:download && npm run splatnet && npm run twitter && npm run cron", @@ -20,11 +20,7 @@ "sync:download": "node src/app syncDownload", "utility:copyTranslation": "node src/utility copyTranslation", "utility:getSplatNetLanguageFiles": "node src/utility getSplatNetLanguageFiles", - "utility:updateGear": "node src/utility updateGear", - "worker:dev": "wrangler dev --local --var LOCAL_DEVELOPMENT:true --persist-to .wrangler/state", - "worker:test": "vitest run --config vitest.worker.config.mjs", - "worker:deploy:dry-run": "wrangler deploy --dry-run", - "worker:deploy": "wrangler deploy" + "utility:updateGear": "node src/utility updateGear" }, "dependencies": { "@atproto/api": "^0.20.42", diff --git a/vitest.worker.config.mjs b/vitest.worker.config.mjs deleted file mode 100644 index 6f6a265..0000000 --- a/vitest.worker.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: './wrangler.jsonc', - }, - }), - ], - test: { - include: ['worker/**/*.spec.mjs'], - }, -}); diff --git a/worker/README.md b/worker/README.md deleted file mode 100644 index e9d5635..0000000 --- a/worker/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Splatoon 2 app Worker - -This Worker is the home for edge behavior on the Splatoon 2 domains. Its first -feature is a read-only directory index for the public R2 bucket. Ordinary object -requests continue to use the R2 custom domain directly and do not invoke the -Worker. - -## Directory index - -The browser-visible directory URLs end in `/`, for example: - -- `https://assets.splatoon2.ink/` -- `https://assets.splatoon2.ink/data/` -- `https://assets.splatoon2.ink/screenshots/` - -Each directory also has a non-recursive JSON representation at the same public -URL by adding `?format=json`. Clients must follow the absolute `next` URL when -present because the R2 cursor is opaque. - -The production Worker route is intentionally limited to: - -```text -assets.splatoon2.ink/__directory/* -``` - -The internal prefix never appears in links or canonical URLs returned by the -Worker. - -## Local development - -Run: - -```sh -npm run worker:dev -``` - -Then open `http://localhost:8787/`. The local-development flag lets Wrangler -serve natural directory paths and linked objects from its simulated R2 bucket. -It does not connect to the production bucket. - -Validate the Worker without deploying: - -```sh -npm run worker:test -npm run worker:deploy:dry-run -``` - -`npm run worker:deploy` deploys the Worker and must only be run as part of an -intentional production rollout. - -## Production rollout - -1. Deploy the Worker. -2. Verify a direct internal URL such as - `https://assets.splatoon2.ink/__directory/data/`. -3. In the `splatoon2.ink` zone, create a Cloudflare **URL Rewrite Rule** with - this filter expression: - - ```text - (http.host eq "assets.splatoon2.ink" and ends_with(http.request.uri.path, "/") and not starts_with(http.request.uri.path, "/__directory/")) - ``` - -4. Configure a dynamic path rewrite with this expression: - - ```text - concat("/__directory", http.request.uri.path) - ``` - -5. Leave the query string unchanged, deploy the rule, and verify the natural - root and nested directory URLs in both HTML and JSON formats. - -The rewrite runs only for paths ending in `/`; JSON files, calendars, images, -and screenshots remain direct R2 requests. To roll back, disable the URL Rewrite -Rule first, then remove the Worker route or deployment. diff --git a/worker/index.js b/worker/index.js deleted file mode 100644 index ddea0a4..0000000 --- a/worker/index.js +++ /dev/null @@ -1,631 +0,0 @@ -const INTERNAL_PREFIX = '/__directory'; -const PAGE_SIZE = 1000; - -/** @param {string} value */ -function escapeHtml(value) { - return value.replace(/[&<>"']/g, character => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\u0027': ''', - })[character]); -} - -/** @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 {number} bytes */ -function formatSize(bytes) { - if (bytes < 1024) { - return `${bytes} B`; - } - - let value = bytes; - let unit = 'B'; - for (let candidate of ['KB', 'MB', 'GB', 'TB']) { - 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 {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 {Request} request - * @param {number} status - * @param {string} message - * @param {HeadersInit} [headers] - */ -function textErrorResponse(request, status, message, headers = {}) { - return new Response(request.method === 'HEAD' ? null : message, { - status, - headers: { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/plain; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - ...headers, - }, - }); -} - -/** - * @param {Request} request - * @param {number} status - * @param {string} code - * @param {string} message - * @param {HeadersInit} [headers] - */ -function errorResponse(request, status, code, message, headers = {}) { - if (new URL(request.url).searchParams.get('format') !== 'json') { - return textErrorResponse(request, 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); -} - -/** - * @param {string} origin - * @param {string} prefix - * @param {R2Objects} listing - * @param {string | undefined} requestedCursor - */ -function directoryPage(origin, prefix, listing, requestedCursor) { - let pathname = publicPath(prefix); - return { - directories: listing.delimitedPrefixes.map(directoryPrefix => ({ - name: directoryPrefix.slice(prefix.length), - pathname: publicPath(directoryPrefix), - prefix: directoryPrefix, - url: new URL(publicPath(directoryPrefix), origin).href, - })), - files: listing.objects.map(object => ({ - etag: object.etag, - key: object.key, - name: object.key.slice(prefix.length), - pathname: publicPath(object.key), - size: object.size, - uploaded: object.uploaded, - url: new URL(publicPath(object.key), origin).href, - })), - nextCursor: listing.truncated ? listing.cursor : undefined, - pathname, - prefix, - requestedCursor, - url: new URL(pathname, origin).href, - }; -} - -/** @param {string} prefix */ -function breadcrumbHtml(prefix) { - let parts = prefix.split('/').filter(Boolean); - if (!parts.length) { - return 'assets'; - } - - let crumbs = ['assets']; - for (let index = 0; index < parts.length; index++) { - crumbs.push(''); - if (index === parts.length - 1) { - crumbs.push(`${escapeHtml(parts[index])}`); - } else { - let path = publicPath(`${parts.slice(0, index + 1).join('/')}/`); - crumbs.push(`${escapeHtml(parts[index])}`); - } - } - return crumbs.join(''); -} - -/** @param {ReturnType} page */ -function renderListing(page) { - let rows = []; - if (page.prefix) { - rows.push(`
  • Name: Parent directory
  • `); - } - for (let directory of page.directories) { - rows.push(`
  • Name: ${escapeHtml(directory.name)}Type: Folder
  • `); - } - for (let file of page.files) { - rows.push(`
  • Name: ${escapeHtml(file.name)}Last modified: Size: ${formatSize(file.size)}
  • `); - } - if (!rows.length) { - rows.push('
  • This directory is empty.
  • '); - } - - let jsonCursor = page.requestedCursor - ? `&cursor=${encodeURIComponent(page.requestedCursor)}` - : ''; - let next = page.nextCursor - ? `` - : ''; - let title = `Asset browser โ€” ${page.pathname}`; - let nonce = crypto.randomUUID().replaceAll('-', ''); - let folderCount = page.directories.length; - let fileCount = page.files.length; - let summary = `${folderCount} ${folderCount === 1 ? 'folder' : 'folders'} ยท ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`; - - return { - body: ` - - - - - - - - ${escapeHtml(title)} - - - -
    -
    -
    - -
    ${summary}JSON
    -
    -
    -
    -
    NameLast modifiedSize
    -
      ${rows.join('')}
    -
    -
    - -
    -
    - -`, - nonce, - }; -} - -/** - * @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 {R2Bucket} bucket - * @param {string} key - */ -async function localObjectResponse(request, bucket, key) { - let object = await bucket.get(key); - if (!object) { - return textErrorResponse(request, 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 }); -} - -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 errorResponse(request, 405, 'method_not_allowed', 'Method not allowed', { - Allow: 'GET, HEAD', - }); - } - - let encodedPath; - if (url.pathname.startsWith(`${INTERNAL_PREFIX}/`)) { - encodedPath = url.pathname.slice(INTERNAL_PREFIX.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 errorResponse(request, 400, 'invalid_path', 'Invalid path'); - } - return localObjectResponse(request, env.ASSETS, key); - } else { - return errorResponse(request, 404, 'not_found', 'Not found'); - } - - let prefix; - try { - prefix = decodeURIComponent(encodedPath); - } catch { - return errorResponse(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 listing; - try { - listing = await env.ASSETS.list({ - cursor, - delimiter: '/', - limit: PAGE_SIZE, - prefix, - }); - } catch (error) { - console.error(JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - message: 'R2 directory listing failed', - prefix, - })); - return errorResponse(request, 500, 'listing_unavailable', 'Directory listing unavailable'); - } - - if (prefix && !listing.objects.length && !listing.delimitedPrefixes.length) { - return errorResponse(request, 404, 'directory_not_found', 'Directory not found'); - } - - let page = directoryPage(url.origin, prefix, listing, cursor); - if (format === 'json') { - return jsonListingResponse(request, page); - } - - let listingHtml = renderListing(page); - return new Response(request.method === 'HEAD' ? null : listingHtml.body, { - headers: { - 'Cache-Control': 'no-store', - 'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${listingHtml.nonce}'; 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/worker/index.spec.mjs b/worker/index.spec.mjs deleted file mode 100644 index c2df229..0000000 --- a/worker/index.spec.mjs +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import worker from './index.js'; - -class FakeBucket -{ - calls = []; - - constructor(result = {}) { - this.result = { - cursor: undefined, - delimitedPrefixes: ['data/locale/'], - objects: [{ - etag: 'opaque-etag', - key: 'data/schedules.json', - size: 54861, - uploaded: new Date('2026-08-30T17:30:26Z'), - }], - truncated: false, - ...result, - }; - } - - async list(options) { - this.calls.push(options); - return this.result; - } -} - -describe('R2 directory index', () => { - it('renders a directory listing through the internal route with public links', async () => { - let bucket = new FakeBucket; - let response = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/data/', - ), { ASSETS: bucket }); - let html = await response.text(); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8'); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(html).toContain('
    '); - expect(html).toContain('class="entry-icon entry-icon--folder"'); - expect(html).toContain('class="entry-icon entry-icon--file"'); - expect(html).toContain('class="listing-header"'); - expect(html).toContain('href="/data/locale/"'); - expect(html).toContain('href="/data/schedules.json"'); - expect(html).not.toContain('__directory'); - expect(bucket.calls).toEqual([{ - cursor: undefined, - delimiter: '/', - limit: 1000, - prefix: 'data/', - }]); - }); - - it('returns paginated JSON with encoded canonical URLs', async () => { - let bucket = new FakeBucket({ - cursor: 'next page', - delimitedPrefixes: ['screenshots/Octo Expansion/bosses/'], - objects: [{ - etag: 'etag-value', - key: 'screenshots/Octo Expansion/odd<&".png', - size: 1536, - uploaded: new Date('2026-08-31T01:02:03Z'), - }], - truncated: true, - }); - let response = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/screenshots/Octo%20Expansion/?format=json&cursor=current%20page', - ), { ASSETS: bucket }); - - expect(response.status).toBe(200); - expect(response.headers.get('access-control-allow-origin')).toBe('*'); - expect(await response.json()).toEqual({ - schemaVersion: 1, - directory: { - prefix: 'screenshots/Octo Expansion/', - url: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/', - }, - directories: [{ - name: 'bosses/', - prefix: 'screenshots/Octo Expansion/bosses/', - url: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/bosses/', - }], - files: [{ - name: 'odd<&".png', - key: 'screenshots/Octo Expansion/odd<&".png', - url: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/odd%3C%26%22.png', - size: 1536, - uploaded: '2026-08-31T01:02:03.000Z', - etag: 'etag-value', - }], - next: 'https://assets.splatoon2.ink/screenshots/Octo%20Expansion/?format=json&cursor=next%20page', - }); - expect(bucket.calls).toEqual([{ - cursor: 'current page', - delimiter: '/', - limit: 1000, - prefix: 'screenshots/Octo Expansion/', - }]); - }); - - it('escapes object names and preserves pagination in HTML', async () => { - let bucket = new FakeBucket({ - cursor: 'opaque cursor', - delimitedPrefixes: [], - objects: [{ - key: 'odd<&".json', - size: 1536, - uploaded: new Date('2026-08-30T17:30:26Z'), - }], - truncated: true, - }); - let response = await worker.fetch( - new Request('https://assets.splatoon2.ink/__directory/'), - { ASSETS: bucket }, - ); - let html = await response.text(); - - expect(html).toContain(''); - expect(html).toContain('href="/odd%3C%26%22.json"'); - expect(html).toContain('odd<&".json'); - expect(html).toContain('1.5 KB'); - expect(html).toContain('2026-08-30 17:30:26 UTC'); - expect(html).toContain('href="/?cursor=opaque%20cursor"'); - }); - - it('supports HEAD and rejects other methods before reading R2', async () => { - let headBucket = new FakeBucket; - let headResponse = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/data/?format=json', - { method: 'HEAD' }, - ), { ASSETS: headBucket }); - let postBucket = new FakeBucket; - let postResponse = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/data/', - { method: 'POST' }, - ), { ASSETS: postBucket }); - - expect(headResponse.status).toBe(200); - expect(headResponse.headers.get('content-type')).toBe('application/json; charset=utf-8'); - expect(await headResponse.text()).toBe(''); - expect(headBucket.calls).toHaveLength(1); - expect(postResponse.status).toBe(405); - expect(postResponse.headers.get('allow')).toBe('GET, HEAD'); - expect(await postResponse.text()).toBe('Method not allowed'); - expect(postBucket.calls).toHaveLength(0); - }); - - it('returns a controlled machine-readable response when R2 fails', async () => { - let errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); - let bucket = { - list: vi.fn().mockRejectedValue(new Error('R2 unavailable')), - }; - let response = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/data/?format=json', - ), { ASSETS: bucket }); - - expect(response.status).toBe(500); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(await response.json()).toEqual({ - schemaVersion: 1, - error: { - code: 'listing_unavailable', - message: 'Directory listing unavailable', - }, - }); - expect(errorLog).toHaveBeenCalledWith(JSON.stringify({ - error: 'R2 unavailable', - message: 'R2 directory listing failed', - prefix: 'data/', - })); - errorLog.mockRestore(); - }); - - it('returns controlled errors for invalid paths, formats, and missing directories', async () => { - let bucket = new FakeBucket({ - delimitedPrefixes: [], - objects: [], - }); - - let invalidPath = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/%E0%A4%A', - ), { ASSETS: bucket }); - let unsupportedFormat = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/data/?format=xml', - ), { ASSETS: bucket }); - let missingDirectory = await worker.fetch(new Request( - 'https://assets.splatoon2.ink/__directory/missing/', - ), { ASSETS: bucket }); - - expect(invalidPath.status).toBe(400); - expect(await invalidPath.text()).toBe('Invalid path'); - expect(unsupportedFormat.status).toBe(400); - expect(await unsupportedFormat.json()).toEqual({ - schemaVersion: 1, - error: { - code: 'unsupported_format', - message: 'Unsupported format', - }, - }); - expect(missingDirectory.status).toBe(404); - expect(await missingDirectory.text()).toBe('Directory not found'); - expect(bucket.calls).toHaveLength(1); - }); -}); diff --git a/wrangler.jsonc b/wrangler.jsonc deleted file mode 100644 index 9a69aa7..0000000 --- a/wrangler.jsonc +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "splatoon2-ink-app", - "main": "worker/index.js", - "compatibility_date": "2026-09-02", - "compatibility_flags": ["nodejs_compat"], - "workers_dev": false, - "preview_urls": false, - "routes": [ - { - "pattern": "assets.splatoon2.ink/__directory/*", - "zone_name": "splatoon2.ink" - } - ], - "r2_buckets": [ - { - "binding": "ASSETS", - "bucket_name": "splatoon2-ink-assets" - } - ], - "observability": { - "enabled": true, - "logs": { - "head_sampling_rate": 1 - }, - "traces": { - "enabled": true, - "head_sampling_rate": 0.01 - } - } -}