Remove asset browser after shared Worker migration

This commit is contained in:
Matt Isenhower
2026-09-13 12:03:23 -07:00
parent d2ea672a78
commit f89226fbbb
9 changed files with 1 additions and 1340 deletions

View File

@@ -25,4 +25,3 @@ jobs:
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run assets-browser:deploy:dry-run

11
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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.

View File

@@ -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<R2Objects>} 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<typeof directoryPage>} 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<typeof directoryPage>} 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<Env>} */
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',
},
});
},
};

View File

@@ -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('<h1>Asset browser</h1>');
expect(html).not.toContain('Public Splatoon 3 data and media');
expect(html).toContain('<span aria-current="page">data</span>');
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('<span class="entry-name">archive&#x2F;</span>');
});
it('browses archive directories while linking files to the public archive origin', async () => {
let fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(`
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Prefix>2026/08/</Prefix>
<IsTruncated>true</IsTruncated>
<Contents>
<Key>2026/08/2026-08-01.tar.zst</Key>
<LastModified>2026-08-31T01:02:03.000Z</LastModified>
<ETag>&quot;archive-etag&quot;</ETag>
<Size>1536</Size>
</Contents>
<CommonPrefixes><Prefix>2026/08/31/</Prefix></CommonPrefixes>
<NextContinuationToken>next archive page</NextContinuationToken>
</ListBucketResult>
`, { 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(`<style nonce="${nonce}">`);
expect(html).toContain('@media (prefers-color-scheme: dark)');
expect(html).toContain('@media (max-width: 640px)');
expect(html).toContain('<meta name="color-scheme" content="light dark">');
});
it('passes an opaque pagination cursor to R2', async () => {
let bucket = new FakeBucket;
let request = new Request(
'https://assets.splatoon3.ink/__directory/data/?cursor=current%20page',
);
let response = await worker.fetch(request, { ASSETS: bucket });
let html = await response.text();
expect(bucket.calls).toEqual([{
cursor: 'current page',
delimiter: '/',
limit: 1000,
prefix: 'data/',
}]);
expect(response.status).toBe(200);
expect(html).toContain('href="/data/?cursor=next%20page"');
expect(html).toContain('<link rel="alternate" type="application/json" href="/data/?format=json&amp;cursor=current%20page">');
expect(html).toContain('href="/data/?format=json&amp;cursor=current%20page">JSON</a>');
});
it('renders a safe root listing with object metadata', async () => {
let bucket = new FakeBucket({
cursor: undefined,
delimitedPrefixes: ['data/'],
objects: [{
key: 'odd<&".json',
size: 1536,
uploaded: new Date('2026-08-30T17:30:26Z'),
}],
truncated: false,
});
let response = await worker.fetch(
new Request('https://assets.splatoon3.ink/__directory/'),
{ ASSETS: bucket },
);
let html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<link rel="canonical" href="https://assets.splatoon3.ink/">');
expect(html).not.toContain('>../</a>');
expect(html).toContain('href="/data/"');
expect(html).toContain('href="/odd%3C%26%22.json"');
expect(html).toContain('odd&lt;&amp;&quot;.json');
expect(html).toContain('1.5 KB');
expect(html).toContain('2026-08-30 17:30:26 UTC');
});
it('rejects unsupported methods without listing the bucket', async () => {
let bucket = new FakeBucket;
let response = await worker.fetch(new Request(
'https://assets.splatoon3.ink/__directory/data/',
{ method: 'POST' },
), { ASSETS: bucket });
expect(response.status).toBe(405);
expect(response.headers.get('allow')).toBe('GET, HEAD');
expect(response.headers.get('cache-control')).toBe('no-store');
expect(bucket.calls).toHaveLength(0);
});
it('returns a non-cacheable 404 for an unknown prefix', async () => {
let response = await worker.fetch(new Request(
'https://assets.splatoon3.ink/__directory/missing/',
), env);
expect(response.status).toBe(404);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.text()).toBe('Directory not found');
});
it('returns machine-readable errors for JSON listing requests', async () => {
let response = await worker.fetch(new Request(
'https://assets.splatoon3.ink/__directory/missing/?format=json',
), env);
expect(response.status).toBe(404);
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,
error: {
code: 'directory_not_found',
message: 'Directory not found',
},
});
});
it('rejects unsupported listing formats without reading R2', async () => {
let bucket = new FakeBucket;
let response = await worker.fetch(new Request(
'https://assets.splatoon3.ink/__directory/data/?format=xml',
), { ASSETS: bucket });
expect(response.status).toBe(400);
expect(response.headers.get('access-control-allow-origin')).toBe('*');
expect(await response.json()).toEqual({
schemaVersion: 1,
error: {
code: 'unsupported_format',
message: 'Unsupported format',
},
});
expect(bucket.calls).toHaveLength(0);
});
it('returns a controlled error when R2 listing 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.splatoon3.ink/__directory/data/',
), { ASSETS: bucket });
expect(response.status).toBe(500);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.text()).toBe('Directory listing unavailable');
expect(errorLog).toHaveBeenCalledWith(JSON.stringify({
error: 'R2 unavailable',
message: 'R2 directory listing failed',
prefix: 'data/',
}));
errorLog.mockRestore();
});
it('returns a controlled error when the archive listing fails', async () => {
let errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
let fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('Unavailable', { status: 503 }),
);
let response = await worker.fetch(new Request(
'https://assets.splatoon3.ink/__directory/data/archive/2026/',
), {
ARCHIVE_ORIGIN: 'https://data-archive.splatoon3.ink',
ASSETS: new FakeBucket,
});
expect(response.status).toBe(500);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.text()).toBe('Directory listing unavailable');
expect(errorLog).toHaveBeenCalledWith(JSON.stringify({
error: 'Archive listing returned 503',
message: 'Archive directory listing failed',
prefix: 'data/archive/2026/',
}));
fetchSpy.mockRestore();
errorLog.mockRestore();
});
});

