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
3 changes: 3 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ jobs:
- run: bun test bff/
working-directory: apps/booskiff-web

- run: bun test test/Upload.test.js
working-directory: apps/booskiff-web

- run: >-
mkdir -p dist && printf 'export function renderPage(...args: any[]): any;\n' > dist/server.d.ts
working-directory: apps/booskiff-web
Expand Down
43 changes: 39 additions & 4 deletions apps/booskiff-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,34 @@ COOKIE_SECRET_BASE64="$(openssl rand -base64 32)" ./scripts/dev.sh release

Mock ログインのパスワードは `password` です(メールアドレスは任意)。

## ファイル画面

`/drive` はルート、`/drive/folders/<id>` はそのフォルダーの直下を表示します。
パンくずから親へ戻れ、深いフォルダーの URL を直接開くこともできます。
リスト表示は名前・サイズ・追加日時、アイコン表示はアイコンと名前だけを表示します。
表示形式を切り替えても、現在地・名前の絞り込み・並び順は維持されます。

見出し右側の「新規」から、一覧の上部に「この場所に追加」を開き、
アップロードやフォルダー作成を行います。ファイルは上部の案内から一覧までの
領域全体にドロップでき、現在表示しているフォルダーへ追加されます。

複数ファイルのアップロードは右下の転送パネルで順番に処理します。
保存先はファイルを追加した時点のフォルダーに固定されます。
送信が 100% になった後も、サーバーが保存成功を返すまでは保存処理中です。
パネルの縮小やアプリ内のフォルダー移動で転送は中断されません。
ページの再読み込み・離脱をまたぐ再開には対応していません。
通信が切れて保存結果を確認できない場合は、保存先を確認してから再送してください。

ダウンロードはブラウザーが管理します。転送パネルは要求済みの記録を表示し、
ダウンロードの完了率や保存完了は表示しません。
画面から削除できるフォルダーは空のものに限ります。

### 対応する core

この画面には、`folders.parent_id` の migration と `root` / `parent_id` /
`require_empty` に対応した Booskiff core が必要です。先に core を更新してから
Web を更新してください。既存フォルダーはルートに残り、ファイルの所属は維持されます。

## 環境変数

