Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions docs/story-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ The episodes come from the **story sites** in the storage bucket, one per game r
(`assetConfig.storySite`, `PUBLIC_STORY_SITE`, default `https://storage.bdon.moe/moenotes`; TW/HK/MO, every text
language) and the JP site (`assetConfig.storySiteJp`, `PUBLIC_STORY_SITE_JP`, default
`https://storage.bdon.moe/moenotes/jp`; Japanese only, JP-only episodes such as the event stories). They are separate
sites because JP Live2D model ids overlap the international ones. The player reads both indexes and lists them as one
(`mergeStorySiteEntries`): an episode on both plays from the international site; an unreadable site leaves only its
stories out. Each site is the layout `nnnotes web` writes (the format is ournotes-player's
`docs/story-data-format.md`, manifests `ournotes.story-manifest/2`):
sites because JP Live2D model ids overlap the international ones. The server reads each regional index, matches entries to that region's MasterData, and returns only officially released
stories through `/api/v1/story-site/index`. The browser receives same-origin proxy roots, not the storage bucket URLs;
an episode on both sites remains associated with its source region. Each site is the layout `nnnotes web` writes (the
format is ournotes-player's `docs/story-data-format.md`, manifests `ournotes.story-manifest/2`):

```text
{storySite}/stories.json story index (ournotes.stories/1: titles, languages, sizes), read at runtime
Expand All @@ -35,22 +35,32 @@ bucket lacks. The bucket's CORS allows `https://bdon.moe` (and `http://localhost
origin needs that origin added there.
The player version (`ournotes-player` in package.json) must read what that workflow writes (`STORY_PLAYER_REF` there).

- The picker lists the build's story data (`getBuildStories`: names, groups, episode numbers and banners, localized at
build time) for the episodes `stories.json` lists, in the story pages' order and in their three lists: main story by
chapter (with its banner; main, another and extra episodes as separate runs), bond stories by pair, and the other
talks by type. Episodes the build does not know yet are listed last under the other talks by the site's own titles.
Chapter and episode artwork keep their source server; entries played from the JP site use the JP Japanese asset
catalog, including episodes already present in the international MasterData whose artwork is not published there.
- A `?story=` the index does not list is opened from its manifest (its `story` block is the index entry): a site build
publishes each manifest as it lands and rewrites `stories.json` at its end. The story page's link checks the manifest
the same way (a `HEAD`), so it never leads to a missing story.
- The picker intersects the filtered cloud index with the locally built story catalog (`getBuildStories`: names, groups,
episode numbers and banners, localized at build time). Cloud-only rows are omitted because cloud presence is not proof
of an in-game release. `/api/v1/story-site/index` checks the same-region MasterData release date and rejects unknown,
missing-date and malformed-date records. The global picker stays locked until every region that lists a story has
officially released it. Chapter and episode artwork keep their source server; entries played from the JP site use the
JP Japanese asset catalog.
- A `?story=` is only an id hint: unknown or unreleased ids are never fetched directly from a cloud manifest. The
story-scoped `/api/v1/story-site/{tw|jp}/stories/{advId}.json` proxy repeats the regional MasterData check, validates
the upstream manifest's `advId` and region, and only serves content-addressed assets and model manifests listed by
that episode's manifests. The proxy rewrites the story root so the player loads those resources through the same
gate. This intentionally drops the previous early-manifest shortcut, where the storage workflow could publish a
manifest before rewriting `stories.json`. The story-page link checks the filtered regional index instead of probing a
public manifest with `HEAD`.
- The story starts in the first of the locale's text languages that the episode has (masterdata's order,
`assetLanguageOrder`); the controls' labels follow the locale. Switching the language in the header goes through
the player (`setLanguage`: it loads that language's files and restarts at the current line), not a reload.
- A story's `assets/` files, its models' included, are kept in the browser once downloaded (`storyPlayerFetch`: the
player file cache of `src/lib/cache/player-files.ts` the Live2D viewer uses too), so replaying an episode or switching
its language fetches only the manifests again; the Live2D viewer reads the models the story site has from it, so a
model seen there is already kept too. The settings' Data tab shows what is kept (backgrounds with the images, voices and music with the audio) and clears it ([browser-cache.md](browser-cache.md)).
player file cache of `src/lib/cache/player-files.ts`), so replaying an episode or switching its language fetches only
the manifests again. The settings' Data tab shows what is kept (backgrounds with the images, voices and music with the
audio) and clears it ([browser-cache.md](browser-cache.md)).

**Deployment limitation:** the same-origin proxy gates requests made through this site, but the configured storage
bucket is still publicly addressable. This repository cannot prevent a caller from requesting a known raw
`{storySite}/stories/<advId>.json`, `{storySite}/models/<id>.json`, or `{storySite}/assets/<hash>` URL directly. To satisfy
a no-direct-link guarantee, the bucket/CDN must also deny public reads (or serve gated URLs only); until that is changed,
release protection is not complete outside the Moenotes application.

