All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 8m53s
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
import "server-only";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import { randomUUID } from "node:crypto";
|
|
import sharp from "sharp";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import {
|
|
admin,
|
|
authClient,
|
|
required,
|
|
user,
|
|
rawUser,
|
|
accountState,
|
|
} from "./supabase";
|
|
import { accountProfile } from "./profile";
|
|
import { browserAccount } from "./request-security";
|
|
import { ServiceError } from "./service-error";
|
|
import {
|
|
authActionSchema,
|
|
authDestination,
|
|
passwordUpdateSchema,
|
|
profileUpdateSchema,
|
|
initialPasswordSchema,
|
|
} from "@/shared/account";
|
|
import { createHash } from "node:crypto";
|
|
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
|
|
import { requestOrigin } from "./request-origin";
|
|
import { issueInitialPassword } from "@/shared/initial-password";
|
|
import {
|
|
profileAvatarBucket,
|
|
profileAvatarMaxBytes,
|
|
} from "@/shared/profile-appearance";
|
|
|
|
type Context = {
|
|
jsonBody: (r: NextRequest) => Promise<unknown>;
|
|
limit: (key: string, count: number, seconds: number) => Promise<void>;
|
|
};
|
|
const json = (body: unknown, status = 200) =>
|
|
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
|
|
const avatarRequestLimit = profileAvatarMaxBytes + 64 * 1024;
|
|
|
|
async function avatarFile(request: NextRequest): Promise<File> {
|
|
const contentType = request.headers.get("content-type") ?? "";
|
|
if (!/^multipart\/form-data\s*;.*\bboundary=/i.test(contentType))
|
|
throw new ServiceError("Choose a valid profile photo", 400);
|
|
const contentLength = request.headers.get("content-length");
|
|
if (
|
|
contentLength &&
|
|
/^\d+$/.test(contentLength) &&
|
|
Number(contentLength) > avatarRequestLimit
|
|
)
|
|
throw new ServiceError("Image must be 5 MB or smaller", 413);
|
|
|
|
const reader = request.body?.getReader();
|
|
if (!reader) throw new ServiceError("Choose a profile photo", 400);
|
|
const chunks: Uint8Array[] = [];
|
|
let size = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
size += value.byteLength;
|
|
if (size > avatarRequestLimit) {
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
// The request is already being rejected; cancellation is best effort.
|
|
}
|
|
throw new ServiceError("Image must be 5 MB or smaller", 413);
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
|
|
let form: FormData;
|
|
try {
|
|
const boundedRequest = new Request(request.url, {
|
|
method: "POST",
|
|
headers: { "content-type": contentType },
|
|
body: Buffer.concat(chunks, size),
|
|
});
|
|
form = await boundedRequest.formData();
|
|
} catch {
|
|
throw new ServiceError("Choose a valid profile photo", 400);
|
|
}
|
|
const entries = [...form.entries()];
|
|
if (entries.length !== 1)
|
|
throw new ServiceError("Choose a valid profile photo", 400);
|
|
const entry = entries[0]!;
|
|
if (
|
|
entry[0] !== "photo" ||
|
|
typeof entry[1] === "string" ||
|
|
!(entry[1] instanceof File)
|
|
)
|
|
throw new ServiceError("Choose a valid profile photo", 400);
|
|
const file = entry[1];
|
|
if (!file.size || file.size > profileAvatarMaxBytes)
|
|
throw new ServiceError("Image must be 5 MB or smaller", 413);
|
|
return file;
|
|
}
|
|
|
|
async function normalizedAvatar(file: File) {
|
|
try {
|
|
const image = sharp(Buffer.from(await file.arrayBuffer()), {
|
|
limitInputPixels: 24_000_000,
|
|
failOn: "error",
|
|
animated: false,
|
|
});
|
|
const metadata = await image.metadata();
|
|
if (
|
|
!["jpeg", "png", "webp"].includes(metadata.format ?? "") ||
|
|
(metadata.pages ?? 1) !== 1
|
|
)
|
|
throw new ServiceError("Choose a JPG, PNG or WebP image", 400);
|
|
const output = await image
|
|
.rotate()
|
|
.resize({
|
|
width: 512,
|
|
height: 512,
|
|
fit: "inside",
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality: 85 })
|
|
.toBuffer();
|
|
if (output.byteLength > profileAvatarMaxBytes)
|
|
throw new ServiceError("Image must be 5 MB or smaller", 413);
|
|
return output;
|
|
} catch (error) {
|
|
if (error instanceof ServiceError) throw error;
|
|
throw new ServiceError("Choose a valid JPG, PNG or WebP image", 400);
|
|
}
|
|
}
|
|
|
|
export async function accountApi(
|
|
request: NextRequest,
|
|
path: string[],
|
|
context: Context,
|
|
) {
|
|
if (
|
|
path[0] === "auth" &&
|
|
path[1] === "logout" &&
|
|
path.length === 2 &&
|
|
request.method === "POST"
|
|
) {
|
|
const client = await authClient();
|
|
const { error } = await client.auth.signOut({ scope: "local" });
|
|
if (error) throw new Error("Could not sign out. Please try again.");
|
|
const response = json({ ok: true });
|
|
for (const name of ["mg_guest", "mg_multiplayer"])
|
|
response.cookies.set(name, "", {
|
|
path: "/",
|
|
maxAge: 0,
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
});
|
|
return response;
|
|
}
|
|
if (path[0] === "profile" && path[1] === "avatar" && path.length === 2) {
|
|
if (request.method !== "POST")
|
|
return json({ error: "Method not allowed" }, 405);
|
|
const account = await browserAccount(request, user);
|
|
await context.limit(`profile:avatar:${account.id}`, 10, 3600);
|
|
const file = await avatarFile(request);
|
|
const image = await normalizedAvatar(file);
|
|
const profileBefore = await accountProfile(account);
|
|
const avatarPath = `${account.id}/${randomUUID()}.webp`;
|
|
const storage = admin().storage.from(profileAvatarBucket);
|
|
const upload = await storage.upload(avatarPath, image, {
|
|
cacheControl: "31536000",
|
|
contentType: "image/webp",
|
|
upsert: false,
|
|
});
|
|
if (upload.error)
|
|
throw new ServiceError(
|
|
"Profile photo upload failed. Please try again.",
|
|
503,
|
|
);
|
|
|
|
const profile = await (async () => {
|
|
try {
|
|
return required(
|
|
await admin()
|
|
.from("profiles")
|
|
.update({ avatar_path: avatarPath })
|
|
.eq("id", account.id)
|
|
.select(
|
|
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
|
)
|
|
.single(),
|
|
);
|
|
} catch (error) {
|
|
await storage.remove([avatarPath]).catch(() => {});
|
|
throw error;
|
|
}
|
|
})();
|
|
if (
|
|
profileBefore.avatar_path &&
|
|
profileBefore.avatar_path.startsWith(`${account.id}/`) &&
|
|
profileBefore.avatar_path.split("/").length === 2
|
|
)
|
|
await storage.remove([profileBefore.avatar_path]).catch(() => {});
|
|
return json({ profile });
|
|
}
|
|
if (
|
|
path[0] === "auth" &&
|
|
path[1] === "initial-password" &&
|
|
path.length === 2
|
|
) {
|
|
const account = await rawUser();
|
|
if (!account?.email || !account.email_confirmed_at)
|
|
return json({ error: "Log in to manage your account" }, 401);
|
|
if (request.method === "GET") {
|
|
return json({
|
|
needed: required(
|
|
await admin().rpc("legacy_password_needed", { p_user: account.id }),
|
|
),
|
|
});
|
|
}
|
|
if (request.method !== "POST")
|
|
return json({ error: "Method not allowed" }, 405);
|
|
const body = initialPasswordSchema.parse(await context.jsonBody(request));
|
|
await context.limit(`initial-password:${account.id}`, 3, 600);
|
|
await issueInitialPassword(account.id, body.password, {
|
|
claim: async (id) =>
|
|
required(await admin().rpc("claim_legacy_password", { p_user: id })),
|
|
update: async (id, password) => {
|
|
const { error } = await admin().auth.admin.updateUserById(id, {
|
|
password,
|
|
});
|
|
if (error)
|
|
throw new Error(
|
|
"Password setup could not be confirmed. Use password recovery before signing out.",
|
|
);
|
|
},
|
|
});
|
|
return json({ ok: true });
|
|
}
|
|
if (path[0] === "profile" && path.length === 1) {
|
|
const account = await browserAccount(request, user);
|
|
if (request.method === "GET")
|
|
return json({
|
|
profile: await accountProfile(account),
|
|
email: account.email,
|
|
});
|
|
if (request.method === "PATCH") {
|
|
const body = profileUpdateSchema.parse(await context.jsonBody(request));
|
|
const current = await accountProfile(account);
|
|
if (
|
|
body.avatarPath !== undefined &&
|
|
body.avatarPath !== null &&
|
|
body.avatarPath !== current.avatar_path
|
|
)
|
|
return json(
|
|
{ error: "Upload a profile photo through the avatar form" },
|
|
400,
|
|
);
|
|
if (body.glowColor !== undefined && !current.name_glow)
|
|
return json(
|
|
{ error: "Earn a username glow before choosing its color" },
|
|
403,
|
|
);
|
|
const profile = required(
|
|
await admin()
|
|
.from("profiles")
|
|
.update({
|
|
...(body.displayName !== undefined
|
|
? { display_name: body.displayName }
|
|
: {}),
|
|
...(body.glowColor !== undefined
|
|
? { glow_color: body.glowColor }
|
|
: {}),
|
|
...(body.avatarPath !== undefined
|
|
? { avatar_path: body.avatarPath }
|
|
: {}),
|
|
})
|
|
.eq("id", account.id)
|
|
.select(
|
|
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
|
)
|
|
.single(),
|
|
);
|
|
if (
|
|
body.avatarPath !== undefined &&
|
|
body.avatarPath !== current.avatar_path
|
|
) {
|
|
if (
|
|
current.avatar_path?.startsWith(`${account.id}/`) &&
|
|
current.avatar_path.split("/").length === 2
|
|
)
|
|
await admin()
|
|
.storage.from(profileAvatarBucket)
|
|
.remove([current.avatar_path]);
|
|
}
|
|
return json({ profile });
|
|
}
|
|
return json({ error: "Method not allowed" }, 405);
|
|
}
|
|
if (path[0] !== "auth" || request.method !== "POST" || path.length > 2)
|
|
return json({ error: "Not found" }, 404);
|
|
const client = await authClient();
|
|
if (path.length === 1) {
|
|
const body = authActionSchema.parse(await context.jsonBody(request));
|
|
const identity = createHash("sha256")
|
|
.update(body.email.toLowerCase())
|
|
.digest("hex");
|
|
await context.limit(
|
|
`auth:${body.action}:${identity}`,
|
|
body.action === "login" ? 15 : 5,
|
|
600,
|
|
);
|
|
const origin = requestOrigin(request, process.env.MINEGEO_URL);
|
|
const next = authDestination("next" in body ? body.next : null);
|
|
const callback = new URL("/auth/callback", origin);
|
|
callback.searchParams.set(
|
|
"type",
|
|
body.action === "recover" ? "recovery" : "signup",
|
|
);
|
|
callback.searchParams.set(
|
|
"next",
|
|
body.action === "recover"
|
|
? "/auth/reset-password"
|
|
: next === "/auth/reset-password"
|
|
? "/dashboard"
|
|
: next,
|
|
);
|
|
if (body.action === "login") {
|
|
const { data, error } = await client.auth.signInWithPassword({
|
|
email: body.email,
|
|
password: body.password,
|
|
});
|
|
if (error)
|
|
return json(
|
|
{
|
|
error:
|
|
error.code === "email_not_confirmed"
|
|
? "Confirm your email address before logging in."
|
|
: "Email or password is incorrect. Try again or reset your password.",
|
|
},
|
|
401,
|
|
);
|
|
return json({
|
|
next:
|
|
data.user && (await accountState(data.user.id)) !== "active"
|
|
? "/ban/appeal"
|
|
: next,
|
|
});
|
|
}
|
|
if (body.action === "signup") {
|
|
const { data, error } = await client.auth.signUp({
|
|
email: body.email,
|
|
password: body.password,
|
|
options: {
|
|
data: { display_name: body.displayName },
|
|
emailRedirectTo: callback.toString(),
|
|
},
|
|
});
|
|
if (error) throw new Error(error.message);
|
|
if (data.session && data.user) {
|
|
await accountProfile(data.user);
|
|
return json({ next });
|
|
}
|
|
return json({
|
|
message:
|
|
"Check your email to confirm your account. If you already have an account, log in or reset your password.",
|
|
});
|
|
}
|
|
const { error } = await client.auth.resetPasswordForEmail(body.email, {
|
|
redirectTo: callback.toString(),
|
|
});
|
|
if (
|
|
error &&
|
|
!["user_not_found", "signup_disabled"].includes(error.code ?? "")
|
|
)
|
|
throw new Error(error.message);
|
|
return json({
|
|
message:
|
|
"If an account exists for this email, a password reset link is on its way. Check your inbox.",
|
|
});
|
|
}
|
|
if (path[1] !== "password") return json({ error: "Not found" }, 404);
|
|
const account = await rawUser();
|
|
if (!account?.email)
|
|
return json({ error: "Log in before changing your password" }, 401);
|
|
const body = passwordUpdateSchema.parse(await context.jsonBody(request));
|
|
await context.limit(`password:${account.id}`, 10, 600);
|
|
const { data: claimData, error: claimError } = await client.auth.getClaims();
|
|
const sessionId =
|
|
typeof claimData?.claims.session_id === "string"
|
|
? claimData.claims.session_id
|
|
: "";
|
|
if (claimError || !sessionId)
|
|
return json({ error: "Your session expired. Log in again." }, 401);
|
|
if (body.currentPassword) {
|
|
// Verify through Auth without replacing this browser's session.
|
|
const verifier = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
|
);
|
|
const { error } = await verifier.auth.signInWithPassword({
|
|
email: account.email,
|
|
password: body.currentPassword,
|
|
});
|
|
if (error)
|
|
return json(
|
|
{
|
|
error:
|
|
"Your current password is incorrect. Use an email reset link if you have not set a password yet.",
|
|
},
|
|
403,
|
|
);
|
|
await verifier.auth.signOut({ scope: "local" });
|
|
} else if (
|
|
!verifyRecoveryGrant(
|
|
request.cookies.get(recoveryCookie)?.value,
|
|
account.id,
|
|
sessionId,
|
|
process.env.SUPABASE_SECRET_KEY!,
|
|
)
|
|
) {
|
|
return json(
|
|
{
|
|
error:
|
|
"Enter your current password or open a fresh password reset link from your email.",
|
|
},
|
|
403,
|
|
);
|
|
}
|
|
const { error } = await client.auth.updateUser({
|
|
password: body.password,
|
|
...(body.currentPassword ? { current_password: body.currentPassword } : {}),
|
|
...(body.nonce ? { nonce: body.nonce } : {}),
|
|
});
|
|
if (error)
|
|
return json(
|
|
{
|
|
error: error.message,
|
|
needsReauthentication: error.code === "reauthentication_needed",
|
|
},
|
|
400,
|
|
);
|
|
const { error: signOutError } = await client.auth.signOut({
|
|
scope: "others",
|
|
});
|
|
const response = json({
|
|
ok: true,
|
|
message: signOutError
|
|
? "Password updated. Other sessions could not be signed out; please try signing out other devices again."
|
|
: "Password updated. Other devices have been signed out.",
|
|
});
|
|
response.cookies.set(recoveryCookie, "", {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 0,
|
|
});
|
|
return response;
|
|
}
|