`auth-bun` は import 時に環境変数を読み取ります。そのため、`dev.sh` と Compose は `SESSION_COOKIE_NAME`、`OAUTH_COOKIE_NAME`、および必要な認証設定を **import より前** に設定します。個別に `bun index.ts` を起動する場合も、同じく起動前に設定してください。
Expand Down Expand Up @@ -67,10 +95,18 @@ Mock ログインのパスワードは `password` です(メールアドレス
| `/api/billing/status` | 課金ステータスの取得 |
| `/.well-known/jwks.json` | mock / test-JWT モードのみの JWKS 公開。`NODE_ENV=production` では常に 404 |

新しい画面は `/api/files?root=true` または `?folder_id=<id>` で現在地を指定します。
BFF は core の一覧を 200 件ずつ最後まで取得するため、50 件を超えるファイルも表示対象です。
`/api/folders` の応答には `parentId` が含まれ、`?root=true` / `?parent_id=<id>` で
直下のフォルダーを取得できます。フィルター省略時の集約一覧は旧 API 利用者向けに維持します。
フォルダー作成は `{name, parent_id}`、画面からの削除は
`DELETE /api/folders/<id>?require_empty=true` を使用します。

## テスト

```bash
bun test bff/ # bare `bun test` ではない。e2e Playwright spec を拾わないため
bun test test/Upload.test.js # 転送キューとXHRの状態遷移
spago test
```

Expand Down Expand Up @@ -111,12 +147,11 @@ NixOS では `PLAYWRIGHT_CHROMIUM_PATH=$(command -v chromium)` を指定でき

**検出した既存不具合と暫定措置:** [#17](https://github.com/ShuttlePub/shuttlepub-frontends/issues/17) /
[#20](https://github.com/ShuttlePub/shuttlepub-frontends/issues/20) のログイン導線は修正済みです。
ログイン成功後に BFF が `next: /auth/oauth/start?return_to=/login` を返し、UI が
ログイン成功後に BFF が `next: /auth/oauth/start?return_to=/drive` を返し、UI が
トップレベル遷移で OAuth を開始するため、E2E の `/auth/oauth/start` 明示遷移は解除済みです。
[#19](https://github.com/ShuttlePub/shuttlepub-frontends/issues/19)(認証済み `/drive` の
フルロード時の resume DOM 破損)は未修正のため、`return_to=/login` の SPA 経路は
BFF 側で維持し、永続化は引き続き API で検証します。
CI は mock/real を別 matrix job で実行し、両方 green です。
フルロード時の resume DOM 破損)は #21 で修正済みです。永続化は `/drive` の
再読み込み後の UI でも検証します。CI は mock/real を別 matrix job で実行します。

## 構成

Expand Down
96 changes: 96 additions & 0 deletions apps/booskiff-web/bff/api-hierarchy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import "./test-setup.ts";
import { afterEach, expect, test } from "bun:test";
import { handleApiRequest } from "./api.ts";
import { createBooskiffClient } from "./booskiff/real.ts";
import { createTestSessionAdapter } from "./session-test-adapter.ts";
import { TEST_COOKIE_SECRET_BASE64 } from "./test-setup.ts";
import { stubFetch, jsonResponse, jsonBody } from "./test-utils.ts";

const originalFetch = globalThis.fetch;
afterEach(() => Object.assign(globalThis, { fetch: originalFetch }));

const adapter = createTestSessionAdapter({
cookieSecretBase64: TEST_COOKIE_SECRET_BASE64, sessionCookieName: "booskiff_session",
isSecureOrigin: false, hydraPublicUrl: "http://hydra.test", hydraClientId: "test",
hydraClientSecret: "test", refreshSkewSeconds: 60,
});

async function request(path: string, method = "GET", body?: unknown): Promise<Response> {
const cookie = await adapter.sealSessionCookie({ v: 1, accessToken: "owner-access", tokenType: "Bearer",
scope: "", expiresAt: Math.floor(Date.now() / 1000) + 3600 });
const result = await handleApiRequest(new Request(`http://localhost:3000/api/${path}`, {
method, headers: { cookie: cookie.split(";")[0] ?? "", origin: "http://localhost:3000", "content-type": "application/json" },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
}), { adapter, createClient: (token) => createBooskiffClient({ coreApiUrl: "http://core.test" }, token) });
if (!result) throw new Error("missing API response");
return result;
}

test.each([
["files?root=true", "root", "true"],
["folders?root=true", "root", "true"],
["folders?parent_id=parent%2F1", "parent_id", "parent/1"],
])("passes hierarchy filter %s with the session owner", async (path, key, value) => {
const { calls } = stubFetch(() => jsonResponse(200, { items: [] }));
expect((await request(path)).status).toBe(200);
expect(new URL(calls[0].url).searchParams.get(key)).toBe(value);
expect(calls[0].headers.Authorization).toBe("Bearer owner-access");
});

test.each([
"files?root=true&folder_id=f1", "folders?root=true&parent_id=p1",
"files?root=bad", "folders?root=", "folders/f1?require_empty=1",
])("rejects invalid hierarchy query before upstream: %s", async (path) => {
const { calls } = stubFetch(() => { throw new Error("must not call core"); });
expect((await request(path, path.includes("require_empty") ? "DELETE" : "GET")).status).toBe(400);
expect(calls).toHaveLength(0);
});

test.each([null, "parent-1"])("creates a folder with parent_id %s and returns parentId", async (parentId) => {
stubFetch((call) => {
expect(jsonBody(call)).toEqual({ name: "docs", parent_id: parentId });
return jsonResponse(201, { id: "f1", name: "docs", parent_id: parentId, created_at: "2026-10-04T00:00:00Z" });
});
const response = await request("folders", "POST", { name: "docs", parent_id: parentId });
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ id: "f1", name: "docs", parentId, createdAt: "2026-10-04T00:00:00Z" });
});

test.each([null, [], { name: "docs", parent_id: 123 }, { name: "docs", parent_id: "" }].map((body) => ({ body })))(
"rejects malformed create body without upstream mutation: %j", async ({ body }) => {
const { calls } = stubFetch(() => { throw new Error("must not call core"); });
expect((await request("folders", "POST", body)).status).toBe(400);
expect(calls).toHaveLength(0);
},
);

test("safe folder delete forwards require_empty and preserves conflict body", async () => {
const error = { error: { code: "folder_not_empty", message: "folder contains files" } };
const { calls } = stubFetch(() => jsonResponse(409, error));
const response = await request("folders/f1?require_empty=true", "DELETE");
expect(calls[0].url).toBe("http://core.test/v1/folders/f1?require_empty=true");
expect(calls[0].method).toBe("DELETE");
expect(response.status).toBe(409);
expect(await response.json()).toEqual(error);
});

test.each(["folders?parent_id=other-owner", "files?folder_id=other-owner"])(
"passes through upstream ownership errors: %s", async (path) => {
const error = { error: { code: "not_found", message: "folder not found" } };
stubFetch(() => jsonResponse(404, error));
const response = await request(path);
expect(response.status).toBe(404);
expect(await response.json()).toEqual(error);
},
);

