This commit is contained in:
eoao
2025-04-26 17:24:39 +08:00
commit 24ba4772e6
107 changed files with 13612 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import app from '../hono/hono';
import accountService from '../service/account-service';
import result from '../model/result';
import userContext from '../security/user-context';
app.get('/account/list', async (c) => {
const list = await accountService.list(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok(list));
});
app.delete('/account/delete', async (c) => {
await accountService.delete(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok());
});
app.post('/account/add', async (c) => {
const account = await accountService.add(c, await c.req.json(), await userContext.getUserId(c));
return c.json(result.ok(account));
});

View File

@@ -0,0 +1,26 @@
import app from '../hono/hono';
import emailService from '../service/email-service';
import result from '../model/result';
import userContext from '../security/user-context';
import attService from '../service/att-service';
app.get('/email/list', async (c) => {
const data = await emailService.list(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok(data));
});
app.get('/email/latest',async (c) => {
const list = await emailService.latest(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok(list));
})
app.delete('/email/delete', async (c) => {
await emailService.delete(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok());
});
app.get('/email/attList', async (c) => {
const attList = await attService.list(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok(attList));
})

View File

@@ -0,0 +1,19 @@
import app from '../hono/hono';
import loginService from '../service/login-service';
import result from '../model/result';
import userContext from '../security/user-context';
app.post('/login', async (c) => {
const token = await loginService.login(c, await c.req.json());
return c.json(result.ok({ token: token }));
});
app.post('/register', async (c) => {
const jwt = await loginService.register(c, await c.req.json());
return c.json(result.ok(jwt));
});
app.delete('/logout', async (c) => {
await loginService.logout(c, await userContext.getUserId(c));
return c.json(result.ok());
});

View File

@@ -0,0 +1,13 @@
import app from '../hono/hono';
import result from '../model/result';
import settingService from '../service/setting-service';
app.put('/setting/set', async (c) => {
await settingService.set(c, await c.req.json());
return c.json(result.ok());
})
app.get('/setting/query', async (c) => {
const setting = await settingService.query(c);
return c.json(result.ok(setting));
})

View File

@@ -0,0 +1,19 @@
import app from '../hono/hono';
import startService from '../service/star-service';
import userContext from '../security/user-context';
import result from '../model/result';
app.post('/star/add', async (c) => {
await startService.add(c, await c.req.json(), await userContext.getUserId(c));
return c.json(result.ok());
});
app.get('/star/list', async (c) => {
const data = await startService.list(c, c.req.query(), await userContext.getUserId(c));
return c.json(result.ok(data));
});
app.delete('/star/cancel', async (c) => {
await startService.cancel(c, await c.req.query(), await userContext.getUserId(c));
return c.json(result.ok());
});

View File

@@ -0,0 +1 @@
import app from '../hono/hono';

View File

@@ -0,0 +1,23 @@
import app from '../hono/hono';
import userService from '../service/user-service';
import result from '../model/result';
import userContext from '../security/user-context';
app.get('/user/loginUserInfo', async (c) => {
const user = await userService.loginUserInfo(c, await userContext.getUserId(c));
return c.json(result.ok(user));
});
app.put('/user/resetPassword', async (c) => {
await userService.resetPassword(c, await c.req.json(), await userContext.getUserId(c));
return c.json(result.ok());
});
app.delete('/user/delete', async (c) => {
await userService.delete(c, await userContext.getUserId(c));
return c.json(result.ok());
})

View File

@@ -0,0 +1,10 @@
const constant = {
TOKEN_HEADER: 'Authorization',
JWT_UID: 'user_id:',
JWT_TOKEN: 'token:',
TOKEN_EXPIRE: 60 * 60 * 24 * 30,
ATTACHMENT_PREFIX: 'attachments/'
}
export default constant

View File

