Files
Excalidraw/excalidraw/excalidraw-app/data/storageAdapters/BackendStorageAdapter.ts
T
2025-07-06 14:28:05 +08:00

181 lines
5.0 KiB
TypeScript

import {
CanvasData,
CanvasMetadata,
dehydrateCanvasData,
hydrateCanvasData,
IStorageAdapter,
} from "../storage";
import { nanoid } from "nanoid";
import { jwtDecode } from "jwt-decode";
import { generateThumbnail } from "../thumbnail";
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthError";
}
}
const API_BASE_URL = "/api/v2/kv";
interface AppJwtPayload {
sub: string;
}
// The backend uses the GitHub user ID as the subject in the JWT.
// We can decode the token to get this ID for frontend purposes.
function getUserIdFromJwt(token: string): number | null {
try {
const decodedToken = jwtDecode<AppJwtPayload>(token);
if (decodedToken && decodedToken.sub) {
const userId = parseInt(decodedToken.sub, 10);
if (!isNaN(userId) && Number.isInteger(userId)) {
return userId;
}
}
return null;
} catch (e) {
console.error("Failed to decode JWT", e);
return null;
}
}
const getAuthHeaders = () => {
const token = localStorage.getItem("token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
};
export class BackendStorageAdapter implements IStorageAdapter {
async listCanvases(): Promise<CanvasMetadata[]> {
const response = await fetch(API_BASE_URL, {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// For list, we can just return an empty array as if the user has no canvases.
// This prevents an error popup when a logged-out user opens the app.
return [];
}
throw new Error(`Failed to list canvases: ${response.statusText}`);
}
const canvases: CanvasMetadata[] = await response.json();
return canvases;
}
async loadCanvas(id: string): Promise<CanvasData | null> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "GET",
headers: getAuthHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to load canvas: ${response.statusText}`);
}
const rawData = await response.json();
return hydrateCanvasData(rawData);
}
async saveCanvas(id: string, data: CanvasData): Promise<void> {
let dataForUpload: CanvasData;
if (data.thumbnail) {
dataForUpload = data;
} else {
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
dataForUpload = {
...data,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
}
const saveData = dehydrateCanvasData(dataForUpload);
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(saveData),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to save canvas: ${response.statusText}`);
}
}
async createCanvas(data: CanvasData): Promise<CanvasMetadata> {
const newId = nanoid();
const token = localStorage.getItem("token");
if (!token) {
throw new Error("Authentication token not found.");
}
const userId = getUserIdFromJwt(token);
if (!userId) {
throw new Error("Could not parse user ID from token.");
}
const thumbnail = await generateThumbnail(
data.elements,
data.appState,
data.files,
);
const dataWithThumbnail: CanvasData = {
...data,
thumbnail: data.elements.length > 0 ? thumbnail : undefined,
};
await this.saveCanvas(newId, dataWithThumbnail);
return {
id: newId,
name: data.appState?.name || "Untitled",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userId,
thumbnail: dataWithThumbnail.thumbnail,
};
}
async deleteCanvas(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AuthError("User is not authenticated");
}
throw new Error(`Failed to delete canvas: ${response.statusText}`);
}
}
async renameCanvas(id: string, newName: string): Promise<void> {
const canvasData = await this.loadCanvas(id);
if (!canvasData) {
throw new Error("Canvas not found, cannot rename.");
}
const updatedData: CanvasData = {
...canvasData,
appState: {
...canvasData.appState,
name: newName,
},
};
await this.saveCanvas(id, updatedData);
}
}