feat: Web 版上线 footpr.linyux.cn —— 登录页 + 按用户隔离的命令 API + 静态托管
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// 本地开发:直接引用仓库内的命令层(与桌面端单一数据源)
|
||||
// Docker 构建:此文件被 app/server/db.mjs 覆盖(见 Dockerfile),保证同源
|
||||
export { createAppCommands } from '../../app/server/db.mjs';
|
||||
+71
-16
@@ -1,13 +1,39 @@
|
||||
// Footprints 自建同步服务(零依赖:node:http + node:sqlite)
|
||||
// Footprints 自建同步 + Web 服务(零依赖:node:http + node:sqlite)
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getDb } from './db.mjs';
|
||||
import { createAppCommands } from './app-commands.mjs';
|
||||
import { register, login, authUser } from './auth.mjs';
|
||||
import { push, pull } from './sync.mjs';
|
||||
import { upload, download } from './attachments.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT || 8788);
|
||||
const WWW_DIR = process.env.WWW_DIR || path.join(__dirname, '..', 'www');
|
||||
const USERS_DIR = path.join(process.env.DATA_DIR || path.join(__dirname, '..', 'data'), 'users');
|
||||
const REGISTER_ENABLED = (process.env.REGISTER_ENABLED ?? 'true') === 'true';
|
||||
const MAX_BODY = 160 * 1024 * 1024; // 160MB(附件 Base64 余量)
|
||||
const MAX_BODY = 160 * 1024 * 1024; // 160MB
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png',
|
||||
'.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp',
|
||||
'.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8'
|
||||
};
|
||||
|
||||
// 按用户惰性创建命令集(每个用户一个独立 SQLite 档案库)
|
||||
const userCommands = new Map();
|
||||
function commandsFor(userId) {
|
||||
let c = userCommands.get(userId);
|
||||
if (!c) {
|
||||
fs.mkdirSync(USERS_DIR, { recursive: true });
|
||||
c = createAppCommands(path.join(USERS_DIR, userId + '.db'));
|
||||
userCommands.set(userId, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
@@ -38,7 +64,7 @@ const server = http.createServer(async (req, res) => {
|
||||
return send(200, true, login(body));
|
||||
}
|
||||
|
||||
// ---- 管理(X-Admin-Token,设置 ADMIN_TOKEN 环境变量后启用)----
|
||||
// ---- 管理(X-Admin-Token)----
|
||||
if (req.method === 'GET' && route === '/admin/users') {
|
||||
const expect = process.env.ADMIN_TOKEN;
|
||||
if (!expect || req.headers['x-admin-token'] !== expect) {
|
||||
@@ -55,12 +81,17 @@ const server = http.createServer(async (req, res) => {
|
||||
return send(200, true, { total: users.length, users });
|
||||
}
|
||||
|
||||
// ---- 同步(需鉴权)----
|
||||
if (route.startsWith('/sync/') || route.startsWith('/auth/')) {
|
||||
// ---- 同步与 Web API(鉴权区) ----
|
||||
if (route.startsWith('/sync/') || route.startsWith('/api/') || route.startsWith('/auth/')) {
|
||||
let auth;
|
||||
try { auth = authUser(req); }
|
||||
catch (e) { return send(e.code || 401, false, e.message); }
|
||||
|
||||
if (req.method === 'GET' && route === '/auth/me') {
|
||||
const row = getDb().prepare('SELECT id, username FROM users WHERE id=?').get(auth.user_id);
|
||||
return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', device_id: auth.device_id });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/sync/push') {
|
||||
const body = await readJson(req);
|
||||
return send(200, true, push(auth.user_id, body.changes));
|
||||
@@ -78,8 +109,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!file) return send(404, false, '附件不存在');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': file.mime || 'application/octet-stream',
|
||||
'Content-Length': file.buf.length,
|
||||
'Content-Disposition': 'attachment; filename="blob"'
|
||||
'Content-Length': file.buf.length
|
||||
});
|
||||
return res.end(file.buf);
|
||||
}
|
||||
@@ -88,13 +118,37 @@ const server = http.createServer(async (req, res) => {
|
||||
const changes = db.prepare('SELECT COUNT(*) AS c FROM changes WHERE user_id=?').get(auth.user_id).c;
|
||||
const att = db.prepare('SELECT COUNT(*) AS c, COALESCE(SUM(size),0) AS bytes FROM attachments WHERE user_id=?').get(auth.user_id);
|
||||
return send(200, true, {
|
||||
user_id: auth.user_id,
|
||||
device_id: auth.device_id,
|
||||
total_changes: changes,
|
||||
attachments: att.c,
|
||||
attachment_bytes: att.bytes
|
||||
user_id: auth.user_id, device_id: auth.device_id,
|
||||
total_changes: changes, attachments: att.c, attachment_bytes: att.bytes
|
||||
});
|
||||
}
|
||||
|
||||
// Web 应用命令层:/api/<command>(与桌面端同一契约,按用户隔离的档案库)
|
||||
if (req.method === 'POST' && route.startsWith('/api/')) {
|
||||
const cmd = route.slice(5);
|
||||
if (!cmd || cmd.includes('/')) return send(404, false, 'not found');
|
||||
const body = await readJson(req);
|
||||
const data = commandsFor(auth.user_id).handleCommand(cmd, body);
|
||||
return send(200, true, data);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 静态前端(www/,指纹资源长缓存,index 禁缓存)----
|
||||
if (req.method === 'GET' && fs.existsSync(WWW_DIR)) {
|
||||
let p = route === '/' ? '/index.html' : decodeURIComponent(route);
|
||||
const abs = path.join(WWW_DIR, path.normalize(p).replace(/^([.][.][\\/])+/, ''));
|
||||
if (abs.startsWith(WWW_DIR) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream',
|
||||
'Cache-Control': p.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
|
||||
});
|
||||
return res.end(fs.readFileSync(abs));
|
||||
}
|
||||
const indexHtml = path.join(WWW_DIR, 'index.html');
|
||||
if (fs.existsSync(indexHtml)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
||||
return res.end(fs.readFileSync(indexHtml));
|
||||
}
|
||||
}
|
||||
|
||||
return send(404, false, 'not found');
|
||||
@@ -107,10 +161,10 @@ function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', c => {
|
||||
size += c.length;
|
||||
req.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) { reject(new Error('请求体过大')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
|
||||
@@ -123,5 +177,6 @@ function readJson(req) {
|
||||
getDb();
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log('[footprints-sync] listening on 0.0.0.0:' + PORT);
|
||||
console.log('[footprints-sync] data dir:', process.env.DATA_DIR || 'data');
|
||||
console.log('[footprints-sync] data dir:', process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
|
||||
console.log('[footprints-sync] web dir:', WWW_DIR, fs.existsSync(WWW_DIR) ? '(已托管)' : '(未部署前端)');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user