View File

@@ -1,338 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="canonical" href="{{{canonicalUrl}}}">
<link rel="alternate" type="application/json" href="{{{jsonPath}}}?format=json{{#jsonCursor}}&amp;cursor={{jsonCursor}}{{/jsonCursor}}">
<title>{{title}}</title>
<style nonce="{{styleNonce}}">
:root {
color-scheme: light dark;
--background: #f3f5f9;
--background-accent: #e8e4ff;
--surface: rgba(255, 255, 255, 0.88);
--surface-strong: #fff;
--text: #18202f;
--muted: #657084;
--border: #dce1ea;
--accent: #6047d9;
--accent-soft: #eeeaff;
--folder: #8a6cff;
--file: #8290a6;
--row-hover: #f7f5ff;
--shadow: 0 24px 70px rgba(38, 29, 82, 0.12);
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 15% 0%, var(--background-accent), transparent 34rem),
var(--background);
color: var(--text);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
a { color: inherit; }
.shell {
width: min(960px, calc(100% - 40px));
margin: 0 auto;
padding: 48px 0;
}
.browser {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.browser-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 68px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface-strong);
}
.breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
overflow: hidden;
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.9rem;
white-space: nowrap;
}
.breadcrumbs a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.breadcrumbs a:hover { text-decoration: underline; }
.breadcrumbs [aria-current="page"] { overflow: hidden; text-overflow: ellipsis; }
.breadcrumb-separator { color: var(--muted); }
.summary {
color: var(--muted);
font-size: 0.82rem;
}
.browser-meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 12px;
}
.json-link {
border-radius: 999px;
padding: 3px 9px;
background: var(--accent-soft);
color: var(--accent);
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
font-weight: 750;
text-decoration: none;
}
.json-link:hover { text-decoration: underline; }
.listing-wrap { overflow-x: auto; }
.listing {
min-width: 520px;
width: 100%;
font-size: 0.9rem;
}
.listing-items {
margin: 0;
padding: 0;
list-style: none;
}
.listing-header, .listing-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 210px 110px;
}
.listing-header > span {
padding: 12px 20px;
color: var(--muted);
font-size: 0.7rem;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.listing-row {
min-height: 56px;
border-top: 1px solid var(--border);
color: var(--muted);
text-decoration: none;
transition: background-color 120ms ease;
}
.listing-row:hover { background: var(--row-hover); }
.listing-row:focus-visible {
border-radius: 4px;
outline: 2px solid var(--accent);
outline-offset: -3px;
}
.listing-cell {
display: flex;
min-width: 0;
align-items: center;
padding: 8px 20px;
white-space: nowrap;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.entry {
max-width: min(560px, 58vw);
gap: 12px;
color: var(--text);
font-weight: 620;
}
.entry-name { overflow: hidden; text-overflow: ellipsis; }
.listing-row:hover .entry-name { color: var(--accent); text-decoration: underline; }
.entry-icon {
position: relative;
display: inline-block;
flex: 0 0 18px;
width: 18px;
height: 18px;
}
.entry-icon--folder {
height: 13px;
margin-top: 3px;
border-radius: 3px;
background: var(--folder);
}
.entry-icon--folder::before {
position: absolute;
top: -4px;
left: 1px;
width: 8px;
height: 5px;
border-radius: 3px 3px 0 0;
background: var(--folder);
content: "";
}
.entry-icon--file {
border: 2px solid var(--file);
border-radius: 3px;
}
.entry-icon--file::after {
position: absolute;
right: 2px;
bottom: 3px;
left: 2px;
height: 2px;
background: var(--file);
box-shadow: 0 -4px 0 var(--file);
content: "";
}
.entry-icon--parent {
display: grid;
place-items: center;
border-radius: 5px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.85rem;
font-weight: 800;
}
.parent-row .entry { color: var(--muted); font-weight: 560; }
.type-label {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.72rem;
font-weight: 700;
}
.empty-state {
display: grid;
height: 120px;
place-items: center;
border-top: 1px solid var(--border);
text-align: center;
}
.browser-footer {
display: flex;
justify-content: flex-end;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: var(--surface-strong);
}
.browser-footer:empty { display: none; }
.next-page {
color: var(--accent);
font-size: 0.86rem;
font-weight: 700;
text-decoration: none;
}
.next-page:hover { text-decoration: underline; }
@media (prefers-color-scheme: dark) {
:root {
--background: #0b0e15;
--background-accent: #201a43;
--surface: rgba(20, 24, 35, 0.9);
--surface-strong: #151923;
--text: #f3f4f8;
--muted: #9ba4b5;
--border: #2b3240;
--accent: #a997ff;
--accent-soft: #292443;
--folder: #9b87ff;
--file: #8590a4;
--row-hover: #1d2030;
--shadow: 0 28px 80px rgba(0, 0, 0, 0.4);
}
}
@media (max-width: 640px) {
.shell { width: min(100% - 24px, 960px); padding: 24px 0; }
.browser-header { align-items: flex-start; flex-direction: column; gap: 6px; }
.browser-meta { width: 100%; justify-content: space-between; }
.listing-header, .listing-row { grid-template-columns: minmax(0, 1fr) 190px 92px; }
.listing-header > span, .listing-cell { padding-right: 14px; padding-left: 14px; }
.entry { max-width: calc(100vw - 150px); }
}
</style>
</head>
<body>
<main class="shell">
<section class="browser" aria-label="Directory contents">
<div class="browser-header">
<nav class="breadcrumbs" aria-label="Breadcrumb">{{#breadcrumbs}}{{#separator}}<span class="breadcrumb-separator" aria-hidden="true">/</span>{{/separator}}{{#link}}<a href="{{{href}}}">{{name}}</a>{{/link}}{{#current}}<span aria-current="page">{{name}}</span>{{/current}}{{/breadcrumbs}}</nav>
<div class="browser-meta">
<span class="summary">{{summary}}</span>
<a class="json-link" href="{{{jsonPath}}}?format=json{{#jsonCursor}}&amp;cursor={{jsonCursor}}{{/jsonCursor}}">JSON</a>
</div>
</div>
<div class="listing-wrap">
<div class="listing">
<div class="listing-header"><span>Name</span><span>Last modified</span><span>Size</span></div>
<ul class="listing-items" aria-label="Directory entries">
{{#rows}}
<li>
<a class="listing-row{{#isParent}} parent-row{{/isParent}}" href="{{{href}}}">
<span class="listing-cell entry"><span class="visually-hidden">Name: </span>{{#isParent}}<span class="entry-icon entry-icon--parent" aria-hidden="true">↑</span>{{/isParent}}{{#isFolder}}<span class="entry-icon entry-icon--folder" aria-hidden="true"></span>{{/isFolder}}{{#isFile}}<span class="entry-icon entry-icon--file" aria-hidden="true"></span>{{/isFile}}<span class="entry-name">{{name}}</span></span>
<span class="listing-cell">{{#isFile}}<span class="visually-hidden">Last modified: </span><time datetime="{{datetime}}">{{date}}</time>{{/isFile}}</span>
<span class="listing-cell">{{#isFolder}}<span class="visually-hidden">Type: </span><span class="type-label">Folder</span>{{/isFolder}}{{#isFile}}<span class="visually-hidden">Size: </span>{{size}}{{/isFile}}</span>
</a>
</li>
{{/rows}}
{{^hasRows}}<li class="empty-state">This directory is empty.</li>{{/hasRows}}
</ul>
</div>
</div>
<div class="browser-footer">{{#next}}<a class="next-page" rel="next" href="{{{href}}}">Next page <span aria-hidden="true">→</span></a>{{/next}}</div>
</section>
</main>
</body>
</html>

View File

@@ -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'],
},
});

View File

@@ -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
}
}
}