@@ -0,0 +1,42 @@
export const userConst = {
type: {
COMMON: 1,
ADMIN: 0,
},
}
export const settingConst = {
register: {
OPEN: 0,
CLOSE: 1,
},
receive: {
OPEN: 0,
CLOSE: 1,
},
send: {
OPEN: 0,
CLOSE: 1,
},
addEmail: {
OPEN: 0,
CLOSE: 1,
},
manyEmail: {
OPEN: 0,
CLOSE: 1,
},
registerVerify: {
OPEN: 0,
CLOSE: 1,
},
addEmailVerify: {
OPEN: 0,
CLOSE: 1,
}
}
export const isDel = {
DELETE: 1,
NORMAL: 0
}

View File

@@ -0,0 +1,6 @@
const KvConst = {
AUTH_INFO: 'auth-uid:',
SETTING: 'setting:',
}
export default KvConst;

View File

@@ -0,0 +1,77 @@
import PostalMime from 'postal-mime';
import emailService from '../service/email-service';
import accountService from '../service/account-service';
import { isDel } from '../const/entity-const';
import settingService from '../service/setting-service';
import attService from '../service/att-service';
import r2Service from '../service/r2-service';
import constant from '../const/constant';
import { v4 as uuidv4 } from 'uuid';
import fileUtils from '../utils/file-utils';
export async function email(message, env, ctx) {
try {
if (!await settingService.isReceive({ env })) {
return;
}
const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to);
const reader = message.raw.getReader();
let content = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
content += new TextDecoder().decode(value);
}
const email = await PostalMime.parse(content);
const params = {
sendEmail: email.from.address,
name: email.from.name,
receiveEmail: message.to,
subject: email.subject,
content: email.html,
text: email.text,
userId: account.userId,
accountId: account.accountId
};
const emailRow = await emailService.receive({ env }, params);
if (email.attachments.length > 0) {
let attachments = email.attachments.map(item => {
let attachment = { ...item };
attachment.emailId = emailRow.emailId;
attachment.userId = emailRow.userId;
attachment.accountId = emailRow.accountId;
attachment.key = constant.ATTACHMENT_PREFIX + uuidv4() + fileUtils.getExtFileName(item.filename);
attachment.size = item.content.length ?? item.content.byteLength;
return attachment;
});
await attService.addAtt({ env }, attachments);
if (!env.r2) {
console.log('r2对象存储未配置, 附件取消保存');
return;
}
for (let attachment of attachments) {
await r2Service.putObj({ env }, attachment.key, attachment.content, {
contentType: attachment.mimeType,
contentDisposition: `attachment; filename="${attachment.filename}"`
});
}
}
} catch (e) {
console.error('邮件接收异常: ', e);
}
}

View File

@@ -0,0 +1,12 @@
import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const account = sqliteTable('account', {
accountId: integer('account_id').primaryKey({ autoIncrement: true }),
email: text('email').notNull(),
status: integer('status').default(0).notNull(),
latestEmailTime: text('latest_email_time'),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`),
userId: integer('user_id').notNull(),
isDel: integer('is_del').default(0).notNull(),
});
export default account

View File

@@ -0,0 +1,19 @@
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const att = sqliteTable('attachments', {
attId: integer('att_id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull(),
emailId: integer('email_id').notNull(),
accountId: integer('account_id').notNull(),
key: text('key').notNull(),
filename: text('filename'),
mimeType: text('mime_type'),
size: integer('size'),
disposition: text('disposition'),
related: text('related'),
contentId: text('content_id'),
encoding: text('encoding'),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),
});

View File

@@ -0,0 +1,16 @@
import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const email = sqliteTable('email', {
emailId: integer('email_id').primaryKey({ autoIncrement: true }),
sendEmail: text('send_email'),
name: text('name'),
receiveEmail: text('receive_email').notNull(),
accountId: integer('account_id').notNull(),
userId: integer('user_id').notNull(),
subject: text('subject'),
text: text('text'),
content: text('content'),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(),
isDel: integer('is_del').default(0).notNull()
});
export default email

View File

@@ -0,0 +1,5 @@
import { drizzle } from 'drizzle-orm/d1';
export default function orm(c) {
return drizzle(c.env.db,{logger: false})
}

View File

@@ -0,0 +1,12 @@
import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
export const setting = sqliteTable('setting', {
register: integer('register').default(0).notNull(),
receive: integer('receive').default(0).notNull(),
title: text('title').default('').notNull(),
manyEmail: integer('many_email').default(1).notNull(),
addEmail: integer('add_email').default(0).notNull(),
autoRefreshTime: integer('auto_refresh_time').default(0).notNull(),
registerVerify: integer('register_verify').default(1).notNull(),
addEmailVerify: integer('add_email_verify').default(1).notNull()
});
export default setting

View File

@@ -0,0 +1,11 @@
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const star = sqliteTable('star', {
starId: integer('star_id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull(),
emailId: integer('email_id').notNull(),
createTime: text('create_time')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -0,0 +1,14 @@
import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
const user = sqliteTable('user', {
userId: integer('user_id').primaryKey({ autoIncrement: true }),
email: text('email').notNull(),
type: integer('type').default(1).notNull(),
password: text('password').notNull(),
salt: text('salt').notNull(),
status: integer('status').default(0).notNull(),
createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`),
activeTime: text('active_time'),
isDel: integer('is_del').default(0).notNull(),
});
export default user

