mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-29 20:56:35 -05:00
Merge origin/main and remove migrated asset browser
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build --modern --no-clean",
|
||||
"test": "node --test && npm run worker:test && npm run updater:test",
|
||||
"test": "node --test && npm run updater:test",
|
||||
"lint": "eslint .",
|
||||
"cron": "node src/app/cron.js",
|
||||
"start": "npm run splatnet && npm run social && npm run cron",
|
||||
@@ -27,10 +27,6 @@
|
||||
"utility:copyTranslation": "node src/utility/index.js copyTranslation",
|
||||
"utility:getSplatNetLanguageFiles": "node src/utility/index.js getSplatNetLanguageFiles",
|
||||
"utility:updateGear": "node src/utility/index.js 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",
|
||||
"lint-fix": "npm run lint -- --fix",
|
||||
"updater:dev": "wrangler dev --config workers/updater/wrangler.jsonc --env dev --test-scheduled",
|
||||
"updater:test": "vitest run --config workers/updater/vitest.config.mjs",
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
631
worker/index.js
631
worker/index.js
@@ -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 '<span aria-current="page">assets</span>';
|
||||
}
|
||||
|
||||
let crumbs = ['<a href="/">assets</a>'];
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
crumbs.push('<span class="breadcrumb-separator" aria-hidden="true">/</span>');
|
||||
if (index === parts.length - 1) {
|
||||
crumbs.push(`<span aria-current="page">${escapeHtml(parts[index])}</span>`);
|
||||
} else {
|
||||
let path = publicPath(`${parts.slice(0, index + 1).join('/')}/`);
|
||||
crumbs.push(`<a href="${path}">${escapeHtml(parts[index])}</a>`);
|
||||
}
|
||||
}
|
||||
return crumbs.join('');
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof directoryPage>} page */
|
||||
function renderListing(page) {
|
||||
let rows = [];
|
||||
if (page.prefix) {
|
||||
rows.push(`<li><a class="listing-row parent-row" href="${parentPath(page.prefix)}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--parent" aria-hidden="true">↑</span><span class="entry-name">Parent directory</span></span><span class="listing-cell"></span><span class="listing-cell"></span></a></li>`);
|
||||
}
|
||||
for (let directory of page.directories) {
|
||||
rows.push(`<li><a class="listing-row" href="${directory.pathname}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--folder" aria-hidden="true"></span><span class="entry-name">${escapeHtml(directory.name)}</span></span><span class="listing-cell"></span><span class="listing-cell"><span class="visually-hidden">Type: </span><span class="type-label">Folder</span></span></a></li>`);
|
||||
}
|
||||
for (let file of page.files) {
|
||||
rows.push(`<li><a class="listing-row" href="${file.pathname}"><span class="listing-cell entry"><span class="visually-hidden">Name: </span><span class="entry-icon entry-icon--file" aria-hidden="true"></span><span class="entry-name">${escapeHtml(file.name)}</span></span><span class="listing-cell"><span class="visually-hidden">Last modified: </span><time datetime="${file.uploaded.toISOString()}">${formatDate(file.uploaded)}</time></span><span class="listing-cell"><span class="visually-hidden">Size: </span>${formatSize(file.size)}</span></a></li>`);
|
||||
}
|
||||
if (!rows.length) {
|
||||
rows.push('<li class="empty-state">This directory is empty.</li>');
|
||||
}
|
||||
|
||||
let jsonCursor = page.requestedCursor
|
||||
? `&cursor=${encodeURIComponent(page.requestedCursor)}`
|
||||
: '';
|
||||
let next = page.nextCursor
|
||||
? `<a class="next-page" rel="next" href="${page.pathname}?cursor=${encodeURIComponent(page.nextCursor)}">Next page <span aria-hidden="true">→</span></a>`
|
||||
: '';
|
||||
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: `<!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="${page.url}">
|
||||
<link rel="alternate" type="application/json" href="${page.pathname}?format=json${jsonCursor}">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style nonce="${nonce}">
|
||||
: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">${breadcrumbHtml(page.prefix)}</nav>
|
||||
<div class="browser-meta"><span class="summary">${summary}</span><a class="json-link" href="${page.pathname}?format=json${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.join('')}</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="browser-footer">${next}</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {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',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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('<main class="shell">');
|
||||
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('<link rel="canonical" href="https://assets.splatoon2.ink/">');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user