0.2.1 Release (#32)

* feat(security): implement CSRF protection

* chore: clean up CSRF implementation

  - Remove unused generateCsrfToken export from security.ts
  - Remove redundant /csrf-token path check (GET already exempt)
  - Restore defineConfig wrapper in vitest.config.ts for type safety

* add K8S note in README, fix broken e2e

* feat/upload-bar (#30)

* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>

* chore: pre-release v0.2.1-dev

* Update backend/src/security.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix filename/math random UUID generation

---------

Co-authored-by: AdrianAcala <adrianacala017@gmail.com>
Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Zimeng Xiong
2026-01-14 11:25:27 -08:00
committed by GitHub
parent e75b727a5a
commit 0476315322
37 changed files with 2074 additions and 685 deletions
+141 -6
View File
@@ -5,6 +5,91 @@ const DEFAULT_BACKEND_PORT = 8000;
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
type CsrfTokenResponse = {
token: string;
header?: string;
};
type CsrfInfo = {
token: string;
headerName: string;
};
// Cache CSRF tokens per Playwright request context so parallel tests don't race.
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const response = await request.get(`${API_URL}/csrf-token`);
if (!response.ok()) {
const text = await response.text();
throw new Error(
`Failed to fetch CSRF token: ${response.status()} ${text || "(empty response)"}`
);
}
const data = (await response.json()) as CsrfTokenResponse;
if (!data || typeof data.token !== "string" || data.token.trim().length === 0) {
throw new Error("Failed to fetch CSRF token: missing token in response");
}
const headerName =
typeof data.header === "string" && data.header.trim().length > 0
? data.header
: "x-csrf-token";
return { token: data.token, headerName };
};
const getCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const cached = csrfInfoByRequest.get(request);
if (cached) return cached;
const inFlight = csrfFetchByRequest.get(request);
if (inFlight) return inFlight;
const promise = fetchCsrfInfo(request)
.then((info) => {
csrfInfoByRequest.set(request, info);
return info;
})
.finally(() => {
csrfFetchByRequest.delete(request);
});
csrfFetchByRequest.set(request, promise);
return promise;
};
const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const promise = fetchCsrfInfo(request)
.then((info) => {
csrfInfoByRequest.set(request, info);
return info;
})
.finally(() => {
csrfFetchByRequest.delete(request);
});
csrfFetchByRequest.set(request, promise);
return promise;
};
export async function getCsrfHeaders(
request: APIRequestContext
): Promise<Record<string, string>> {
const info = await getCsrfInfo(request);
return { [info.headerName]: info.token };
}
const withCsrfHeaders = async (
request: APIRequestContext,
headers: Record<string, string> = {}
): Promise<Record<string, string>> => ({
...headers,
...(await getCsrfHeaders(request)),
});
export interface DrawingRecord {
id: string;
name: string;
@@ -53,10 +138,26 @@ export async function createDrawing(
overrides: CreateDrawingOptions = {}
): Promise<DrawingRecord> {
const payload = { ...defaultDrawingPayload(), ...overrides };
const response = await request.post(`${API_URL}/drawings`, {
headers: { "Content-Type": "application/json" },
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
let response = await request.post(`${API_URL}/drawings`, {
headers,
data: payload,
});
// Retry once with a fresh token in case it expired or the cache was primed under
// a different clientId (rare, but can happen under parallelism / CI proxies).
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/drawings`, {
headers: retryHeaders,
data: payload,
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
@@ -77,7 +178,17 @@ export async function deleteDrawing(
request: APIRequestContext,
id: string
): Promise<void> {
const response = await request.delete(`${API_URL}/drawings/${id}`);
const headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, {
headers: retryHeaders,
});
}
if (!response.ok()) {
// Ignore not found to keep cleanup idempotent
if (response.status() !== 404) {
@@ -113,10 +224,24 @@ export async function createCollection(
request: APIRequestContext,
name: string
): Promise<CollectionRecord> {
const response = await request.post(`${API_URL}/collections`, {
headers: { "Content-Type": "application/json" },
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
let response = await request.post(`${API_URL}/collections`, {
headers,
data: { name },
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/collections`, {
headers: retryHeaders,
data: { name },
});
}
expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord;
}
@@ -133,7 +258,17 @@ export async function deleteCollection(
request: APIRequestContext,
id: string
): Promise<void> {
const response = await request.delete(`${API_URL}/collections/${id}`);
const headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, {
headers: retryHeaders,
});
}
if (!response.ok()) {
if (response.status() !== 404) {
const text = await response.text();