View File

@@ -0,0 +1,9 @@
class BizError extends Error {
constructor(message, code) {
super(message);
this.code = code ? code : 501;
this.name = 'BizError';
}
}
export default BizError;

View File

@@ -0,0 +1,50 @@
import { Hono } from 'hono';
const app = new Hono();
import initDB from '../init/init-db';
import initCache from '../init/init-cache';
import verify from '../security/security';
import result from '../model/result';
import { cors } from 'hono/cors';
app.use('*', cors());
let initStatus = true;
app.use('*', async (c, next) => {
if (initStatus) {
await initDB(c);
initStatus = false;
}
await next();
});
let initCacheStatus = true;
app.use('*', async (c, next) => {
if (initCacheStatus) {
await initCache(c);
initCacheStatus = false;
}
await next();
});
app.use('*', async (c, next) => {
if(await verify(c)) {
await next();
}
});
app.onError((err, c) => {
if (err.name === 'BizError') {
console.log(err.message);
}else {
console.error(err);
}
return c.json(result.fail(err.message, err.code));
});
export default app;

View File

@@ -0,0 +1,9 @@
import app from './hono';
import '../api/email-api';
import '../api/user-api';
import '../api/login-api';
import '../api/setting-api';
import '../api/account-api';
import '../api/star-api';
import '../api/test-api';
export default app;

17
mail-worker/src/index.js Normal file
View File

@@ -0,0 +1,17 @@
import app from './hono/webs';
import { email } from './email/email';
export default {
fetch(req, env, ctx) {
const url = new URL(req.url)
console.log(url.pathname)
if (url.pathname.startsWith('/api/')) {
url.pathname = url.pathname.replace('/api', '')
req = new Request(url.toString(), req)
return app.fetch(req, env, ctx);
}
return env.ASSETS.fetch(req);
},
email: email
};

View File

@@ -0,0 +1,4 @@
import settingService from '../service/setting-service';
export default async function initCache(c) {
await settingService.refresh(c)
}

View File

