feat: add PublicSendPage and SendsPage components for managing sends

This commit is contained in:
shuaiplus
2026-03-01 05:55:42 +08:00
committed by Shuai
parent be3b68956b
commit bb50617b16
16 changed files with 2792 additions and 28 deletions
+11
View File
@@ -15,6 +15,9 @@
// Attachment download token lifetime in seconds.
// 附件下载令牌有效期(秒)。
fileDownloadTokenTtlSeconds: 300,
// Send access token lifetime in seconds.
// Send 访问令牌有效期(秒)。
sendAccessTokenTtlSeconds: 300,
// Minimum required JWT secret length.
// JWT 密钥最小长度要求。
jwtSecretMinLength: 32,
@@ -73,6 +76,14 @@
// 附件上传大小上限(字节)。
maxFileSizeBytes: 100 * 1024 * 1024,
},
send: {
// Max file size allowed for Send file uploads.
// Send 文件上传大小上限。
maxFileSizeBytes: 550_502_400,
// Max days allowed between now and deletion date.
// 允许的最远删除日期(距当前天数)。
maxDeletionDays: 31,
},
pagination: {
// Default page size when client does not specify pageSize.
// 客户端未传 pageSize 时的默认分页大小。
+35
View File
@@ -7,6 +7,7 @@ import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device';
import { issueSendAccessToken } from './sends';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -247,6 +248,40 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return jsonResponse(response);
} else if (grantType === 'send_access') {
const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) {
return jsonResponse(
{
error: 'invalid_request',
error_description: 'send_id is required',
send_access_error_type: 'invalid_send_id',
ErrorModel: {
Message: 'send_id is required',
Object: 'error',
},
},
400
);
}
const passwordHashB64 = String(
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
).trim() || null;
const password = String(body.password || '').trim() || null;
const result = await issueSendAccessToken(env, sendId, passwordHashB64, password);
if ('error' in result) {
return result.error;
}
return jsonResponse({
access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer',
scope: 'api.send',
unofficialServer: true,
});
} else if (grantType === 'refresh_token') {
// Refresh token
const refreshToken = body.refresh_token;
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -2,6 +2,7 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } fr
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
@@ -61,6 +62,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const sends = await storage.getAllSends(userId);
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
// Build profile response
@@ -116,7 +118,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'domains',
},
policies: [],
sends: [],
sends: sends.map(sendToResponse),
// PascalCase for desktop/browser clients
UserDecryptionOptions: {
HasMasterPassword: true,
+86 -4
View File
@@ -44,6 +44,25 @@ import {
handleDeleteFolder
} from './handlers/folders';
// Send handlers
import {
handleGetSends,
handleGetSend,
handleCreateSend,
handleCreateFileSendV2,
handleGetSendFileUpload,
handleUploadSendFile,
handleUpdateSend,
handleDeleteSend,
handleRemoveSendPassword,
handleRemoveSendAuth,
handleAccessSend,
handleAccessSendFile,
handleAccessSendV2,
handleAccessSendFileV2,
handleDownloadSendFile,
} from './handlers/sends';
// Sync handler
import { handleSync } from './handlers/sync';
@@ -229,6 +248,38 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
}
// Public Send access endpoints
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const accessId = sendAccessMatch[1];
return handleAccessSend(request, env, accessId);
}
const sendAccessV2Match = path === '/api/sends/access';
if (sendAccessV2Match && method === 'POST') {
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
if (sendAccessFileV2Match && method === 'POST') {
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 idOrAccessId = sendAccessFileMatch[1];
const fileId = sendAccessFileMatch[2];
return handleAccessSendFile(request, env, idOrAccessId, fileId);
}
const sendDownloadMatch = path.match(/^\/api\/sends\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
if (sendDownloadMatch && method === 'GET') {
const sendId = sendDownloadMatch[1];
const fileId = sendDownloadMatch[2];
return handleDownloadSendFile(request, env, sendId, fileId);
}
// Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) {
return new Response(null, { status: 200 });
@@ -295,6 +346,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'unauth-ui-refresh': true,
},
object: 'config',
@@ -547,10 +599,40 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
}
// Sends endpoint (stub - not implemented)
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
// Send endpoints
if (path === '/api/sends') {
if (method === 'GET') return handleGetSends(request, env, userId);
if (method === 'POST') return handleCreateSend(request, env, userId);
}
if (path === '/api/sends/file/v2' && method === 'POST') {
return handleCreateFileSendV2(request, env, userId);
}
const sendMatch = path.match(/^\/api\/sends\/([a-f0-9-]+)(\/.*)?$/i);
if (sendMatch) {
const sendId = sendMatch[1];
const subPath = sendMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
}
if (subPath === '/remove-password' && method === 'PUT') {
return handleRemoveSendPassword(request, env, userId, sendId);
}
if (subPath === '/remove-auth' && method === 'PUT') {
return handleRemoveSendAuth(request, env, userId, sendId);
}
const sendFileUploadMatch = subPath.match(/^\/file\/([a-f0-9-]+)$/i);
if (sendFileUploadMatch) {
const fileId = sendFileUploadMatch[1];
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
if (method === 'POST') return handleUploadSendFile(request, env, userId, sendId, fileId);
}
}
+110 -1
View File
@@ -1,4 +1,4 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog } from '../types';
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType } from '../types';
import { LIMITS } from '../config/limits';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -39,6 +39,17 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
'CREATE TABLE IF NOT EXISTS sends (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -768,6 +779,104 @@ export class StorageService {
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
}
// --- Sends ---
private mapSendRow(row: any): Send {
return {
id: row.id,
userId: row.user_id,
type: row.type,
name: row.name,
notes: row.notes,
data: row.data,
key: row.key,
passwordHash: row.password_hash,
passwordSalt: row.password_salt,
passwordIterations: row.password_iterations,
authType: row.auth_type ?? SendAuthType.None,
emails: row.emails ?? null,
maxAccessCount: row.max_access_count,
accessCount: row.access_count,
disabled: !!row.disabled,
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
createdAt: row.created_at,
updatedAt: row.updated_at,
expirationDate: row.expiration_date,
deletionDate: row.deletion_date,
};
}
async getSend(id: string): Promise<Send | null> {
const row = await this.db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
)
.bind(id)
.first<any>();
if (!row) return null;
return this.mapSendRow(row);
}
async saveSend(send: Send): Promise<void> {
const stmt = this.db.prepare(
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
);
await this.safeBind(
stmt,
send.id,
send.userId,
Number(send.type) || 0,
send.name,
send.notes,
send.data,
send.key,
send.passwordHash,
send.passwordSalt,
send.passwordIterations,
send.authType,
send.emails,
send.maxAccessCount,
send.accessCount,
send.disabled ? 1 : 0,
send.hideEmail === null || send.hideEmail === undefined ? null : (send.hideEmail ? 1 : 0),
send.createdAt,
send.updatedAt,
send.expirationDate,
send.deletionDate
).run();
}
async deleteSend(id: string, userId: string): Promise<void> {
await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async getAllSends(userId: string): Promise<Send[]> {
const res = await this.db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map(row => this.mapSendRow(row));
}
async getSendsPage(userId: string, limit: number, offset: number): Promise<Send[]> {
const res = await this.db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map(row => this.mapSendRow(row));
}
async deleteRefreshTokensByUserId(userId: string): Promise<void> {
await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
}
+57 -1
View File
@@ -183,6 +183,62 @@ export interface Device {
updatedAt: string;
}
export enum SendType {
Text = 0,
File = 1,
}
export enum SendAuthType {
Email = 0,
Password = 1,
None = 2,
}
export interface Send {
id: string;
userId: string;
type: SendType;
name: string;
notes: string | null;
data: string;
key: string;
passwordHash: string | null;
passwordSalt: string | null;
passwordIterations: number | null;
authType: SendAuthType;
emails: string | null;
maxAccessCount: number | null;
accessCount: number;
disabled: boolean;
hideEmail: boolean | null;
createdAt: string;
updatedAt: string;
expirationDate: string | null;
deletionDate: string;
}
export interface SendResponse {
id: string;
accessId: string;
type: number;
name: string;
notes: string | null;
text: any | null;
file: any | null;
key: string;
maxAccessCount: number | null;
accessCount: number;
password: string | null;
emails: string | null;
authType: SendAuthType;
disabled: boolean;
hideEmail: boolean | null;
revisionDate: string;
expirationDate: string | null;
deletionDate: string;
object: string;
}
// JWT Payload
export interface JWTPayload {
sub: string; // user id
@@ -318,7 +374,7 @@ export interface SyncResponse {
ciphers: CipherResponse[];
domains: any;
policies: any[];
sends: any[];
sends: SendResponse[];
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+137
View File
@@ -177,3 +177,140 @@ export async function verifyFileDownloadToken(
return null;
}
}
export interface SendFileDownloadClaims {
sendId: string;
fileId: string;
exp: number;
}
export async function createSendFileDownloadToken(
sendId: string,
fileId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendFileDownloadClaims = {
sendId,
fileId,
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendFileDownloadToken(
token: string,
secret: string
): Promise<SendFileDownloadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
export interface SendAccessTokenClaims {
sub: string; // send id
typ: 'send_access';
iat: number;
exp: number;
}
export async function createSendAccessToken(sendId: string, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendAccessTokenClaims = {
sub: sendId,
typ: 'send_access',
iat: now,
exp: now + LIMITS.auth.sendAccessTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendAccessToken(token: string, secret: string): Promise<SendAccessTokenClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (payload.typ !== 'send_access') return null;
if (!payload.sub) return null;
return payload;
} catch {
return null;
}
}