test("later file page errors return an error without a partial listing", async () => {
const files = Array.from({ length: 200 }, (_, i) => ({ id: `f${i}`, name: "a", mime_type: "text/plain",
size_bytes: 1, folder_id: null, is_public: false, created_at: "2026-10-04T00:00:00Z" }));
const error = { error: { code: "rate_limited", message: "retry later" } };
stubFetch((call) => !new URL(call.url).searchParams.has("before_id")
? jsonResponse(200, { items: files }) : jsonResponse(429, error));
const response = await request("files?root=true");
expect(response.status).toBe(429);
expect(await response.json()).toEqual(error);
});
8 changes: 4 additions & 4 deletions apps/booskiff-web/bff/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe("GET /api/files", () => {
test("forwards folder_id, Bearer token, maps snake_case → camelCase", async () => {
const { calls } = stubFetch((call) => {
expect(call.method).toBe("GET");
expect(call.url).toBe(`${CORE}/v1/files?folder_id=f1`);
expect(call.url).toBe(`${CORE}/v1/files?folder_id=f1&limit=200`);
expect(call.headers.Authorization).toBe("Bearer core-access-token");
return jsonResponse(200, { items: [wireFile] });
});
Expand All @@ -141,7 +141,7 @@ describe("GET /api/files", () => {

test("no folder_id → no query param; null folder_id maps to null", async () => {
stubFetch((call) => {
expect(call.url).toBe(`${CORE}/v1/files`);
expect(call.url).toBe(`${CORE}/v1/files?limit=200`);
return jsonResponse(200, { items: [{ ...wireFile, folder_id: null }] });
});
const cookie = await cookieFor(makeSession());
Expand Down Expand Up @@ -264,8 +264,8 @@ describe("file download / delete", () => {
});

describe("folders CRUD", () => {
const wireFolder = { id: "f1", name: "docs", created_at: "2026-01-01T00:00:00Z" };
const camelFolder = { id: "f1", name: "docs", createdAt: "2026-01-01T00:00:00Z" };
const wireFolder = { id: "f1", name: "docs", parent_id: null, created_at: "2026-01-01T00:00:00Z" };
const camelFolder = { id: "f1", name: "docs", parentId: null, createdAt: "2026-01-01T00:00:00Z" };

test("GET /api/folders → {items:[camelCase]}", async () => {
stubFetch((call) => {
Expand Down
48 changes: 39 additions & 9 deletions apps/booskiff-web/bff/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ async function dispatch(req: Request, pathname: string, client: BooskiffClient,
if (pathname === "/api/files") {
if (method === "GET") {
const folderId = url.searchParams.get("folder_id") || undefined;
return jsonResponse({ items: await client.listFiles(folderId) }, setCookie);
const root = booleanQuery(url, "root");
if (root === null || (root && folderId !== undefined)) {
return jsonError(400, "bad_request", "root must be a boolean and cannot be true with folder_id", setCookie);
}
return jsonResponse({ items: await client.listFiles(folderId, root) }, setCookie);
}
if (method === "POST") return upload(req, url, client, setCookie);
}
Expand All @@ -95,11 +99,18 @@ async function dispatch(req: Request, pathname: string, client: BooskiffClient,
}

if (pathname === "/api/folders") {
if (method === "GET") return jsonResponse({ items: await client.listFolders() }, setCookie);
if (method === "GET") {
const parentId = url.searchParams.get("parent_id") || undefined;
const root = booleanQuery(url, "root");
if (root === null || (root && parentId !== undefined)) {
return jsonError(400, "bad_request", "root must be a boolean and cannot be true with parent_id", setCookie);
}
return jsonResponse({ items: await client.listFolders(parentId, root) }, setCookie);
}
if (method === "POST") {
const name = await readName(req);
if (name === null) return jsonError(400, "bad_request", `JSON body with non-empty "name" is required`, setCookie);
return jsonResponse(await client.createFolder(name), setCookie, 201);
const body = await readFolderBody(req);
if (body === null) return jsonError(400, "bad_request", `JSON body with non-empty "name" and optional "parent_id" is required`, setCookie);
return jsonResponse(await client.createFolder(body.name, body.parentId), setCookie, 201);
}
}

Expand All @@ -113,7 +124,9 @@ async function dispatch(req: Request, pathname: string, client: BooskiffClient,
return jsonResponse(await client.renameFolder(id, name), setCookie);
}
if (method === "DELETE") {
await client.deleteFolder(id);
const requireEmpty = booleanQuery(url, "require_empty");
if (requireEmpty === null) return jsonError(400, "bad_request", "require_empty must be a boolean", setCookie);
await client.deleteFolder(id, requireEmpty);
return emptyResponse(204, setCookie);
}
}
Expand Down Expand Up @@ -146,11 +159,28 @@ async function upload(req: Request, url: URL, client: BooskiffClient, setCookie:
}

async function readName(req: Request): Promise<string | null> {
let body: { name?: unknown };
const body = await readFolderBody(req);
return body?.name ?? null;
}

function booleanQuery(url: URL, key: string): boolean | undefined | null {
const value = url.searchParams.get(key);
if (value === null) return undefined;
if (value === "true") return true;
if (value === "false") return false;
return null;
}

async function readFolderBody(req: Request): Promise<{ name: string; parentId?: string | null } | null> {
let body: unknown;
try {
body = await req.json() as { name?: unknown };
body = await req.json();
} catch {
return null;
}
return typeof body.name === "string" && body.name.length > 0 ? body.name : null;
if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
const { name, parent_id: parentId } = body as { name?: unknown; parent_id?: unknown };
if (typeof name !== "string" || name.length === 0) return null;
if (parentId !== undefined && parentId !== null && (typeof parentId !== "string" || parentId.length === 0)) return null;
return { name, ...(parentId !== undefined ? { parentId: parentId as string | null } : {}) };
}
9 changes: 5 additions & 4 deletions apps/booskiff-web/bff/booskiff/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type FileItem = {
export type Folder = {
readonly id: string;
readonly name: string;
readonly parentId: string | null;
readonly createdAt: string;
};

Expand Down Expand Up @@ -53,14 +54,14 @@ export class BooskiffApiError extends Error {

export interface BooskiffClient {
getFile(id: string): Promise<FileItem>;
listFiles(folderId?: string): Promise<readonly FileItem[]>;
listFiles(folderId?: string, root?: boolean): Promise<readonly FileItem[]>;
uploadFile(input: UploadInput): Promise<FileItem>;
deleteFile(id: string): Promise<void>;
getDownloadUrl(id: string): Promise<string>;
listFolders(): Promise<readonly Folder[]>;
listFolders(parentId?: string, root?: boolean): Promise<readonly Folder[]>;
getFolder(id: string): Promise<Folder>;
createFolder(name: string): Promise<Folder>;
createFolder(name: string, parentId?: string | null): Promise<Folder>;
renameFolder(id: string, name: string): Promise<Folder>;
deleteFolder(id: string): Promise<void>;
deleteFolder(id: string, requireEmpty?: boolean): Promise<void>;
billingStatus(): Promise<BillingStatus>;
}
30 changes: 30 additions & 0 deletions apps/booskiff-web/bff/booskiff/mock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ describe("upload quota accounting", () => {
});

describe("folders", () => {
test("parent hierarchy filters immediate children and permits names in separate folders", async () => {
const client = createMockBooskiffClient();
const parent = await client.createFolder("docs");
const child = await client.createFolder("docs", parent.id);
const nested = await client.createFolder("nested", child.id);
expect(child.parentId).toBe(parent.id);
expect(await client.listFolders(undefined, true)).toEqual([parent]);
expect(await client.listFolders(parent.id)).toEqual([child]);
expect(await client.listFolders()).toEqual([parent, child, nested]);
expect((await errorOf(client.createFolder("docs", parent.id))).status).toBe(409);
expect((await errorOf(client.createFolder("missing", "unknown"))).status).toBe(404);
expect((await errorOf(client.deleteFolder(parent.id))).status).toBe(409);
await client.deleteFolder(nested.id, true);
});

test("root file filter and safe delete preserve files until emptied", async () => {
const client = createMockBooskiffClient();
const folder = await client.createFolder("docs");
const input = { name: "a.txt", mime: null, contentType: "text/plain", contentLength: "1" };
const rootFile = await client.uploadFile({ ...input, folderId: null, body: streamOfSize(1) });
const childFile = await client.uploadFile({ ...input, folderId: folder.id, body: streamOfSize(1) });
expect(await client.listFiles(undefined, true)).toEqual([rootFile]);
expect(await client.listFiles(folder.id)).toEqual([childFile]);
expect((await errorOf(client.deleteFolder(folder.id, true))).status).toBe(409);
expect(await client.getFolder(folder.id)).toEqual(folder);
expect(await client.getFile(childFile.id)).toEqual(childFile);
await client.deleteFile(childFile.id);
await client.deleteFolder(folder.id, true);
});

test("duplicate folder name → 409 folder_already_exists shaped error", async () => {
const client = createMockBooskiffClient();
await client.createFolder("docs");
Expand Down
Loading
Loading