@@ -0,0 +1,96 @@
export default async function initDB(c) {
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS email (
email_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
send_email TEXT,
name TEXT,
receive_email TEXT NOT NULL,
account_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
subject TEXT,
content TEXT,
text TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_del INTEGER DEFAULT 0 NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS star (
star_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email_id INTEGER NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS attachments (
att_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
key TEXT NOT NULL,
filename TEXT,
mime_type TEXT,
size INTEGER,
disposition TEXT,
related TEXT,
content_id TEXT,
encoding TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS user (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
type INTEGER DEFAULT 1 NOT NULL,
password TEXT NOT NULL,
salt TEXT NOT NULL,
status INTEGER DEFAULT 0 NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
active_time DATETIME,
is_del INTEGER DEFAULT 0 NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS account (
account_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
status INTEGER DEFAULT 0 NOT NULL,
latest_email_time DATETIME,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER NOT NULL,
is_del INTEGER DEFAULT 0 NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS setting (
register INTEGER NOT NULL,
receive INTEGER NOT NULL,
add_email INTEGER NOT NULL,
many_email INTEGER NOT NULL,
title TEXT NOT NULL,
auto_refresh_time INTEGER NOT NULL,
register_verify INTEGER NOT NULL,
add_email_verify INTEGER NOT NULL
)
`).run();
await c.env.db.prepare(`
INSERT INTO setting (register, receive,add_email,many_email,title,auto_refresh_time,register_verify,add_email_verify)
SELECT 0, 0, 0, 1, 'Cloud 邮箱', 0, 1, 1
WHERE NOT EXISTS (SELECT 1 FROM setting)
`).run();
}

View File

@@ -0,0 +1,9 @@
const result = {
ok(data) {
return { code: 200, message: 'success', data: data ? data : null };
},
fail(message, code = 500) {
return { code, message };
}
};
export default result;

View File

@@ -0,0 +1,60 @@
import BizError from '../error/biz-error';
import constant from '../const/constant';
import jwtUtils from '../utils/jwt-utils';
import KvConst from '../const/kv-const';
import { userConst } from '../const/entity-const';
import dayjs from 'dayjs';
const exclude = [
'/login',
'/register',
'/setting/query'
];
const adminPerm = [
'/setting/set'
]
const verify = async (c) => {
if (c.req.path.startsWith('/test')) {
return true
}
if (exclude.includes(c.req.path)) {
return true;
}
const jwt = c.req.header(constant.TOKEN_HEADER);
const result = await jwtUtils.verifyToken(c, jwt)
if (!result) {
throw new BizError('身份认证失效,请重新登录',401)
}
const {userId, token} = result
const authInfo = await c.env.kv.get(KvConst.AUTH_INFO + userId,{ type: 'json' });
if (!authInfo) {
throw new BizError('身份认证失效,请重新登录',401)
}
if(!authInfo.tokens.includes(token)) {
throw new BizError('身份认证失效,请重新登录',401)
}
if (adminPerm.includes(c.req.path) && c.env.admin !== authInfo.user.email) {
throw new BizError('权限不足',403)
}
if (dayjs().diff(dayjs(authInfo.createTime), 'day') >= 1) {
authInfo.refreshTime = new Date().toISOString()
await c.env.kv.put(KvConst.AUTH_INFO + userId, JSON.stringify(authInfo), { expirationTtl: constant.TOKEN_EXPIRE });
}
return true
};
export default verify;

View File

@@ -0,0 +1,16 @@
import JwtUtils from '../utils/jwt-utils';
import constant from '../const/constant';
const userContext = {
async getUserId(c) {
const jwt = c.req.header(constant.TOKEN_HEADER);
const { userId } = await JwtUtils.verifyToken(c, jwt);
return Number(userId);
},
async getToken(c) {
const jwt = c.req.header(constant.TOKEN_HEADER);
const { token } = JwtUtils.verifyToken(c,jwt);
return token;
},
};
export default userContext;

View File

@@ -0,0 +1,127 @@
import BizError from '../error/biz-error';
import verifyUtils from '../utils/verify-utils';
import emailUtils from '../utils/email-utils';
import userService from './user-service';
import emailService from './email-service';
import orm from '../entity/orm';
import account from '../entity/account';
import { and, asc, eq, gt } from 'drizzle-orm';
import { isDel } from '../const/entity-const';
import settingService from './setting-service';
import turnstileService from './turnstile-service';
const accountService = {
async add(c, params, userId) {
if (!await settingService.isAddEmail(c)) {
throw new BizError('添加邮箱功能已关闭');
}
let { email, token } = params;
if (!email) {
throw new BizError('邮箱不能为空');
}
if (!verifyUtils.isEmail(email)) {
throw new BizError('非法邮箱');
}
if (!c.env.domain.includes(emailUtils.getDomain(email))) {
throw new BizError('未配置改邮箱域名');
}
const accountRow = await this.selectByEmailIncludeDel(c, email);
if (accountRow && accountRow.isDel === isDel.DELETE) {
throw new BizError('该邮箱已被注销');
}
if (accountRow) {
throw new BizError('该邮箱已被注册');
}
if (await settingService.isAddEmailVerify(c)) {
await turnstileService.verify(c, token);
}
return orm(c).insert(account).values({ email: email, userId: userId }).returning().get();
},
selectByEmailIncludeDel(c, email) {
return orm(c).select().from(account).where(eq(account.email, email)).get();
},
selectByEmail(c, email) {
return orm(c).select().from(account).where(
and(
eq(account.email, email),
eq(account.isDel, isDel.NORMAL)))
.get();
},
list(c, params, userId) {
let { accountId, size } = params;
accountId = Number(accountId);
size = Number(size);
if (size > 30) {
size = 30;
}
if (!accountId) {
accountId = 0;
}
return orm(c).select().from(account).where(
and(
eq(account.userId, userId),
eq(account.isDel, isDel.NORMAL),
gt(account.accountId, accountId)))
.orderBy(asc(account.accountId))
.limit(size)
.all();
},
async delete(c, params, userId) {
let { accountId } = params;
const user = await userService.selectById(c, userId);
const accountRow = await this.selectById(c, accountId);
if (accountRow.email === user.email) {
throw new BizError('不可以删除自己的邮箱');
}
if (accountRow.userId !== user.userId) {
throw new BizError('该邮箱不属于当前用户');
}
await orm(c).update(account).set({ isDel: isDel.DELETE }).where(
and(eq(account.userId, userId),
eq(account.accountId, accountId)))
.run();
await emailService.removeByAccountId(c, accountId);
},
selectById(c, accountId) {
return orm(c).select().from(account).where(
and(eq(account.accountId, accountId),
eq(account.isDel, isDel.NORMAL)))
.get();
},
async insert(c, params) {
await orm(c).insert(account).values({ ...params }).returning();
},
async removeByUserId(c, userId) {
await orm(c).update(account).set({ isDel: isDel.DELETE }).where(eq(account.userId, userId)).run();
}
};
export default accountService;

View File

@@ -0,0 +1,22 @@
import orm from '../entity/orm';
import { att } from '../entity/att';
import { and, eq } from 'drizzle-orm';
const attService = {
async addAtt(c, params) {
await orm(c).insert(att).values(params).run();
},
async list(c, params, userId) {
const { emailId } = params;
const list = await orm(c).select().from(att).where(
and(
eq(att.emailId, emailId),
eq(att.userId, userId)))
.all();
return list;
}
};
export default attService;

View File

@@ -0,0 +1,102 @@
import orm from '../entity/orm';
import email from '../entity/email';
import { isDel } from '../const/entity-const';
import { and, desc, eq, gt, inArray, lt, sql } from 'drizzle-orm';
import { star } from '../entity/star';
const emailService = {
async list(c, params, userId) {
let { emailId, accountId, size } = params;
size = Number(size);
emailId = Number(emailId);
if (size > 30) {
size = 30;
}
if (!emailId) {
emailId = 9999999999;
}
const list = await orm(c)
.select({
...email,
starId: star.starId
})
.from(email)
.leftJoin(
star,
and(
eq(star.emailId, email.emailId),
eq(star.userId, userId)
)
)
.where(
and(
lt(email.emailId, emailId),
eq(email.accountId, accountId),
eq(email.userId, userId),
eq(email.isDel, isDel.NORMAL)
)
)
.orderBy(desc(email.emailId))
.limit(size)
.all();
const resultList = list.map(item => ({
...item,
isStar: item.starId != null ? 1 : 0
}));
return { list: resultList };
},
async delete(c, params, userId) {
const { emailIds } = params;
const emailIdList = emailIds.split(',').map(Number);
await orm(c).update(email).set({ isDel: isDel.DELETE }).where(
and(
eq(email.userId, userId),
inArray(email.emailId, emailIdList)))
.run();
},
receive(c, params) {
return orm(c).insert(email).values({ ...params }).returning().get();
},
async removeByAccountId(c, accountId) {
await orm(c).update(email)
.set({ isDel: isDel.DELETE })
.where(eq(email.accountId, accountId))
.run();
},
async removeByUserId(c, userId) {
await orm(c).update(email).set({ isDel: isDel.DELETE }).where(eq(email.userId, userId)).run();
},
selectById(c, emailId) {
return orm(c).select().from(email).where(
and(eq(email.emailId, emailId),
eq(email.isDel, isDel.NORMAL)))
.get();
},
latest(c, params, userId) {
let { emailId,accountId } = params;
return orm(c).select().from(email).where(
and(
eq(email.userId, userId),
eq(email.isDel, isDel.NORMAL),
eq(email.accountId, accountId),
gt(email.emailId, emailId)
))
.orderBy(desc(email.emailId))
.limit(20);
}
};
export default emailService;

View File

@@ -0,0 +1,113 @@
import BizError from '../error/biz-error';
import userService from './user-service';
import emailUtils from '../utils/email-utils';
import { isDel, userConst } from '../const/entity-const';
import JwtUtils from '../utils/jwt-utils';
import { v4 as uuidv4 } from 'uuid';
import KvConst from '../const/kv-const';
import constant from '../const/constant';
import userContext from '../security/user-context';
import verifyUtils from '../utils/verify-utils';
import accountService from './account-service';
import settingService from './setting-service';
import saltHashUtils from '../utils/crypto-utils';
import cryptoUtils from '../utils/crypto-utils';
import turnstileService from './turnstile-service';
const loginService = {
async register(c, params) {
const { email, password, token } = params;
if (!await settingService.isRegister(c)) {
throw new BizError('注册功能已关闭');
}
if (!verifyUtils.isEmail(email)) {
throw new BizError('非法邮箱');
}
if (password.length < 6) {
throw new BizError('密码必须大于6位');
}
if (!c.env.domain.includes(emailUtils.getDomain(email))) {
throw new BizError('非法邮箱域名');
}
const userRow = await userService.selectByEmailIncludeDel(c, email);
if (userRow && userRow.isDel === isDel.DELETE) {
throw new BizError('该邮箱已被注销');
}
if (userRow) {
throw new BizError('该邮箱已被注册');
}
if (await settingService.isRegisterVerify(c)) {
await turnstileService.verify(c,token)
}
const { salt, hash } = await saltHashUtils.hashPassword(password);
const userId = await userService.insert(c, { email, password: hash, salt, type: userConst.type.COMMON });
await accountService.insert(c, { userId: userId, email });
},
async login(c, params) {
const { email, password } = params;
if (!email || !password) {
throw new BizError('邮箱和密码不能为空');
}
const userRow = await userService.selectByEmail(c, email);
if (!userRow) {
throw new BizError('该邮箱不存在');
}
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password)) {
throw new BizError('密码输入错误');
}
const uuid = uuidv4();
const jwt = await JwtUtils.generateToken(c,{ userId: userRow.userId, token: uuid });
let authInfo = await c.env.kv.get(KvConst.AUTH_INFO + userRow.userId, { type: 'json' });
if (authInfo) {
authInfo.tokens.push(uuid);
} else {
authInfo = {
tokens: [],
user: userRow,
refreshTime: new Date().toISOString()
};
authInfo.tokens.push(uuid);
}
await c.env.kv.put(KvConst.AUTH_INFO + userRow.userId, JSON.stringify(authInfo), { expirationTtl: constant.TOKEN_EXPIRE });
return jwt;
},
async logout(c, userId) {
const token = await userContext.getToken(c);
const authInfo = await c.env.kv.get(KvConst.AUTH_INFO + userId, { type: 'json' });
const index = authInfo.tokens.findIndex(item => item === token);
authInfo.tokens.splice(index, 1);
await c.env.kv.put(KvConst.AUTH_INFO + userId, JSON.stringify(authInfo));
}
};
export default loginService;

View File

@@ -0,0 +1,11 @@
const r2Service = {
async putObj(c, key, content, metadata) {
const body = typeof content === 'string'
? new TextEncoder().encode(content)
: content;
await c.env.r2.put(key, body, {
httpMetadata: {...metadata}
});
}
};
export default r2Service;

View File

@@ -0,0 +1,54 @@
import KvConst from '../const/kv-const';
import setting from '../entity/setting';
import orm from '../entity/orm';
import { settingConst } from '../const/entity-const';
const settingService = {
async refresh(c) {
const settingRow = await orm(c).select().from(setting).get();
await c.env.kv.put(KvConst.SETTING, JSON.stringify(settingRow));
},
async query(c) {
const setting = await c.env.kv.get(KvConst.SETTING, { type: 'json' });
let domainList = c.env.domain;
domainList = domainList.map(item => '@' + item);
setting.domainList = domainList;
setting.siteKey = c.env.site_key;
setting.r2Domain = c.env.r2_domain;
return setting;
},
async set(c, params) {
await orm(c).update(setting).set({ ...params }).returning().get();
await this.refresh(c);
},
async isRegister(c) {
const { register } = await this.query(c);
return register === settingConst.register.OPEN;
},
async isReceive(c) {
const { receive } = await this.query(c);
return receive === settingConst.receive.OPEN;
},
async isAddEmail(c) {
const { addEmail, manyEmail } = await this.query(c);
return addEmail === settingConst.addEmail.OPEN && manyEmail === settingConst.manyEmail.OPEN;
},
async isRegisterVerify(c) {
const { registerVerify } = await this.query(c);
return registerVerify === settingConst.registerVerify.OPEN;
},
async isAddEmailVerify(c) {
const { addEmailVerify } = await this.query(c);
return addEmailVerify === settingConst.addEmailVerify.OPEN;
}
};
export default settingService;

View File

@@ -0,0 +1,69 @@
import orm from '../entity/orm';
import { star } from '../entity/star';
import emailService from './email-service';
import BizError from '../error/biz-error';
import { and, desc, eq, lt, sql } from 'drizzle-orm';
import email from '../entity/email';
import { isDel } from '../const/entity-const';
const startService = {
async add(c, params, userId) {
const { emailId } = params;
const email = await emailService.selectById(c, emailId);
if (!email) {
throw new BizError('星标的邮件不存在');
}
if (!email.userId === userId) {
throw new BizError('星标的邮件非当前用户所有');
}
const exist = await orm(c).select().from(star).where(
and(
eq(star.userId, userId),
eq(star.emailId, emailId)))
.get()
if (exist) {
return
}
await orm(c).insert(star).values({ userId, emailId }).run();
},
async cancel(c, params, userId) {
const { emailId } = params;
await orm(c).delete(star).where(
and(
eq(star.userId, userId),
eq(star.emailId, emailId)))
.run();
},
async list(c, params, userId) {
let { emailId, size } = params;
emailId = Number(emailId);
size = Number(size);
if (!emailId) {
emailId = 9999999999;
}
const list = await orm(c).select({
isStar: sql`1`.as('isStar'),
starId: star.starId
, ...email
}).from(star)
.leftJoin(email, eq(email.emailId, star.emailId))
.where(
and(
eq(star.userId, userId),
eq(email.isDel, isDel.NORMAL),
lt(star.emailId, emailId)))
.orderBy(desc(star.emailId))
.limit(size)
.all();
return { list };
}
};
export default startService;

View File

@@ -0,0 +1,30 @@
import BizError from '../error/biz-error';
const turnstileService = {
async verify(c, token) {
if (!token) {
throw new BizError('验证token不能为空');
}
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
secret: c.env.secret_key,
response: token,
remoteip: c.req.header('cf-connecting-ip')
})
});
const result = await res.json();
console.log(result)
if (!result.success) {
throw new BizError('人机验证失败,请重试',400)
}
}
};
export default turnstileService;

View File

@@ -0,0 +1,67 @@
import BizError from '../error/biz-error';
import accountService from './account-service';
import orm from '../entity/orm';
import user from '../entity/user';
import { eq, and } from 'drizzle-orm';
import { isDel } from '../const/entity-const';
import kvConst from '../const/kv-const';
import cryptoUtils from '../utils/crypto-utils';
const userService = {
async loginUserInfo(c, userId) {
let user = await userService.selectById(c, userId);
let account = await accountService.selectByEmailIncludeDel(c, user.email);
delete user.password;
delete user.salt;
user.accountId = account.accountId;
user.type = c.env.admin === user.email ? 0 : 1;
return user;
},
async resetPassword(c, params, userId) {
const { password } = params;
if (password < 6) {
throw new BizError('密码不能小于6位');
}
const { salt, hash } = await cryptoUtils.hashPassword(password);
await orm(c).update(user).set({ password: hash, salt: salt }).where(eq(user.userId, userId)).run();
},
selectByEmail(c, email) {
return orm(c).select().from(user).where(
and(
eq(user.email, email),
eq(user.isDel, isDel.NORMAL)))
.get();
},
async insert(c, params) {
const { userId } = await orm(c).insert(user).values({ ...params }).returning().get();
return userId;
},
selectByEmailIncludeDel(c, email) {
return orm(c).select().from(user).where(eq(user.email, email)).get();
},
selectById(c, userId) {
return orm(c).select().from(user).where(
and(
eq(user.userId, userId),
eq(user.isDel, isDel.NORMAL)))
.get();
},
async delete(c, userId) {
await orm(c).update(user).set({ isDel: isDel.DELETE }).where(eq(user.userId, userId)).run();
await Promise.all([
c.env.kv.delete(kvConst.AUTH_INFO + userId),
accountService.removeByUserId(c, userId)
]);
}
};
export default userService;

View File

@@ -0,0 +1,31 @@
const encoder = new TextEncoder();
const saltHashUtils = {
generateSalt(length = 16) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array));
},
async hashPassword(password) {
const salt = this.generateSalt();
const hash = await this.genHashPassword(password, salt);
return { salt, hash };
},
async genHashPassword(password, salt) {
const data = encoder.encode(salt + password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return btoa(String.fromCharCode(...hashArray));
},
async verifyPassword(inputPassword, salt, storedHash) {
const hash = await this.genHashPassword(inputPassword, salt);
return hash === storedHash;
}
};
export default saltHashUtils;

View File

@@ -0,0 +1,9 @@
const emailUtils = {
getDomain(email) {
if (typeof email !== 'string') return ''
const parts = email.split('@')
return parts.length === 2 ? parts[1] : ''
}
}
export default emailUtils

View File

@@ -0,0 +1,9 @@
const fileUtils = {
getExtFileName(filename) {
const index = filename.lastIndexOf('.');
return index !== -1 ? filename.slice(index) : '';
}
};
export default fileUtils

View File

@@ -0,0 +1,87 @@
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const base64url = (input) => {
const str = btoa(String.fromCharCode(...new Uint8Array(input)));
return str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
};
const base64urlDecode = (str) => {
str = str.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
return Uint8Array.from(atob(str), c => c.charCodeAt(0));
};
const jwtUtils = {
async generateToken(c, payload, expiresInSeconds) {
const header = {
alg: 'HS256',
typ: 'JWT'
};
const now = Math.floor(Date.now() / 1000);
const exp = expiresInSeconds ? now + expiresInSeconds : undefined;
const fullPayload = {
...payload,
iat: now,
...(exp ? { exp } : {})
};
const headerStr = base64url(encoder.encode(JSON.stringify(header)));
const payloadStr = base64url(encoder.encode(JSON.stringify(fullPayload)));
const data = `${headerStr}.${payloadStr}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(c.env.jwt_secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureStr = base64url(signature);
return `${data}.${signatureStr}`;
},
async verifyToken(c, token) {
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
if (!headerB64 || !payloadB64 || !signatureB64) return null;
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(c.env.jwt_secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const valid = await crypto.subtle.verify(
'HMAC',
key,
base64urlDecode(signatureB64),
encoder.encode(data)
);
if (!valid) return null;
const payloadJson = decoder.decode(base64urlDecode(payloadB64));
const payload = JSON.parse(payloadJson);
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) return null;
return payload;
} catch (err) {
return null;
}
}
};
export default jwtUtils;

View File

@@ -0,0 +1,7 @@
const verifyUtils = {
isEmail(str) {
return /^[a-zA-Z0-9]+@([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]+$/.test(str);
}
}
export default verifyUtils