mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-09-27 09:36:25 -05:00
Remove asset browser after shared Worker migration
This commit is contained in:
1
.github/workflows/build-frontend.yml
vendored
1
.github/workflows/build-frontend.yml
vendored
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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/</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>"archive-etag"</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&cursor=current%20page">');
|
||||
expect(html).toContain('href="/data/?format=json&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<&".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();
|
||||
});
|
||||
});
|
||||
@@ -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}}&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}}&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>
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user