Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||
import { encodeUploadImage } from "@/infrastructure/imageUploadEncoding";
|
||||
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
||||
|
||||
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
||||
const accessTokenCookie = "usertoken";
|
||||
const refreshTokenCookie = "refreshToken";
|
||||
|
||||
async function request<T>(path: string, body: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} catch (err) {
|
||||
throw createNetworkFailure("auth", err);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw createApiFailure("auth", response, payload);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, { cache: "no-store" });
|
||||
} catch (err) {
|
||||
throw createNetworkFailure("auth", err);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw createApiFailure("auth", response, payload);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value));
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store"
|
||||
});
|
||||
} catch (err) {
|
||||
throw createNetworkFailure("auth", err);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw createApiFailure("auth", response, payload);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const authGateway = {
|
||||
options() {
|
||||
return get<AuthOptions>("/api/auth/options");
|
||||
},
|
||||
|
||||
requestGlobalEmailCode(email: string, turnstileToken: string) {
|
||||
return request<AuthCodeResponse>("/api/auth/global/email-code", { email, turnstileToken });
|
||||
},
|
||||
|
||||
loginGlobalEmailCode(email: string, code: string) {
|
||||
return request<AuthSession>("/api/auth/global/email-login", { email, code });
|
||||
},
|
||||
|
||||
loginGlobalGoogle(idToken: string) {
|
||||
return request<AuthSession>("/api/auth/global/google-login", { idToken });
|
||||
},
|
||||
|
||||
profile() {
|
||||
return apiRequest<{ user: AuthUser }>("/api/account/profile");
|
||||
},
|
||||
|
||||
updateProfile(userPatch: Partial<Pick<AuthUser, "name" | "avatarUrl">>) {
|
||||
return apiRequest<{ user: AuthUser }>("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(userPatch)
|
||||
});
|
||||
},
|
||||
|
||||
devices() {
|
||||
return apiRequest<{ devices: AccountDevice[] }>("/api/account/devices");
|
||||
},
|
||||
|
||||
removeDevices() {
|
||||
return apiRequest<{ removed: number }>("/api/account/devices", {
|
||||
method: "DELETE"
|
||||
});
|
||||
},
|
||||
|
||||
logoutAccount() {
|
||||
return apiRequest<{ removed: number }>("/api/account/logout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
},
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
const uploadFile = await encodeUploadImage(file);
|
||||
const upload = await apiRequest<{
|
||||
uploadUrl: string;
|
||||
publicUrl: string;
|
||||
headers: Record<string, string>;
|
||||
}>("/api/assets/upload-url", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fileName: uploadFile.name,
|
||||
contentType: uploadFile.type || file.type || "application/octet-stream",
|
||||
size: uploadFile.size
|
||||
})
|
||||
});
|
||||
|
||||
if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) {
|
||||
await fetch(upload.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: upload.headers,
|
||||
body: uploadFile
|
||||
});
|
||||
}
|
||||
return upload.publicUrl;
|
||||
}
|
||||
};
|
||||
|
||||
export function readStoredAuthSession(): StoredAuthSession | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.localStorage.getItem(sessionStorageKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const session = JSON.parse(raw) as StoredAuthSession;
|
||||
if (!session.accessToken || isSessionExpired(session)) {
|
||||
clearStoredAuthSession();
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
} catch {
|
||||
clearStoredAuthSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthSession(session: AuthSession): StoredAuthSession {
|
||||
const stored = { ...session, savedAt: Date.now() };
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored));
|
||||
setCookie(accessTokenCookie, session.accessToken, session.expiresIn);
|
||||
if (session.refreshToken && session.refreshExpiresIn) {
|
||||
setCookie(refreshTokenCookie, session.refreshToken, session.refreshExpiresIn);
|
||||
}
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function updateStoredAuthUser(userPatch: Partial<AuthUser>): StoredAuthSession | null {
|
||||
const current = readStoredAuthSession();
|
||||
if (!current) return null;
|
||||
|
||||
const stored = {
|
||||
...current,
|
||||
user: {
|
||||
...current.user,
|
||||
...userPatch
|
||||
}
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored));
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearStoredAuthSession() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(sessionStorageKey);
|
||||
expireCookie(accessTokenCookie);
|
||||
expireCookie(refreshTokenCookie);
|
||||
}
|
||||
|
||||
export function getAuthAccessToken() {
|
||||
return readStoredAuthSession()?.accessToken ?? "";
|
||||
}
|
||||
|
||||
export function authHeaders() {
|
||||
const token = getAuthAccessToken();
|
||||
if (!token) return {};
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
token
|
||||
} satisfies Record<string, string>;
|
||||
}
|
||||
|
||||
function isSessionExpired(session: StoredAuthSession) {
|
||||
const expiresIn = Number(session.expiresIn || 0);
|
||||
return expiresIn > 0 && session.savedAt + expiresIn * 1000 <= Date.now() + 30_000;
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, maxAge: number) {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAge))}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function expireCookie(name: string) {
|
||||
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
||||
}
|
||||
Reference in New Issue
Block a user