## Layout

Expand Down
61 changes: 61 additions & 0 deletions src/app/api/v1/live2d/[server]/[source]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { NextRequest } from "next/server";
import { buildFetch } from "@/lib/build/fetch";
import { getGatedLive2DBundle, getApprovedLive2DResourcePath, isLive2DSource } from "@/lib/live2d/site-access";

export const dynamic = "force-dynamic";

interface RouteContext {
params: Promise<{ server: string; source: string; path: string[] }>;
}

const NO_STORE = { "Cache-Control": "private, no-store, max-age=0" };
const NOT_FOUND = () => Response.json({ error: "Live2D resource not found" }, { status: 404, headers: NO_STORE });

/** Proxy model manifests and assets only for costume identities released in the requested server's MasterData. */
export async function GET(_request: NextRequest, { params }: RouteContext) {
const { server, source, path } = await params;
if (!Array.isArray(path) || !isLive2DSource(source)) return NOT_FOUND();

const manifestMatch = path.length === 2 && path[0] === "models"
? /^([a-z0-9_]+)\.json$/.exec(path[1] ?? "")
: null;
const isModelAssetPath = path.length === 4 && path[0] === "models" && path[2] === "assets";
const modelId = manifestMatch?.[1] ?? (isModelAssetPath ? path[1] : undefined);
if (!modelId || !/^[a-z0-9_]+$/.test(modelId)) return NOT_FOUND();

const resourcePath = isModelAssetPath
? getApprovedLive2DResourcePath(path.slice(2).join("/"))
: null;
if (!manifestMatch && (!isModelAssetPath || !resourcePath)) return NOT_FOUND();

const bundle = await getGatedLive2DBundle(server, source, modelId).catch((error: unknown) => {
console.error("[live2d] failed to verify a model bundle:", error);
return null;
});
if (!bundle) return NOT_FOUND();

if (manifestMatch) return Response.json(bundle.manifest, { headers: NO_STORE });
if (!resourcePath || !bundle.assetPaths.has(resourcePath)) return NOT_FOUND();

const upstreamUrl = new URL(resourcePath, `${bundle.root.replace(/\/+$/, "")}/`);
let upstream: Response;
try {
upstream = await buildFetch(upstreamUrl, { cache: "no-store", redirect: "error" });
} catch (error) {
console.error("[live2d] failed to fetch a verified model asset:", error);
return Response.json({ error: "Model resource is temporarily unavailable" }, { status: 502, headers: NO_STORE });
}
if (!upstream.ok || !upstream.body) return NOT_FOUND();

const headers = new Headers(NO_STORE);
const contentType = upstream.headers.get("content-type");
const contentEncoding = upstream.headers.get("content-encoding");
if (contentType) headers.set("Content-Type", contentType);
if (contentEncoding) headers.set("Content-Encoding", contentEncoding);
return new Response(upstream.body, { status: 200, headers });
}

export async function HEAD(request: NextRequest, context: RouteContext) {
const response = await GET(request, context);
return new Response(null, { status: response.status, headers: response.headers });
}
22 changes: 22 additions & 0 deletions src/app/api/v1/live2d/index/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { NextRequest } from "next/server";
import { isGameServer } from "@/config/servers";
import { getReleasedLive2DModels } from "@/lib/live2d/site-access";

export const dynamic = "force-dynamic";

const NO_STORE = { "Cache-Control": "private, no-store, max-age=0" };

/** A cloud model index is only a locator; models are listed only after regional MasterData verifies their costume. */
export async function GET(request: NextRequest) {
const server = request.nextUrl.searchParams.get("server") ?? "";
if (!isGameServer(server)) {
return Response.json({ error: "Invalid game server" }, { status: 400, headers: NO_STORE });
}
try {
const models = await getReleasedLive2DModels(server);
return Response.json({ models }, { headers: { ...NO_STORE, "X-Moenotes-Live2D-Region": server } });
} catch (error) {
console.error("[live2d] failed to verify the regional model index:", error);
return Response.json({ error: "Model index is temporarily unavailable" }, { status: 502, headers: NO_STORE });
}
}
85 changes: 85 additions & 0 deletions src/app/api/v1/story-site/[server]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { NextRequest } from "next/server";
import { buildFetch } from "@/lib/build/fetch";
import { getStorySites } from "@/lib/story/player-data";
import {
getApprovedStoryResourcePath,
getGatedStoryBundle,
isStorySiteRegion,
} from "@/lib/story/site-access";

export const dynamic = "force-dynamic";

interface RouteContext {
params: Promise<{ server: string; path: string[] }>;
}

