381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import config from "@/../config.toml";
|
|
import { jwt } from '@elysiajs/jwt';
|
|
import { AuditLog } from "@/models/audit";
|
|
import { USER_ROLES, User } from "@/models/user";
|
|
import { createAuditLog } from "@/lib/audit";
|
|
import { ulid } from "ulid";
|
|
|
|
const hardcodedOwners = new Set<string>(
|
|
Array.isArray(config.auth.hardcoded_owner)
|
|
? (config.auth.hardcoded_owner as Array<string | number>).map((id: string | number) => String(id))
|
|
: [],
|
|
);
|
|
|
|
export default new Elysia({ prefix: "/auth" })
|
|
.use(
|
|
jwt({
|
|
name: 'jwt',
|
|
secret: config.auth.jwt_secret,
|
|
})
|
|
)
|
|
.get("/me", async ({ jwt, cookie: { mizuki }, status }) => {
|
|
const rawToken = mizuki.value;
|
|
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const payload = await jwt.verify(rawToken);
|
|
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const user = await User.findOne({ userId: payload.id });
|
|
if (!user) {
|
|
return status(404, "User not found");
|
|
}
|
|
|
|
return {
|
|
id: user.userId,
|
|
discordId: user.discordId,
|
|
username: user.username,
|
|
avatar: user.avatar,
|
|
role: user.role,
|
|
};
|
|
})
|
|
|
|
.get("/discord/login", ({ redirect }) => redirect(`https://discord.com/oauth2/authorize?client_id=${config.auth.discord_client_id}&response_type=code&redirect_uri=${encodeURIComponent(config.auth.discord_redirect_uri)}&scope=guilds+identify`))
|
|
|
|
.get("/roles", () => USER_ROLES)
|
|
|
|
.post("/logout", ({ cookie: { mizuki } }) => {
|
|
mizuki.set({
|
|
value: "",
|
|
httpOnly: true,
|
|
maxAge: 0,
|
|
path: "/",
|
|
});
|
|
|
|
return { ok: true };
|
|
})
|
|
|
|
.get("/discord/callback", async ({ jwt, query, status, cookie: { mizuki }, redirect }) => {
|
|
const code = query.code;
|
|
if (!code) {
|
|
return status(400, "Missing code");
|
|
}
|
|
|
|
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: new URLSearchParams({
|
|
client_id: config.auth.discord_client_id,
|
|
client_secret: config.auth.discord_client_secret,
|
|
code,
|
|
grant_type: "authorization_code",
|
|
redirect_uri: config.auth.discord_redirect_uri,
|
|
}),
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
console.error("Failed to exchange code for token", await tokenResponse.text());
|
|
return status(500, "Failed to exchange code for token");
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json();
|
|
const accessToken = tokenData.access_token;
|
|
|
|
const userResponse = await fetch("https://discord.com/api/users/@me", {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!userResponse.ok) {
|
|
console.error("Failed to fetch user info", await userResponse.text());
|
|
return status(500, "Failed to fetch user info");
|
|
}
|
|
|
|
const userData = await userResponse.json();
|
|
const isHardcodedOwner = hardcodedOwners.has(String(userData.id));
|
|
const updateSet: { username: string; avatar: string; role?: "admin" } = {
|
|
username: userData.username,
|
|
avatar: userData.avatar,
|
|
};
|
|
|
|
if (isHardcodedOwner) {
|
|
updateSet.role = "admin";
|
|
}
|
|
|
|
const user = await User.findOneAndUpdate(
|
|
{ discordId: userData.id },
|
|
{
|
|
$set: updateSet,
|
|
$setOnInsert: {
|
|
userId: ulid(),
|
|
discordId: userData.id,
|
|
},
|
|
},
|
|
{ upsert: true, new: true },
|
|
);
|
|
|
|
const ensuredUser = user && !user.userId
|
|
? await User.findOneAndUpdate(
|
|
{ discordId: userData.id },
|
|
{ $set: { userId: ulid() } },
|
|
{ new: true },
|
|
)
|
|
: user;
|
|
|
|
const token = await jwt.sign({
|
|
id: ensuredUser?.userId,
|
|
discordId: userData.id,
|
|
username: userData.username,
|
|
avatar: userData.avatar,
|
|
role: ensuredUser?.role ?? "reader",
|
|
});
|
|
|
|
mizuki.set({
|
|
value: token,
|
|
httpOnly: true,
|
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
path: "/",
|
|
});
|
|
|
|
return redirect("/");
|
|
}, {
|
|
query: t.Object({
|
|
code: t.String(),
|
|
})
|
|
})
|
|
|
|
.post("/role", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
|
const rawToken = mizuki.value;
|
|
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const payload = await jwt.verify(rawToken);
|
|
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const requester = await User.findOne({ userId: payload.id });
|
|
if (!requester) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
if (requester.role !== "admin") {
|
|
return status(403, "Forbidden");
|
|
}
|
|
|
|
const target = await User.findOne({ userId: body.userId });
|
|
if (!target) {
|
|
return status(404, "User not found");
|
|
}
|
|
|
|
if (hardcodedOwners.has(target.discordId) && body.role !== "admin") {
|
|
return status(400, "Hardcoded owner role must remain admin");
|
|
}
|
|
|
|
const updated = await User.findOneAndUpdate(
|
|
{ userId: body.userId },
|
|
{ $set: { role: body.role } },
|
|
{ new: true },
|
|
);
|
|
|
|
if (!updated) {
|
|
return status(404, "User not found");
|
|
}
|
|
|
|
if (requester.userId === updated.userId) {
|
|
const nextToken = await jwt.sign({
|
|
id: updated.userId,
|
|
discordId: updated.discordId,
|
|
username: updated.username,
|
|
avatar: updated.avatar,
|
|
role: updated.role,
|
|
});
|
|
|
|
mizuki.set({
|
|
value: nextToken,
|
|
httpOnly: true,
|
|
maxAge: 60 * 60 * 24 * 7,
|
|
path: "/",
|
|
});
|
|
}
|
|
|
|
await createAuditLog({
|
|
actor: {
|
|
userId: requester.userId,
|
|
discordId: requester.discordId,
|
|
username: requester.username,
|
|
role: requester.role,
|
|
},
|
|
action: "auth.role.update",
|
|
targetType: "user",
|
|
targetId: updated.userId,
|
|
summary: `${requester.username} changed role of ${updated.username} to ${updated.role}`,
|
|
detail: {
|
|
role: updated.role,
|
|
username: updated.username,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: updated.userId,
|
|
discordId: updated.discordId,
|
|
username: updated.username,
|
|
avatar: updated.avatar,
|
|
role: updated.role,
|
|
};
|
|
}, {
|
|
body: t.Object({
|
|
userId: t.String(),
|
|
role: t.Union([
|
|
t.Literal("admin"),
|
|
t.Literal("writer"),
|
|
t.Literal("reader"),
|
|
]),
|
|
}),
|
|
})
|
|
|
|
.get("/users", async ({ jwt, cookie: { mizuki }, status }) => {
|
|
const rawToken = mizuki.value;
|
|
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const payload = await jwt.verify(rawToken);
|
|
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const requester = await User.findOne({ userId: payload.id });
|
|
if (!requester) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
if (requester.role !== "admin") {
|
|
return status(403, "Forbidden");
|
|
}
|
|
|
|
const users = await User.find().sort({ createdAt: -1 });
|
|
return users.map((user) => ({
|
|
id: user.userId,
|
|
discordId: user.discordId,
|
|
username: user.username,
|
|
avatar: user.avatar,
|
|
role: user.role,
|
|
createdAt: user.createdAt,
|
|
}));
|
|
})
|
|
|
|
.post("/user", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
|
const rawToken = mizuki.value;
|
|
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const payload = await jwt.verify(rawToken);
|
|
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const requester = await User.findOne({ userId: payload.id });
|
|
if (!requester) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
if (requester.role !== "admin") {
|
|
return status(403, "Forbidden");
|
|
}
|
|
|
|
const role = hardcodedOwners.has(body.discordId) ? "admin" : body.role;
|
|
|
|
try {
|
|
const created = await User.create({
|
|
userId: ulid(),
|
|
discordId: body.discordId,
|
|
username: body.username,
|
|
avatar: body.avatar,
|
|
role,
|
|
});
|
|
|
|
await createAuditLog({
|
|
actor: {
|
|
userId: requester.userId,
|
|
discordId: requester.discordId,
|
|
username: requester.username,
|
|
role: requester.role,
|
|
},
|
|
action: "auth.user.create",
|
|
targetType: "user",
|
|
targetId: created.userId,
|
|
summary: `${requester.username} created user ${created.username}`,
|
|
detail: {
|
|
username: created.username,
|
|
discordId: created.discordId,
|
|
role: created.role,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: created.userId,
|
|
discordId: created.discordId,
|
|
username: created.username,
|
|
avatar: created.avatar,
|
|
role: created.role,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to create user";
|
|
return status(400, message);
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
discordId: t.String({ minLength: 1 }),
|
|
username: t.String({ minLength: 1 }),
|
|
avatar: t.Optional(t.String()),
|
|
role: t.Union([
|
|
t.Literal("admin"),
|
|
t.Literal("writer"),
|
|
t.Literal("reader"),
|
|
]),
|
|
}),
|
|
})
|
|
|
|
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
|
|
const rawToken = mizuki.value;
|
|
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const payload = await jwt.verify(rawToken);
|
|
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
const requester = await User.findOne({ userId: payload.id });
|
|
if (!requester) {
|
|
return status(401, "Unauthorized");
|
|
}
|
|
|
|
if (requester.role !== "admin") {
|
|
return status(403, "Forbidden");
|
|
}
|
|
|
|
const page = query.page;
|
|
const size = query.size;
|
|
const logs = await AuditLog.find()
|
|
.sort({ createdAt: -1 })
|
|
.skip((page - 1) * size)
|
|
.limit(size);
|
|
return logs;
|
|
}, {
|
|
query: t.Object({
|
|
page: t.Number({ default: 1, minimum: 1 }),
|
|
size: t.Number({ default: 30, minimum: 1, maximum: 200 }),
|
|
}),
|
|
})
|