pv~%+EhO6u5WJDgWl(HJXdb0`TTO1_vu|*R}xeoE~GpA{EDC|;|oljTUJWj
zV_OPVmuKvbi2o)5HRNSdkTqw91&;Y|6_o0nzL?sY&tSZ+jwaf@GC8{9#P#ZMV<`i7
z(dd4!E0RvvWqHPlnVQ>n!mk8HHauHVN(*dVpLVfXSr>Kh{{Q~YZ~7vC68Y)KLgdGK
zzux<~-i=3*U6C%eyvp9^n<`$E4P@{$mKKDf~JTGuajje!|IH~~(8
z6W|0m0Z!nDoj|6)3(^oB4|D}IrYo3YNrhZN$go9fLY5)fprRt`qJb4=OER);OSBA~
zDxzX5kU`yGDkapEOlHfvItBMP-@E)tIN$ZhPiL;*Qw|0cqMNpcOr{cM5YNCR@VLG$0
zjBQg!A{fY2l|W1-3X?TaHi<>Ch!L}>Zdr1@rLSz|+PIUrEBvppm*p!%VVTmq84)IFdgD`>zLqjp3>%U-L1HIc(>2>xnQ0@YL(V5t#MD#}!Ui^MQ<6+u!9*c2CKJoV
z&_JCKGd8;t1x@m;$Kia(+B2CGEoEXL2@*zWmSiEKVuk8J0IS57DTdqS6=W!eDHDp2
zVIWn7L{JK`Y)RFyY-8KNW2xmuPr3GGIDg=4uVjw5lvO55V2qGtO0sDf5|a!W76byQ
zfB;zG`#OoXV&Hkn?TPBfJ#JmtT1GS{?}
zZ5cx!!A+pDViK4Yld2Y@wumfMWr)haN|QB0WlciRbD(BhszqV>h*UQx^^svu*?P=V
z{)^wrT-{Q(Y@{Gr1JH_rA=ejCETUV6Xi-VBOz5+UMG?y~h93p!_jFy;L{p_$w?)aC8l9BAJ}-UIQ~txRWqMl5gi3~ONf>;WTHe{V
z31Q$gqNKt0z!YTbB2ftpOHsk03|M@cW&>tOs)kh!jn2(`%Bin<%D?worn{vK+olFv
zonkg~uE{d&A+Q=??g0iY6!05K_gdF<1{v2Rj0_5VAzd3Wpi7D|8J(R3&9y&x-cvsJ
zDuzFlmh`q~Y>T>%B}IbW2OJ4Y%rsO}p*A>+49gfT$GmM}wc}+tuT=g6hxQG1P4^bR
zf>dvHdFvlC%ZaecG;eb;EO~6w+qYNSy{A)FHmOX-#_}V8t
z!|c33hZ|joJj)5)|kx@{BaR4B_B3LRU4a3AkXmB
zy`JHHpT++DG3t=!4`var9=c?Lod}>*$ixTN4amU<*VE}0&mA(tZ+nKNuj00SLk$}y
zwGFBp3Pz@hB-zp>0@p~`lt4CAQtw!3cd|vVsLr#_ECkZRDvl*
z;Ps|VC0#M%y0?WcJv;{I)b||k{{Q|^$k%(O=g~;1`^Q7Gy}uTEsrNs_Pxf?2#v{KG
z3Po-T-`Jav{A}-oJ*jZ6=jHIXA}{s6(EV)ak?s%wu>XgL_m30c1ULasfD_;ZH~~)J
ztqH{2cYB1R#)bNuDo2e0UEfqWYP{!UQ{||!o$Hz^M~&n3HC2upvpLaJIcj|7cvI!5
zv6yR{Do2gG9BZl^HO6vHQ{|}fl&hO6M~$6a)l@lZoFvjzIciL#x2bZ}_(xAu<*2ca
z?lb{U7}h7s~A9Tu+$dX)}~O>{Mt4v9l_dX)}~IdpiH4vQ}Yyh?|~6572=
Whs6#2&E^h?5wzXpPX-T*2mBX*^k8BD
delta 1736
zcmb7FTZ|jk8J=O|c0`u4S*bsg-WF_INy=u>uLckMHCAjw`g8@r*rV&&4|z
zkH?De8Wb)q6|5$uK%qjs(TAe4yum{s`T__lwW`!20eJ!h0*OkMdf}mtLQ>_iM>;zC
z|D*5n|3~MX!^3+He-A$6JF^A=z{%CTJx&~5K)h?fkEc(bg8m6OHk<(DgkIbDY~$*N
z^Z3knj@&0Shtq3sY<%c`?E7m@uh$Dc<+Yca7wqw=zb+;QBMSX}O*DpAf;uDh=Ok2$_F#qT(Vtm1w#{6n9@2I3RY{aV6|N?-z3tMzCern
zq9Bj8{gI}8%l1Fi;wKN?ji6T~w7UO)>ac>=SEk&zIFY*&6l9$na_Su=T{XD7tlfg_
ztT3X|r*+}Bx0|-_&}qD4-r2VwaX)(e%4r5X2|~vJbbNd!_$p){2kNW%8vy!d6@LO9
zk(@M(lY1EV_Q`4yPN-SDT3Y9inAgVoD>rY!{%~AzaW3b@?R+5M!&QFpx
zwu$OpBG2hCHgA=ff{{|@Oe?oi&0*OLNoL0BNOe>j!ANHaUc30Ue;s_Dc`@?Ava#*~
zJo&S5D753>f&F3EuFLM;-(O&L#X?6T3e~5v296gCZMfLiGi1MFQuAtYs$`;i4CzKB
ztEfqd(PY5ataiFot*JvruQn$Gi%QA|G|a28nTfTq(FlAt@#4?_4nA@2PtQ4)dFQJ1
z%RU(1@db9a;gHMcayT-yC34zCq&42YaeqsQPmppSPmP;sZBM8xlh~}DJ}Aeniah75
z>|SKXs_KE9F0s_Op6!}Bxt*y9h1y=nMCSQ8mnaSlvVn{v5pek{_xIx9(}_3r#*#Ye
z0Rn}yek8Po_;*p*wY6?n9-tQ}LbSO^okFBp-b65zstqWjOrU#whcXYyhS4lnl?=^V
zt(2*y^n*$x5zFYkcylnw5XBj6Hit>ML9Nt>FjGKNVzI6xNF62mE%505=ZZt{mzTb9
z{CbHyJixt|z5Y-D+1@(06>!0;((|v
z7ky#h`S8v*>PKDM>kdbb6Na2155(0aWkhbDHgrRJ(6p~u;guyq63$>k;3VLj1_WnK~wT^)tpH@(I*8ao5XVx+vFGuGY*;sgi&=e8Z!&I-bjxOYmtGO
z!`nS9R+J-{&O`~WN3z{UJEi7uSgOO|PyhYZ=_2^d+s|KpZ<)Qv1Dws?>pSN|&g}*q
z;eZQu*+0D8Uch*N5{=e`G!vK8MlD~ctL-E?qUK1FF|bD2!ZoRv7PzcL2^3!)W#=Lt
z>sn$RDPajrld2h3;l`8hl&K+jLh2|@0lb#~%f}hehI0?!w@iT^z?blb!n<1tydChn
z{HtD6Xq^{0gEu7m<;&zE!lg(Rk=on@ugVysP9nHv>ap~IOytU@qH&2yP9V~S&UjEk
zs8Y1j&}U?}Gn7{Q&2}=IpLV)QnMuLmBM-HbpMZ~2|M~Qi`{u(2T?e4op+7_KK-ZxU
kp_ie5K%Yauf!@0PCEZ$&1HlnwU$2SITbRnRll&L-Uk_Cx9{>OV
diff --git a/packages/drizzle-pkg/lib/schema/auth.ts b/packages/drizzle-pkg/lib/schema/auth.ts
index 4eb2bb2..fdd9995 100644
--- a/packages/drizzle-pkg/lib/schema/auth.ts
+++ b/packages/drizzle-pkg/lib/schema/auth.ts
@@ -1,4 +1,4 @@
-import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
+import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer().primaryKey(),
@@ -15,3 +15,12 @@ export const users = sqliteTable("users", {
.$onUpdate(() => new Date())
.notNull(),
});
+
+export const captchaCodes = sqliteTable("captcha_codes", {
+ id: integer().primaryKey(),
+ token: text().notNull().unique(),
+ code: text().notNull(),
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
+ used: integer({ mode: "boolean" }).notNull().default(false),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
+});
diff --git a/packages/drizzle-pkg/migrations/0001_naive_gabe_jones.sql b/packages/drizzle-pkg/migrations/0001_naive_gabe_jones.sql
new file mode 100644
index 0000000..fa2433c
--- /dev/null
+++ b/packages/drizzle-pkg/migrations/0001_naive_gabe_jones.sql
@@ -0,0 +1,19 @@
+CREATE TABLE `captcha_codes` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `token` text NOT NULL,
+ `code` text NOT NULL,
+ `expires_at` integer NOT NULL,
+ `used` integer DEFAULT false NOT NULL,
+ `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `captcha_codes_token_unique` ON `captcha_codes` (`token`);--> statement-breakpoint
+DROP INDEX `users_public_slug_unique`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `public_slug`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `bio_markdown`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `bio_visibility`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `social_links_json`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `avatar_visibility`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `discover_visible`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `discover_location`;--> statement-breakpoint
+ALTER TABLE `users` DROP COLUMN `discover_show_location`;
\ No newline at end of file
diff --git a/packages/drizzle-pkg/migrations/meta/0001_snapshot.json b/packages/drizzle-pkg/migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..40ec3e2
--- /dev/null
+++ b/packages/drizzle-pkg/migrations/meta/0001_snapshot.json
@@ -0,0 +1,172 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "33ed99cd-53f2-4200-ae9f-0283f282445f",
+ "prevId": "b0a44e8d-8950-4409-8ca7-97ef171c2ec8",
+ "tables": {
+ "captcha_codes": {
+ "name": "captcha_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used": {
+ "name": "used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
+ }
+ },
+ "indexes": {
+ "captcha_codes_token_unique": {
+ "name": "captcha_codes_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "nickname": {
+ "name": "nickname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "avatar": {
+ "name": "avatar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json
index 6719e86..569a690 100644
--- a/packages/drizzle-pkg/migrations/meta/_journal.json
+++ b/packages/drizzle-pkg/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1778727736262,
"tag": "0000_huge_sage",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1778993818174,
+ "tag": "0001_naive_gabe_jones",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/api/auth/captcha.post.ts b/server/api/auth/captcha.post.ts
new file mode 100644
index 0000000..8158ba9
--- /dev/null
+++ b/server/api/auth/captcha.post.ts
@@ -0,0 +1,6 @@
+import { createCaptcha } from "#server/service/auth";
+
+export default defineWrappedResponseHandler(async () => {
+ const result = await createCaptcha();
+ return R.success(result);
+});
diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts
new file mode 100644
index 0000000..3d380e7
--- /dev/null
+++ b/server/api/auth/login.post.ts
@@ -0,0 +1,36 @@
+import { loginUser } from "#server/service/auth";
+import { z } from "zod";
+
+const loginSchema = z.object({
+ username: z.string().min(1),
+ password: z.string().min(1),
+ captchaToken: z.string().min(1),
+ captchaCode: z.string().min(1),
+});
+
+export default defineWrappedResponseHandler(async (event) => {
+ const body = await readBody(event);
+ const parsed = loginSchema.safeParse(body);
+
+ if (!parsed.success) {
+ return R.error("参数校验失败", parsed.error.issues);
+ }
+
+ const { username, password, captchaToken, captchaCode } = parsed.data;
+ const result = await loginUser(username, password, captchaToken, captchaCode);
+
+ if (!result.success) {
+ return R.error(result.message!, null);
+ }
+
+ // Set token as httpOnly cookie
+ setCookie(event, "token", result.token!, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 7,
+ path: "/",
+ });
+
+ return R.success({ user: result.user });
+});
diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts
new file mode 100644
index 0000000..2d3137e
--- /dev/null
+++ b/server/api/auth/logout.post.ts
@@ -0,0 +1,4 @@
+export default defineWrappedResponseHandler(async (event) => {
+ deleteCookie(event, "token", { path: "/" });
+ return R.success(null);
+});
diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts
new file mode 100644
index 0000000..aaedfcd
--- /dev/null
+++ b/server/api/auth/me.get.ts
@@ -0,0 +1,16 @@
+import { getUserFromEvent } from "#server/utils/jwt";
+import { getCurrentUser } from "#server/service/auth";
+
+export default defineWrappedResponseHandler(async (event) => {
+ const payload = getUserFromEvent(event);
+ if (!payload) {
+ return R.error("未登录", null);
+ }
+
+ const user = await getCurrentUser(payload);
+ if (!user) {
+ return R.error("用户不存在", null);
+ }
+
+ return R.success({ user });
+});
diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts
new file mode 100644
index 0000000..c4a97bb
--- /dev/null
+++ b/server/api/auth/register.post.ts
@@ -0,0 +1,36 @@
+import { registerUser } from "#server/service/auth";
+import { z } from "zod";
+
+const registerSchema = z.object({
+ username: z.string().min(3).max(20),
+ password: z.string().min(8).max(128),
+ captchaToken: z.string().min(1),
+ captchaCode: z.string().min(1),
+});
+
+export default defineWrappedResponseHandler(async (event) => {
+ const body = await readBody(event);
+ const parsed = registerSchema.safeParse(body);
+
+ if (!parsed.success) {
+ return R.error("参数校验失败", parsed.error.issues);
+ }
+
+ const { username, password, captchaToken, captchaCode } = parsed.data;
+ const result = await registerUser(username, password, captchaToken, captchaCode);
+
+ if (!result.success) {
+ return R.error(result.message!, null);
+ }
+
+ // Set token as httpOnly cookie
+ setCookie(event, "token", result.token!, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 60 * 60 * 24 * 7, // 7 days
+ path: "/",
+ });
+
+ return R.success({ user: result.user });
+});
diff --git a/server/service/auth/index.ts b/server/service/auth/index.ts
index 7751318..82837f0 100644
--- a/server/service/auth/index.ts
+++ b/server/service/auth/index.ts
@@ -1,12 +1,160 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
-import { users as usersTable } from "drizzle-pkg/lib/schema/auth";
-import { eq } from "drizzle-orm";
+import { users as usersTable, captchaCodes } from "drizzle-pkg/lib/schema/auth";
+import { eq, and, lt, sql } from "drizzle-orm";
+import { compare, hash } from "bcryptjs";
import log4js from "logger";
+import { signToken, type JwtPayload } from "#server/utils/jwt";
+import { generateCaptchaCode, generateCaptchaToken, generateCaptchaSvg, captchaSvgToDataUri } from "#server/utils/captcha";
-const logger = log4js.getLogger("AUTH")
+const logger = log4js.getLogger("AUTH");
+const CAPTCHA_EXPIRY_MS = 5 * 60 * 1000;
+const BCRYPT_ROUNDS = 12;
-export async function getUsers() {
- const users = await dbGlobal.select().from(usersTable)
- logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2));
- return users;
-}
\ No newline at end of file
+// ========== Captcha ==========
+
+export async function createCaptcha() {
+ const code = generateCaptchaCode();
+ const token = generateCaptchaToken();
+ const expiresAt = new Date(Date.now() + CAPTCHA_EXPIRY_MS);
+
+ await dbGlobal.insert(captchaCodes).values({
+ token,
+ code,
+ expiresAt,
+ });
+
+ const svg = generateCaptchaSvg(code);
+ const dataUri = captchaSvgToDataUri(svg);
+
+ return { token, image: dataUri };
+}
+
+async function verifyAndConsumeCaptcha(token: string, code: string): Promise {
+ const now = new Date();
+
+ // Atomic: mark as used only if not already used and not expired, returning the code
+ const [record] = await dbGlobal
+ .update(captchaCodes)
+ .set({ used: true })
+ .where(
+ and(
+ eq(captchaCodes.token, token),
+ eq(captchaCodes.used, false),
+ sql`${captchaCodes.expiresAt} > ${now.getTime()}`,
+ ),
+ )
+ .returning({ code: captchaCodes.code });
+
+ if (!record) return false;
+
+ return record.code.toUpperCase() === code.toUpperCase();
+}
+
+// ========== Register ==========
+
+export async function registerUser(username: string, password: string, captchaToken: string, captchaCode: string) {
+ const validCaptcha = await verifyAndConsumeCaptcha(captchaToken, captchaCode);
+ if (!validCaptcha) {
+ return { success: false, message: "验证码错误或已过期" };
+ }
+
+ if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
+ return { success: false, message: "用户名需为3-20位字母、数字或下划线" };
+ }
+
+ if (password.length < 8) {
+ return { success: false, message: "密码长度不能少于8位" };
+ }
+
+ const [existing] = await dbGlobal
+ .select({ id: usersTable.id })
+ .from(usersTable)
+ .where(eq(usersTable.username, username));
+
+ if (existing) {
+ return { success: false, message: "用户名已被注册" };
+ }
+
+ const hashedPassword = await hash(password, BCRYPT_ROUNDS);
+
+ const [newUser] = await dbGlobal
+ .insert(usersTable)
+ .values({
+ username,
+ password: hashedPassword,
+ })
+ .returning({ id: usersTable.id, username: usersTable.username, role: usersTable.role });
+
+ const token = signToken({ userId: newUser.id, username: newUser.username });
+
+ return { success: true, token, user: newUser };
+}
+
+// ========== Login ==========
+
+export async function loginUser(username: string, password: string, captchaToken: string, captchaCode: string) {
+ const validCaptcha = await verifyAndConsumeCaptcha(captchaToken, captchaCode);
+ if (!validCaptcha) {
+ return { success: false, message: "验证码错误或已过期" };
+ }
+
+ const [user] = await dbGlobal
+ .select()
+ .from(usersTable)
+ .where(eq(usersTable.username, username));
+
+ // Constant-time check: always run bcrypt compare even if user doesn't exist
+ const dummyHash = "$2a$12$" + "0".repeat(53);
+ const hashToCheck = user ? user.password : dummyHash;
+ const validPassword = await compare(password, hashToCheck);
+
+ if (!user || !validPassword) {
+ return { success: false, message: "用户名或密码错误" };
+ }
+
+ const token = signToken({ userId: user.id, username: user.username });
+
+ return {
+ success: true,
+ token,
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ nickname: user.nickname,
+ avatar: user.avatar,
+ role: user.role,
+ },
+ };
+}
+
+// ========== Get current user ==========
+
+export async function getCurrentUser(payload: JwtPayload) {
+ const [user] = await dbGlobal
+ .select()
+ .from(usersTable)
+ .where(eq(usersTable.id, payload.userId));
+
+ if (!user) return null;
+
+ return {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ nickname: user.nickname,
+ avatar: user.avatar,
+ role: user.role,
+ createdAt: user.createdAt,
+ };
+}
+
+// ========== Cleanup expired captchas ==========
+
+export async function cleanupExpiredCaptchas() {
+ await dbGlobal
+ .delete(captchaCodes)
+ .where(lt(captchaCodes.expiresAt, new Date()));
+}
+
+export { getUsers } from "./legacy";
diff --git a/server/service/auth/legacy.ts b/server/service/auth/legacy.ts
new file mode 100644
index 0000000..8ec9dac
--- /dev/null
+++ b/server/service/auth/legacy.ts
@@ -0,0 +1,11 @@
+import { dbGlobal } from "drizzle-pkg/lib/db";
+import { users as usersTable } from "drizzle-pkg/lib/schema/auth";
+import log4js from "logger";
+
+const logger = log4js.getLogger("AUTH");
+
+export async function getUsers() {
+ const users = await dbGlobal.select().from(usersTable);
+ logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2));
+ return users;
+}
diff --git a/server/utils/captcha.ts b/server/utils/captcha.ts
new file mode 100644
index 0000000..fcf422c
--- /dev/null
+++ b/server/utils/captcha.ts
@@ -0,0 +1,66 @@
+import { randomBytes, createHash } from "node:crypto";
+
+function randomInt(min: number, max: number): number {
+ const range = max - min + 1;
+ const maxRand = 256 - (256 % range);
+ let rand: number;
+ do {
+ rand = randomBytes(1)[0];
+ } while (rand >= maxRand);
+ return min + (rand % range);
+}
+
+function randomChar(charset: string): string {
+ return charset[randomInt(0, charset.length - 1)];
+}
+
+export function generateCaptchaCode(length: number = 4): string {
+ const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ let code = "";
+ for (let i = 0; i < length; i++) {
+ code += randomChar(charset);
+ }
+ return code;
+}
+
+export function generateCaptchaToken(): string {
+ return randomBytes(32).toString("hex");
+}
+
+export function generateCaptchaSvg(code: string): string {
+ const width = 120;
+ const height = 44;
+ const charWidth = width / code.length;
+ const colors = ["#0066cc", "#1d1d1f", "#0071e3", "#333333"];
+
+ let paths = "";
+ let texts = "";
+
+ for (let i = 0; i < 3; i++) {
+ const x1 = randomInt(0, width);
+ const y1 = randomInt(0, height);
+ const x2 = randomInt(0, width);
+ const y2 = randomInt(0, height);
+ paths += ``;
+ }
+
+ for (let i = 0; i < code.length; i++) {
+ const x = charWidth * i + randomInt(4, charWidth - 20);
+ const y = randomInt(26, 34);
+ const rotate = randomInt(-20, 20);
+ const color = colors[randomInt(0, colors.length - 1)];
+ const fontSize = randomInt(22, 28);
+ texts += `${code[i]}`;
+ }
+
+ return ``;
+}
+
+export function captchaSvgToDataUri(svg: string): string {
+ const base64 = Buffer.from(svg).toString("base64");
+ return `data:image/svg+xml;base64,${base64}`;
+}
diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts
new file mode 100644
index 0000000..0b6d1df
--- /dev/null
+++ b/server/utils/jwt.ts
@@ -0,0 +1,41 @@
+import jwt from "jsonwebtoken";
+import type { H3Event } from "h3";
+
+const JWT_SECRET = process.env.JWT_SECRET as string;
+if (!JWT_SECRET) {
+ throw new Error("JWT_SECRET is not defined in environment variables");
+}
+const JWT_EXPIRES_IN = "7d";
+
+export interface JwtPayload {
+ userId: number;
+ username: string;
+}
+
+export function signToken(payload: JwtPayload): string {
+ return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
+}
+
+export function verifyToken(token: string): JwtPayload {
+ return jwt.verify(token, JWT_SECRET) as JwtPayload;
+}
+
+export function getTokenFromEvent(event: H3Event): string | null {
+ const header = getHeader(event, "Authorization");
+ if (header && header.startsWith("Bearer ")) {
+ return header.slice(7);
+ }
+ const cookie = getCookie(event, "token");
+ if (cookie) return cookie;
+ return null;
+}
+
+export function getUserFromEvent(event: H3Event): JwtPayload | null {
+ const token = getTokenFromEvent(event);
+ if (!token) return null;
+ try {
+ return verifyToken(token);
+ } catch {
+ return null;
+ }
+}