docs: update README files for clarity on deployment steps and features

This commit is contained in:
shuaiplus
2026-03-01 19:31:03 +08:00
committed by Shuai
parent f5a2523f91
commit 26447cd9b4
13 changed files with 164 additions and 100 deletions
+3
View File
@@ -41,6 +41,9 @@
// /api/devices/knowndevice probe budget per IP per minute.
// /api/devices/knowndevice 每 IP 每分钟探测配额。
knownDeviceRequestsPerMinute: 10,
// Public Send access budget per IP per minute.
// 公共 Send 访问接口每 IP 每分钟配额。
publicSendRequestsPerMinute: 60,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
+22 -4
View File
@@ -1,6 +1,7 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
@@ -449,6 +450,7 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string | undefined>;
try {
@@ -466,20 +468,35 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
const email = String(body.email || body.username || '').trim().toLowerCase();
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) {
return errorResponse(
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
429
);
}
if (!email || !masterPasswordHash || !recoveryCode) {
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
}
const user = await storage.getUser(email);
if (!user) return errorResponse('Invalid credentials', 400);
if (user.status !== 'active') return errorResponse('Account is disabled', 403);
if (!user || user.status !== 'active') {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash);
if (!validPassword) return errorResponse('Invalid credentials', 400);
if (!validPassword) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
return errorResponse('Recovery code is incorrect. Try again.', 400);
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
user.totpSecret = null;
@@ -488,6 +505,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey);
return jsonResponse({
success: true,
+11 -1
View File
@@ -105,6 +105,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request);
if (grantType === 'password') {
// Login with password
@@ -113,7 +114,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = getClientIdentifier(request);
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
@@ -266,6 +267,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return jsonResponse(response);
} else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`);
if (!sendAccessLimit.allowed) {
return identityErrorResponse(
`Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) {
return jsonResponse(
+18 -2
View File
@@ -322,6 +322,14 @@ function hasEmailAuth(send: Send): boolean {
return send.authType === SendAuthType.Email;
}
function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return { ok: false, response: errorResponse('Server configuration error', 500) };
}
return { ok: true, secret };
}
function extractBearerToken(request: Request): string | null {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return null;
@@ -1078,12 +1086,15 @@ export async function handleAccessSendFile(
// POST /api/sends/access (v2 bearer)
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const token = extractBearerToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
const claims = await verifySendAccessToken(token, env.JWT_SECRET);
const claims = await verifySendAccessToken(token, jwt.secret);
if (!claims) {
return errorResponse('Unauthorized', 401);
}
@@ -1196,6 +1207,11 @@ export async function issueSendAccessToken(
passwordHashB64?: string | null,
password?: string | null
): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) {
return { error: jwt.response };
}
const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
@@ -1260,7 +1276,7 @@ export async function issueSendAccessToken(
}
}
const token = await createSendAccessToken(send.id, env.JWT_SECRET);
const token = await createSendAccessToken(send.id, jwt.secret);
return { token };
}
+28 -6
View File
@@ -12,7 +12,6 @@ import { handleToken, handlePrelogin, handleRevocation } from './handlers/identi
import {
handleRegister,
handleGetProfile,
handleUpdateProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
@@ -214,6 +213,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
const clientId = getClientIdentifier(request);
async function enforcePublicSendRateLimit(): Promise<Response | null> {
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`);
if (check.allowed) return null;
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(check.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
});
}
// Handle CORS preflight
if (method === 'OPTIONS') {
@@ -272,23 +289,31 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Public Send access endpoints
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
if (blocked) return blocked;
const accessId = sendAccessMatch[1];
return handleAccessSend(request, env, accessId);
}
const sendAccessV2Match = path === '/api/sends/access';
if (sendAccessV2Match && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
if (blocked) return blocked;
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
if (sendAccessFileV2Match && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
if (blocked) return blocked;
const fileId = sendAccessFileV2Match[1];
return handleAccessSendFileV2(request, env, fileId);
}
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
if (sendAccessFileMatch && method === 'POST') {
const blocked = await enforcePublicSendRateLimit();
if (blocked) return blocked;
const idOrAccessId = sendAccessFileMatch[1];
const fileId = sendAccessFileMatch[2];
return handleAccessSendFile(request, env, idOrAccessId, fileId);
@@ -309,8 +334,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Known device check (no auth required)
if (path === '/api/devices/knowndevice' && method === 'GET') {
const rateLimit = new RateLimitService(env.DB);
const clientIp = getClientIdentifier(request);
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientIp + ':known-device');
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientId + ':known-device');
if (!probeLimit.allowed) {
// Keep compatibility simple: do not error, just answer "unknown device".
return jsonResponse(false);
@@ -417,8 +441,6 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
}
const clientId = getClientIdentifier(request);
// Dedicated read rate limiting for heavy sync endpoint.
if (path === '/api/sync' && method === 'GET') {
const rateLimit = new RateLimitService(env.DB);
@@ -476,7 +498,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Account endpoints
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
+11
View File
@@ -17,6 +17,8 @@ const CONFIG = {
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
// Dedicated budget for GET /api/devices/knowndevice probes.
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
// Dedicated budget for unauthenticated public Send access endpoints.
PUBLIC_SEND_REQUESTS_PER_MINUTE: LIMITS.rateLimit.publicSendRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
@@ -233,6 +235,15 @@ export class RateLimitService {
CONFIG.API_WINDOW_SECONDS
);
}
// Budget for unauthenticated public Send access endpoints.
async consumePublicSendAccessBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
}
export function getClientIdentifier(request: Request): string {