const PRIVATE_NO_STORE = { "Cache-Control": "private, no-store, max-age=0" };
const NOT_FOUND = () => Response.json({ error: "Story resource not found" }, { status: 404, headers: PRIVATE_NO_STORE });

/**
* Proxy a story manifest or one of the assets it names. The episode's regional MasterData release date is checked
* before every request, and file requests must be listed by that released episode's story/model manifests.
*/
export async function GET(_request: NextRequest, { params }: RouteContext) {
const { server, path } = await params;
if (!isStorySiteRegion(server) || !Array.isArray(path)) return NOT_FOUND();

const manifestMatch = path.length === 2 && path[0] === "stories"
? /^([1-9]\d*)\.json$/.exec(path[1] ?? "")
: null;
if (manifestMatch) {
const advId = Number(manifestMatch[1]);
const bundle = await getGatedStoryBundle(server, advId).catch((error: unknown) => {
console.error("[story-player] failed to verify a story manifest:", error);
return null;
});
return bundle
? Response.json(bundle.manifest, { headers: PRIVATE_NO_STORE })
: NOT_FOUND();
}

const resourceMatch = path.length >= 4 && path[0] === "stories"
? /^([1-9]\d*)$/.exec(path[1] ?? "")
: null;
if (!resourceMatch) return NOT_FOUND();

const advId = Number(resourceMatch[1]);
const resourcePath = getApprovedStoryResourcePath(path.slice(2).join("/"));
if (!resourcePath) return NOT_FOUND();

const bundle = await getGatedStoryBundle(server, advId).catch((error: unknown) => {
console.error("[story-player] failed to verify a story resource:", error);
return null;
});
if (!bundle) return NOT_FOUND();

const modelManifest = bundle.modelManifests.get(resourcePath);
if (modelManifest) {
return Response.json(modelManifest, { headers: PRIVATE_NO_STORE });
}
if (!bundle.assetPaths.has(resourcePath)) return NOT_FOUND();

const site = getStorySites().find((candidate) => candidate.server === server);
if (!site) return NOT_FOUND();
const upstreamUrl = new URL(resourcePath, `${site.root.replace(/\/+$/, "")}/`);
let upstream: Response;
try {
upstream = await buildFetch(upstreamUrl, { cache: "no-store", redirect: "error" });
} catch (error) {
console.error("[story-player] failed to fetch a verified story asset:", error);
return Response.json({ error: "Story resource is temporarily unavailable" }, { status: 502, headers: PRIVATE_NO_STORE });
}
if (!upstream.ok || !upstream.body) return NOT_FOUND();

const headers = new Headers(PRIVATE_NO_STORE);
const contentType = upstream.headers.get("content-type");
const contentEncoding = upstream.headers.get("content-encoding");
if (contentType) headers.set("Content-Type", contentType);
if (contentEncoding) headers.set("Content-Encoding", contentEncoding);
return new Response(upstream.body, { status: 200, headers });
}

export async function HEAD(request: NextRequest, context: RouteContext) {
const response = await GET(request, context);
return new Response(null, { status: response.status, headers: response.headers });
}
32 changes: 32 additions & 0 deletions src/app/api/v1/story-site/index/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { NextRequest } from "next/server";
import {
getReleasedStorySiteIndex,
isStorySiteRegion,
toPublicStoryIndexEntry,
} from "@/lib/story/site-access";

export const dynamic = "force-dynamic";

