Initial import
This commit is contained in:
782
apps/web/src/main.ts
Normal file
782
apps/web/src/main.ts
Normal file
@@ -0,0 +1,782 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { loadConfig } from "@nproxy/config";
|
||||
import {
|
||||
createPrismaAccountStore,
|
||||
createPrismaAuthStore,
|
||||
createPrismaBillingStore,
|
||||
createPrismaGenerationStore,
|
||||
prisma,
|
||||
} from "@nproxy/db";
|
||||
import { createEmailTransport, createPaymentProviderAdapter } from "@nproxy/providers";
|
||||
import {
|
||||
AuthError,
|
||||
GenerationRequestError,
|
||||
createGenerationRequest,
|
||||
type CreateGenerationRequestInput,
|
||||
} from "@nproxy/domain";
|
||||
|
||||
const config = loadConfig();
|
||||
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
||||
const accountStore = createPrismaAccountStore(prisma);
|
||||
const authStore = createPrismaAuthStore(prisma);
|
||||
const billingStore = createPrismaBillingStore(prisma);
|
||||
const generationStore = createPrismaGenerationStore(prisma);
|
||||
const emailTransport = createEmailTransport(config.email);
|
||||
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
|
||||
const sessionCookieName = "nproxy_session";
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method === "GET" && request.url === "/healthz") {
|
||||
sendJson(response, 200, { ok: true, service: "web" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/register") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readAuthPayload(body);
|
||||
const session = await authStore.registerUser({
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
passwordPepper: config.auth.passwordPepper,
|
||||
});
|
||||
|
||||
sendJson(
|
||||
response,
|
||||
201,
|
||||
{ user: serializeAuthenticatedUser(session.user) },
|
||||
createSessionCookie(session.token, session.expiresAt),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/login") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readAuthPayload(body);
|
||||
const session = await authStore.loginUser({
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
passwordPepper: config.auth.passwordPepper,
|
||||
});
|
||||
|
||||
sendJson(
|
||||
response,
|
||||
200,
|
||||
{ user: serializeAuthenticatedUser(session.user) },
|
||||
createSessionCookie(session.token, session.expiresAt),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/request") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readEmailOnlyPayload(body);
|
||||
const challenge = await authStore.createPasswordResetChallenge({
|
||||
email: payload.email,
|
||||
});
|
||||
|
||||
if (challenge) {
|
||||
const resetUrl = new URL("/reset-password", config.urls.appBaseUrl);
|
||||
resetUrl.searchParams.set("token", challenge.token);
|
||||
|
||||
await emailTransport.send({
|
||||
to: challenge.email,
|
||||
subject: "Reset your nproxy password",
|
||||
text: [
|
||||
"We received a request to reset your password.",
|
||||
`Reset link: ${resetUrl.toString()}`,
|
||||
`This link expires at ${challenge.expiresAt.toISOString()}.`,
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readPasswordResetConfirmPayload(body);
|
||||
|
||||
await authStore.resetPassword({
|
||||
token: payload.token,
|
||||
newPassword: payload.password,
|
||||
passwordPepper: config.auth.passwordPepper,
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/logout") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
|
||||
await authStore.revokeSession(authenticatedSession.token);
|
||||
|
||||
sendJson(response, 200, { ok: true }, clearSessionCookie());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/auth/me") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
sendJson(response, 200, {
|
||||
user: serializeAuthenticatedUser(authenticatedSession.user),
|
||||
session: serializeUserSession(authenticatedSession.session, authenticatedSession.session.id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/auth/sessions") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const sessions = await authStore.listUserSessions(authenticatedSession.user.id);
|
||||
sendJson(response, 200, {
|
||||
sessions: sessions.map((session) =>
|
||||
serializeUserSession(session, authenticatedSession.session.id),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/account") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const overview = await accountStore.getUserAccountOverview(authenticatedSession.user.id);
|
||||
|
||||
if (!overview) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "account_not_found",
|
||||
message: "Account was not found.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, serializeAccountOverview(overview));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/billing/invoices") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const invoices = await billingStore.listUserInvoices(authenticatedSession.user.id);
|
||||
sendJson(response, 200, {
|
||||
invoices: invoices.map(serializeBillingInvoice),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/billing/invoices") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const invoice = await billingStore.createSubscriptionInvoice({
|
||||
userId: authenticatedSession.user.id,
|
||||
paymentProvider: config.payment.provider,
|
||||
paymentProviderAdapter,
|
||||
});
|
||||
sendJson(response, 201, {
|
||||
invoice: serializeBillingInvoice(invoice),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/logout-all") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const revokedCount = await authStore.revokeAllUserSessions({
|
||||
userId: authenticatedSession.user.id,
|
||||
exceptSessionId: authenticatedSession.session.id,
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
revokedCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url) {
|
||||
const invoiceMarkPaidMatch = request.url.match(/^\/api\/admin\/invoices\/([^/]+)\/mark-paid$/);
|
||||
|
||||
if (invoiceMarkPaidMatch) {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
|
||||
if (!authenticatedSession.user.isAdmin) {
|
||||
throw new HttpError(403, "forbidden", "Admin session is required.");
|
||||
}
|
||||
|
||||
const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? "");
|
||||
const invoice = await billingStore.markInvoicePaid({ invoiceId });
|
||||
sendJson(response, 200, {
|
||||
invoice: serializeBillingInvoice(invoice),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && request.url) {
|
||||
const sessionMatch = request.url.match(/^\/api\/auth\/sessions\/([^/]+)$/);
|
||||
|
||||
if (sessionMatch) {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const sessionId = decodeURIComponent(sessionMatch[1] ?? "");
|
||||
|
||||
if (sessionId === authenticatedSession.session.id) {
|
||||
await authStore.revokeSession(authenticatedSession.token);
|
||||
sendJson(response, 200, { ok: true }, clearSessionCookie());
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await authStore.revokeUserSession({
|
||||
userId: authenticatedSession.user.id,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!revoked) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "session_not_found",
|
||||
message: "Session was not found.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/generations") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const body = await readJsonBody(request);
|
||||
const requestInput = mapCreateGenerationRequestInput(body, authenticatedSession.user.id);
|
||||
const result = await createGenerationRequest(generationStore, requestInput);
|
||||
|
||||
sendJson(response, result.reusedExistingRequest ? 200 : 201, {
|
||||
request: serializeGenerationRequest(result.request),
|
||||
reusedExistingRequest: result.reusedExistingRequest,
|
||||
approximateQuotaBucket: result.approximateQuotaBucket,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url) {
|
||||
const match = request.url.match(/^\/api\/generations\/([^/]+)$/);
|
||||
|
||||
if (match) {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const requestId = decodeURIComponent(match[1] ?? "");
|
||||
const generationRequest = await generationStore.getGenerationRequest(requestId);
|
||||
|
||||
if (!generationRequest || generationRequest.userId !== authenticatedSession.user.id) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "not_found",
|
||||
message: "Generation request was not found.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
request: serializeGenerationRequest(generationRequest),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
service: "web",
|
||||
appBaseUrl: config.urls.appBaseUrl.toString(),
|
||||
adminBaseUrl: config.urls.adminBaseUrl.toString(),
|
||||
providerModel: config.provider.nanoBananaDefaultModel,
|
||||
endpoints: {
|
||||
register: "POST /api/auth/register",
|
||||
login: "POST /api/auth/login",
|
||||
passwordResetRequest: "POST /api/auth/password-reset/request",
|
||||
passwordResetConfirm: "POST /api/auth/password-reset/confirm",
|
||||
logout: "POST /api/auth/logout",
|
||||
me: "GET /api/auth/me",
|
||||
sessions: "GET /api/auth/sessions",
|
||||
revokeSession: "DELETE /api/auth/sessions/:id",
|
||||
logoutAll: "POST /api/auth/logout-all",
|
||||
account: "GET /api/account",
|
||||
listInvoices: "GET /api/billing/invoices",
|
||||
createInvoice: "POST /api/billing/invoices",
|
||||
adminMarkInvoicePaid: "POST /api/admin/invoices/:id/mark-paid",
|
||||
createGenerationRequest: "POST /api/generations",
|
||||
getGenerationRequest: "GET /api/generations/:id",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleRequestError(response, error);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`web listening on ${port}`);
|
||||
});
|
||||
|
||||
process.once("SIGTERM", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.once("SIGINT", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
async function readJsonBody(
|
||||
request: IncomingMessage,
|
||||
): Promise<unknown> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
for await (const chunk of request) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
throw new HttpError(400, "invalid_json", "Request body must not be empty.");
|
||||
}
|
||||
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as unknown;
|
||||
} catch {
|
||||
throw new HttpError(400, "invalid_json", "Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function readAuthPayload(body: unknown): { email: string; password: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
email: readRequiredString(payload.email, "email"),
|
||||
password: readRequiredString(payload.password, "password"),
|
||||
};
|
||||
}
|
||||
|
||||
function readEmailOnlyPayload(body: unknown): { email: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
email: readRequiredString(payload.email, "email"),
|
||||
};
|
||||
}
|
||||
|
||||
function readPasswordResetConfirmPayload(
|
||||
body: unknown,
|
||||
): { token: string; password: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
token: readRequiredString(payload.token, "token"),
|
||||
password: readRequiredString(payload.password, "password"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCreateGenerationRequestInput(
|
||||
body: unknown,
|
||||
userId: string,
|
||||
): CreateGenerationRequestInput {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
userId,
|
||||
mode: readGenerationMode(payload.mode),
|
||||
providerModel: readRequiredString(payload.providerModel, "providerModel"),
|
||||
prompt: readRequiredString(payload.prompt, "prompt"),
|
||||
resolutionPreset: readRequiredString(payload.resolutionPreset, "resolutionPreset"),
|
||||
batchSize: readRequiredInteger(payload.batchSize, "batchSize"),
|
||||
...(payload.sourceImageKey !== undefined
|
||||
? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") }
|
||||
: {}),
|
||||
...(payload.imageStrength !== undefined
|
||||
? { imageStrength: readRequiredNumber(payload.imageStrength, "imageStrength") }
|
||||
: {}),
|
||||
...(payload.idempotencyKey !== undefined
|
||||
? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readGenerationMode(value: unknown): CreateGenerationRequestInput["mode"] {
|
||||
if (value === "text_to_image" || value === "image_to_image") {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
400,
|
||||
"invalid_mode",
|
||||
'mode must be "text_to_image" or "image_to_image".',
|
||||
);
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new HttpError(400, "invalid_field", `${field} must be a non-empty string.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readRequiredInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new HttpError(400, "invalid_field", `${field} must be an integer.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readRequiredNumber(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
throw new HttpError(400, "invalid_field", `${field} must be a number.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function serializeAuthenticatedUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeUserSession(
|
||||
session: {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
revokedAt?: Date;
|
||||
lastSeenAt?: Date;
|
||||
createdAt: Date;
|
||||
},
|
||||
currentSessionId: string,
|
||||
) {
|
||||
return {
|
||||
id: session.id,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
isCurrent: session.id === currentSessionId,
|
||||
...(session.revokedAt ? { revokedAt: session.revokedAt.toISOString() } : {}),
|
||||
...(session.lastSeenAt ? { lastSeenAt: session.lastSeenAt.toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAccountOverview(overview: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
subscription: {
|
||||
id: string;
|
||||
status: string;
|
||||
renewsManually: boolean;
|
||||
activatedAt?: Date;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
canceledAt?: Date;
|
||||
plan: {
|
||||
id: string;
|
||||
code: string;
|
||||
displayName: string;
|
||||
monthlyRequestLimit: number;
|
||||
monthlyPriceUsd: number;
|
||||
billingCurrency: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
} | null;
|
||||
quota: {
|
||||
approximateBucket: number;
|
||||
usedSuccessfulRequests: number;
|
||||
monthlyRequestLimit: number;
|
||||
} | null;
|
||||
}) {
|
||||
return {
|
||||
user: serializeAuthenticatedUser(overview.user),
|
||||
subscription: overview.subscription
|
||||
? {
|
||||
id: overview.subscription.id,
|
||||
status: overview.subscription.status,
|
||||
renewsManually: overview.subscription.renewsManually,
|
||||
...(overview.subscription.activatedAt
|
||||
? { activatedAt: overview.subscription.activatedAt.toISOString() }
|
||||
: {}),
|
||||
...(overview.subscription.currentPeriodStart
|
||||
? { currentPeriodStart: overview.subscription.currentPeriodStart.toISOString() }
|
||||
: {}),
|
||||
...(overview.subscription.currentPeriodEnd
|
||||
? { currentPeriodEnd: overview.subscription.currentPeriodEnd.toISOString() }
|
||||
: {}),
|
||||
...(overview.subscription.canceledAt
|
||||
? { canceledAt: overview.subscription.canceledAt.toISOString() }
|
||||
: {}),
|
||||
plan: overview.subscription.plan,
|
||||
}
|
||||
: null,
|
||||
quota: overview.quota,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeBillingInvoice(invoice: {
|
||||
id: string;
|
||||
subscriptionId?: string;
|
||||
provider: string;
|
||||
providerInvoiceId?: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
amountCrypto: number;
|
||||
amountUsd?: number;
|
||||
paymentAddress?: string;
|
||||
expiresAt?: Date;
|
||||
paidAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: invoice.id,
|
||||
provider: invoice.provider,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
amountCrypto: invoice.amountCrypto,
|
||||
createdAt: invoice.createdAt.toISOString(),
|
||||
updatedAt: invoice.updatedAt.toISOString(),
|
||||
...(invoice.subscriptionId ? { subscriptionId: invoice.subscriptionId } : {}),
|
||||
...(invoice.providerInvoiceId ? { providerInvoiceId: invoice.providerInvoiceId } : {}),
|
||||
...(invoice.amountUsd !== undefined ? { amountUsd: invoice.amountUsd } : {}),
|
||||
...(invoice.paymentAddress ? { paymentAddress: invoice.paymentAddress } : {}),
|
||||
...(invoice.expiresAt ? { expiresAt: invoice.expiresAt.toISOString() } : {}),
|
||||
...(invoice.paidAt ? { paidAt: invoice.paidAt.toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGenerationRequest(requestRecord: {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey?: string;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength?: number;
|
||||
idempotencyKey?: string;
|
||||
terminalErrorCode?: string;
|
||||
terminalErrorText?: string;
|
||||
requestedAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: requestRecord.id,
|
||||
userId: requestRecord.userId,
|
||||
mode: requestRecord.mode,
|
||||
status: requestRecord.status,
|
||||
providerModel: requestRecord.providerModel,
|
||||
prompt: requestRecord.prompt,
|
||||
resolutionPreset: requestRecord.resolutionPreset,
|
||||
batchSize: requestRecord.batchSize,
|
||||
requestedAt: requestRecord.requestedAt.toISOString(),
|
||||
createdAt: requestRecord.createdAt.toISOString(),
|
||||
updatedAt: requestRecord.updatedAt.toISOString(),
|
||||
...(requestRecord.sourceImageKey !== undefined
|
||||
? { sourceImageKey: requestRecord.sourceImageKey }
|
||||
: {}),
|
||||
...(requestRecord.imageStrength !== undefined
|
||||
? { imageStrength: requestRecord.imageStrength }
|
||||
: {}),
|
||||
...(requestRecord.idempotencyKey !== undefined
|
||||
? { idempotencyKey: requestRecord.idempotencyKey }
|
||||
: {}),
|
||||
...(requestRecord.terminalErrorCode !== undefined
|
||||
? { terminalErrorCode: requestRecord.terminalErrorCode }
|
||||
: {}),
|
||||
...(requestRecord.terminalErrorText !== undefined
|
||||
? { terminalErrorText: requestRecord.terminalErrorText }
|
||||
: {}),
|
||||
...(requestRecord.startedAt !== undefined
|
||||
? { startedAt: requestRecord.startedAt.toISOString() }
|
||||
: {}),
|
||||
...(requestRecord.completedAt !== undefined
|
||||
? { completedAt: requestRecord.completedAt.toISOString() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireAuthenticatedSession(request: IncomingMessage) {
|
||||
const sessionToken = readSessionToken(request);
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new HttpError(401, "unauthorized", "Missing authenticated session.");
|
||||
}
|
||||
|
||||
const authenticatedSession = await authStore.getUserBySessionToken(sessionToken);
|
||||
|
||||
if (!authenticatedSession) {
|
||||
throw new HttpError(401, "unauthorized", "Authenticated session is invalid.");
|
||||
}
|
||||
|
||||
return {
|
||||
...authenticatedSession,
|
||||
token: sessionToken,
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionToken(request: IncomingMessage): string | null {
|
||||
const cookieHeader = request.headers.cookie;
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawName, ...rawValue] = part.trim().split("=");
|
||||
|
||||
if (rawName === sessionCookieName) {
|
||||
return decodeURIComponent(rawValue.join("="));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSessionCookie(token: string, expiresAt: Date): string {
|
||||
return [
|
||||
`${sessionCookieName}=${encodeURIComponent(token)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Secure",
|
||||
`Expires=${expiresAt.toUTCString()}`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function clearSessionCookie(): string {
|
||||
return [
|
||||
`${sessionCookieName}=`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Secure",
|
||||
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function handleRequestError(
|
||||
response: ServerResponse<IncomingMessage>,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (error instanceof HttpError) {
|
||||
sendJson(response, error.statusCode, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof AuthError) {
|
||||
const statusCode =
|
||||
error.code === "email_already_exists"
|
||||
? 409
|
||||
: error.code === "invalid_credentials"
|
||||
? 401
|
||||
: 400;
|
||||
|
||||
sendJson(response, statusCode, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof GenerationRequestError) {
|
||||
const statusCode =
|
||||
error.code === "missing_active_subscription"
|
||||
? 403
|
||||
: error.code === "quota_exhausted"
|
||||
? 409
|
||||
: error.code === "request_not_found"
|
||||
? 404
|
||||
: error.code === "request_not_completable"
|
||||
? 409
|
||||
: 400;
|
||||
|
||||
sendJson(response, statusCode, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendJson(response, 500, {
|
||||
error: {
|
||||
code: "internal_error",
|
||||
message: "Internal server error.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
response: ServerResponse<IncomingMessage>,
|
||||
statusCode: number,
|
||||
payload: unknown,
|
||||
setCookie?: string,
|
||||
): void {
|
||||
response.writeHead(statusCode, {
|
||||
"content-type": "application/json",
|
||||
...(setCookie ? { "set-cookie": setCookie } : {}),
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user