init
This commit is contained in:
6
server/.dockerignore
Normal file
6
server/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
@@ -8,19 +8,15 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import type { Database as DatabaseType } from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = path.join(__dirname, '..', 'data.db');
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
const db: DatabaseType = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
phone_number TEXT NOT NULL,
|
||||
contact_name TEXT,
|
||||
content TEXT NOT NULL,
|
||||
@@ -28,11 +20,11 @@ db.exec(`
|
||||
sms_date DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_user ON sms_messages(user_id, sms_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_date ON sms_messages(sms_date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sms_dedup ON sms_messages(phone_number, content, sms_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
device_name TEXT NOT NULL,
|
||||
last_sync_at DATETIME
|
||||
);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import smsRoutes from './routes/sms.js';
|
||||
import deviceRoutes from './routes/devices.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { tokenAuth } from './middleware/auth.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// Ensure UTF-8 charset on all JSON responses
|
||||
app.use((_req, res, next) => {
|
||||
const orig = res.json.bind(res);
|
||||
res.json = (body: unknown) => {
|
||||
@@ -22,8 +24,13 @@ app.use((_req, res, next) => {
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/sms', authMiddleware, smsRoutes);
|
||||
app.use('/api/devices', authMiddleware, deviceRoutes);
|
||||
app.use('/api/sms', tokenAuth, smsRoutes);
|
||||
app.use('/api/devices', tokenAuth, deviceRoutes);
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'sms-monitor-secret-change-in-production';
|
||||
export const ACCESS_TOKEN = process.env.ACCESS_TOKEN || 'change-me-in-production';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
export function tokenAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Missing token' });
|
||||
@@ -15,13 +10,10 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
|
||||
}
|
||||
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { userId: number };
|
||||
req.userId = payload.userId;
|
||||
next();
|
||||
} catch {
|
||||
if (token !== ACCESS_TOKEN) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export { JWT_SECRET };
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -1,63 +1,26 @@
|
||||
import { Router, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import db from '../db.js';
|
||||
import { JWT_SECRET, AuthRequest } from '../middleware/auth.js';
|
||||
import { ACCESS_TOKEN } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(3, '用户名至少3个字符').max(50, '用户名最多50个字符'),
|
||||
password: z.string().min(6, '密码至少6个字符').max(100, '密码最多100个字符'),
|
||||
const verifySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
function formatZodErrors(errors: z.ZodIssue[]) {
|
||||
return errors.map(e => e.message).join('; ');
|
||||
}
|
||||
|
||||
router.post('/register', (req: AuthRequest, res: Response) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
router.post('/verify', (req, res: Response) => {
|
||||
const parsed = verifySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: formatZodErrors(parsed.error.errors) });
|
||||
res.status(400).json({ error: parsed.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: '用户名已存在' });
|
||||
if (parsed.data.token !== ACCESS_TOKEN) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 10);
|
||||
const result = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, password_hash);
|
||||
|
||||
const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '30d' });
|
||||
res.status(201).json({ token, userId: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
router.post('/login', (req: AuthRequest, res: Response) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: formatZodErrors(parsed.error.errors) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const user = db.prepare('SELECT id, password_hash FROM users WHERE username = ?').get(username) as any;
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
res.status(401).json({ error: '用户名或密码错误' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '30d' });
|
||||
res.json({ token, userId: user.id });
|
||||
res.json({ valid: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db.js';
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -9,7 +8,7 @@ const registerSchema = z.object({
|
||||
device_name: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/register', (req: AuthRequest, res: Response) => {
|
||||
router.post('/register', (req, res: Response) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.errors });
|
||||
@@ -19,8 +18,8 @@ router.post('/register', (req: AuthRequest, res: Response) => {
|
||||
const { device_name } = parsed.data;
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT id FROM devices WHERE user_id = ? AND device_name = ?')
|
||||
.get(req.userId!, device_name);
|
||||
.prepare('SELECT id FROM devices WHERE device_name = ?')
|
||||
.get(device_name);
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE devices SET last_sync_at = CURRENT_TIMESTAMP WHERE id = ?').run((existing as any).id);
|
||||
@@ -29,8 +28,8 @@ router.post('/register', (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare('INSERT INTO devices (user_id, device_name, last_sync_at) VALUES (?, ?, CURRENT_TIMESTAMP)')
|
||||
.run(req.userId!, device_name);
|
||||
.prepare('INSERT INTO devices (device_name, last_sync_at) VALUES (?, CURRENT_TIMESTAMP)')
|
||||
.run(device_name);
|
||||
|
||||
res.status(201).json({ deviceId: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db.js';
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -15,7 +14,7 @@ const uploadSchema = z.array(
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/upload', (req: AuthRequest, res: Response) => {
|
||||
router.post('/upload', (req, res: Response) => {
|
||||
const parsed = uploadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.errors });
|
||||
@@ -23,14 +22,14 @@ router.post('/upload', (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO sms_messages (user_id, phone_number, contact_name, content, type, sms_date) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT OR IGNORE INTO sms_messages (phone_number, contact_name, content, type, sms_date) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
const txn = db.transaction((items: z.infer<typeof uploadSchema>) => {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
insert.run(req.userId!, item.phone_number, item.contact_name || null, item.content, item.type, item.sms_date);
|
||||
count++;
|
||||
const result = insert.run(item.phone_number, item.contact_name || null, item.content, item.type, item.sms_date);
|
||||
if (result.changes > 0) count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
@@ -39,13 +38,13 @@ router.post('/upload', (req: AuthRequest, res: Response) => {
|
||||
res.json({ uploaded: count });
|
||||
});
|
||||
|
||||
router.get('/', (req: AuthRequest, res: Response) => {
|
||||
router.get('/', (req, res: Response) => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let where = 'WHERE user_id = ?';
|
||||
const params: any[] = [req.userId!];
|
||||
let where = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
|
||||
if (req.query.phone) {
|
||||
where += ' AND phone_number LIKE ?';
|
||||
@@ -64,8 +63,8 @@ router.get('/', (req: AuthRequest, res: Response) => {
|
||||
res.json({ messages, total, page, limit, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
router.get('/:id', (req: AuthRequest, res: Response) => {
|
||||
const msg = db.prepare('SELECT * FROM sms_messages WHERE id = ? AND user_id = ?').get(req.params.id, req.userId!);
|
||||
router.get('/:id', (req, res: Response) => {
|
||||
const msg = db.prepare('SELECT * FROM sms_messages WHERE id = ?').get(req.params.id);
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
@@ -73,8 +72,8 @@ router.get('/:id', (req: AuthRequest, res: Response) => {
|
||||
res.json(msg);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: AuthRequest, res: Response) => {
|
||||
const result = db.prepare('DELETE FROM sms_messages WHERE id = ? AND user_id = ?').run(req.params.id, req.userId!);
|
||||
router.delete('/:id', (req, res: Response) => {
|
||||
const result = db.prepare('DELETE FROM sms_messages WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SmsMessage {
|
||||
id: number;
|
||||
user_id: number;
|
||||
phone_number: string;
|
||||
contact_name: string | null;
|
||||
content: string;
|
||||
@@ -18,7 +10,6 @@ export interface SmsMessage {
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
user_id: number;
|
||||
device_name: string;
|
||||
last_sync_at: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user