/**
* A cloud listing is only a locator, never release evidence. Return a small, sanitized list only after the same-region
* MasterData confirms each story's official in-game release time.
*/
export async function GET(request: NextRequest) {
const server = request.nextUrl.searchParams.get("server") ?? "";
if (!isStorySiteRegion(server)) {
return Response.json({ error: "Unsupported story site" }, { status: 400, headers: { "Cache-Control": "no-store" } });
}

try {
const { stories } = await getReleasedStorySiteIndex(server);
return Response.json({ stories: stories.map(toPublicStoryIndexEntry) }, {
headers: {
"Cache-Control": "public, max-age=30, stale-while-revalidate=60",
"X-Moenotes-Story-Region": server,
},
});
} catch (error) {
console.error("[story-player] failed to verify the regional story index:", error);
return Response.json({ error: "Story index is temporarily unavailable" }, { status: 502, headers: { "Cache-Control": "no-store" } });
}
}
3 changes: 1 addition & 2 deletions src/components/cards/CardDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { localizePath } from "@/i18n/routing";
import { getRoutePathById } from "@/lib/route/registry";
import { useNow } from "@/lib/schedule/use-now";
import { isSpoiler, useSpoiler } from "@/lib/settings/spoiler";
import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice";
import SpoilerNotice from "@/components/shared/SpoilerNotice";
import {
getCardBackgroundUrl,
getCardCharacterUrl,
Expand Down Expand Up @@ -137,7 +137,6 @@ function CardDetailView({ locale, data, materials, serverSwitch }: { locale: App

return (
<div className="w-full space-y-8">
{isUnreleased && <DeveloperTestingBanner locale={locale} />}
<DetailHero
title={card.title}
subtitle={<a href={localizePath(`/characters/${card.characterId}`, locale)} className="hover:text-primary hover:underline">{card.characterName}</a>}
Expand Down
8 changes: 6 additions & 2 deletions src/components/characters/CharacterDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ interface Props {
locale: AppLocale;
characterId: number;
initialData: ServerFacetedValue<DetailData>;
progressionData: CharacterProgressionData | null;
progressionData: ServerFacetedValue<CharacterProgressionData> | null;
/** Bonds, missions and related entries (build-character-extras.ts). */
extrasData: ServerFacetedValue<CharacterExtrasData>;
servers: GameServer[];
Expand All @@ -52,12 +52,16 @@ interface DetailData {
export default function CharacterDetail({ locale, initialData, progressionData, extrasData, servers }: Props) {
const [server, pickServer] = useContentServer(locale, servers);
const data = useMemo(() => moveReleaseUrls(valueForServer(initialData, server), entityServer(initialData, server)), [initialData, server]);
const progression = useMemo(
() => progressionData?.servers.includes(server) ? valueForServer(progressionData, server) : null,
[progressionData, server],
);
const extras = useMemo(() => moveReleaseUrls(valueForServer(extrasData, server), entityServer(extrasData, server)), [extrasData, server]);
// The hero carries the server switch beside the name, so the scope leaves its own out.
const serverSwitch = <HeadingServerSwitch locale={locale} servers={servers} server={server} onChange={pickServer} entityServers={initialData.servers} />;
return (
<ServerScope locale={locale} servers={servers} server={server} onChange={pickServer} entityServers={initialData.servers} hideSwitch>
<CharacterDetailView locale={locale} data={data} progression={progressionData} extras={extras} serverSwitch={serverSwitch} />
<CharacterDetailView locale={locale} data={data} progression={progression} extras={extras} serverSwitch={serverSwitch} />
</ServerScope>
);
}
Expand Down
3 changes: 1 addition & 2 deletions src/components/events/EventDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { useNow } from "@/lib/schedule/use-now";
import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone";
import { useSpoiler } from "@/lib/settings/spoiler";
import { SPOILER_REFERENCE_NOW } from "@/lib/settings/spoiler-reference";
import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice";
import SpoilerNotice from "@/components/shared/SpoilerNotice";
import { entityServer, valueForServer, type ServerFacetedValue } from "@/lib/servers/facets";
import { useAssetUrl, useContentServer } from "@/lib/servers/use-content-server";

Expand Down Expand Up @@ -119,7 +119,6 @@ function EventDetailView({ locale, server, event, serverSwitch, schedules }: { l

return (
<div className="w-full space-y-8">
{isUnreleased && <DeveloperTestingBanner locale={locale} />}
<DetailHero
title={event.name}
aside={serverSwitch}
Expand Down
3 changes: 1 addition & 2 deletions src/components/gacha/GachaDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { useNow } from "@/lib/schedule/use-now";
import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone";
import { useSpoiler } from "@/lib/settings/spoiler";
import { SPOILER_REFERENCE_NOW } from "@/lib/settings/spoiler-reference";
import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice";
import SpoilerNotice from "@/components/shared/SpoilerNotice";
import { getSupportRarityIconUrl, type SupportCardRarity } from "@/lib/support-cards/assets";

interface Props {
Expand Down Expand Up @@ -92,7 +92,6 @@ function GachaDetailView({ locale, gacha, serverSwitch, schedules }: { locale: A

return (
<div className="w-full space-y-8">
{isUnreleased && <DeveloperTestingBanner locale={locale} />}
<DetailHero
title={gacha.name}
long
Expand Down
3 changes: 1 addition & 2 deletions src/components/music/MusicDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getRoutePathById } from "@/lib/route/registry";
import { getChartPreviewHref } from "@/lib/music/chart-preview";
import { useNow } from "@/lib/schedule/use-now";
import { isSpoiler, useSpoiler } from "@/lib/settings/spoiler";
import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice";
import SpoilerNotice from "@/components/shared/SpoilerNotice";
import ChartSheetDialog from "@/components/music/ChartSheetDialog";
import { difficultyStyles } from "@/components/music/difficulty-styles";
import {
Expand Down Expand Up @@ -96,7 +96,6 @@ function MusicDetailView({ locale, song, liveDetail, extras, serverSwitch }: { l

return (
<div className="w-full space-y-8">
{isUnreleased && <DeveloperTestingBanner locale={locale} />}
<DetailHero
title={title}
subtitle={title !== song.title ? song.title : undefined}
Expand Down
Loading
Loading