mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add PublicSendPage and SendsPage components for managing sends
This commit is contained in:
@@ -76,6 +76,32 @@ CREATE TABLE IF NOT EXISTS attachments (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
|
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);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
// Attachment download token lifetime in seconds.
|
// Attachment download token lifetime in seconds.
|
||||||
// 附件下载令牌有效期(秒)。
|
// 附件下载令牌有效期(秒)。
|
||||||
fileDownloadTokenTtlSeconds: 300,
|
fileDownloadTokenTtlSeconds: 300,
|
||||||
|
// Send access token lifetime in seconds.
|
||||||
|
// Send 访问令牌有效期(秒)。
|
||||||
|
sendAccessTokenTtlSeconds: 300,
|
||||||
// Minimum required JWT secret length.
|
// Minimum required JWT secret length.
|
||||||
// JWT 密钥最小长度要求。
|
// JWT 密钥最小长度要求。
|
||||||
jwtSecretMinLength: 32,
|
jwtSecretMinLength: 32,
|
||||||
@@ -73,6 +76,14 @@
|
|||||||
// 附件上传大小上限(字节)。
|
// 附件上传大小上限(字节)。
|
||||||
maxFileSizeBytes: 100 * 1024 * 1024,
|
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: {
|
pagination: {
|
||||||
// Default page size when client does not specify pageSize.
|
// Default page size when client does not specify pageSize.
|
||||||
// 客户端未传 pageSize 时的默认分页大小。
|
// 客户端未传 pageSize 时的默认分页大小。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
import { createRefreshToken } from '../utils/jwt';
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
|
import { issueSendAccessToken } from './sends';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
@@ -247,6 +248,40 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
return jsonResponse(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') {
|
} else if (grantType === 'refresh_token') {
|
||||||
// Refresh token
|
// Refresh token
|
||||||
const refreshToken = body.refresh_token;
|
const refreshToken = body.refresh_token;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } fr
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled } from '../utils/totp';
|
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 ciphers = await storage.getAllCiphers(userId);
|
||||||
const folders = await storage.getAllFolders(userId);
|
const folders = await storage.getAllFolders(userId);
|
||||||
|
const sends = await storage.getAllSends(userId);
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||||
|
|
||||||
// Build profile response
|
// Build profile response
|
||||||
@@ -116,7 +118,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: [],
|
sends: sends.map(sendToResponse),
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: {
|
||||||
HasMasterPassword: true,
|
HasMasterPassword: true,
|
||||||
|
|||||||
+86
-4
@@ -44,6 +44,25 @@ import {
|
|||||||
handleDeleteFolder
|
handleDeleteFolder
|
||||||
} from './handlers/folders';
|
} 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
|
// Sync handler
|
||||||
import { handleSync } from './handlers/sync';
|
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);
|
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)
|
// Notifications hub (stub - no auth required, return 200 for connection)
|
||||||
if (path.startsWith('/notifications/')) {
|
if (path.startsWith('/notifications/')) {
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
@@ -295,6 +346,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
featureStates: {
|
featureStates: {
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
|
'pm-19051-send-email-verification': false,
|
||||||
'unauth-ui-refresh': true,
|
'unauth-ui-refresh': true,
|
||||||
},
|
},
|
||||||
object: 'config',
|
object: 'config',
|
||||||
@@ -547,10 +599,40 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sends endpoint (stub - not implemented)
|
// Send endpoints
|
||||||
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
|
if (path === '/api/sends') {
|
||||||
if (method === 'GET') {
|
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
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
@@ -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';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
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)',
|
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
'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 (' +
|
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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();
|
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> {
|
async deleteRefreshTokensByUserId(userId: string): Promise<void> {
|
||||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-1
@@ -183,6 +183,62 @@ export interface Device {
|
|||||||
updatedAt: string;
|
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
|
// JWT Payload
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
sub: string; // user id
|
sub: string; // user id
|
||||||
@@ -318,7 +374,7 @@ export interface SyncResponse {
|
|||||||
ciphers: CipherResponse[];
|
ciphers: CipherResponse[];
|
||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
sends: any[];
|
sends: SendResponse[];
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
UserDecryptionOptions: UserDecryptionOptions | null;
|
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
|
|||||||
@@ -177,3 +177,140 @@ export async function verifyFileDownloadToken(
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+130
-5
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { CircleHelp, LogOut, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
|
import { CircleHelp, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
import VaultPage from '@/components/VaultPage';
|
import VaultPage from '@/components/VaultPage';
|
||||||
|
import SendsPage from '@/components/SendsPage';
|
||||||
|
import PublicSendPage from '@/components/PublicSendPage';
|
||||||
import SettingsPage from '@/components/SettingsPage';
|
import SettingsPage from '@/components/SettingsPage';
|
||||||
import AdminPage from '@/components/AdminPage';
|
import AdminPage from '@/components/AdminPage';
|
||||||
import HelpPage from '@/components/HelpPage';
|
import HelpPage from '@/components/HelpPage';
|
||||||
@@ -15,8 +17,10 @@ import {
|
|||||||
createCipher,
|
createCipher,
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
createInvite,
|
createInvite,
|
||||||
|
createSend,
|
||||||
deleteAllInvites,
|
deleteAllInvites,
|
||||||
deleteCipher,
|
deleteCipher,
|
||||||
|
deleteSend,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
bulkMoveCiphers,
|
bulkMoveCiphers,
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
getFolders,
|
getFolders,
|
||||||
getProfile,
|
getProfile,
|
||||||
getSetupStatus,
|
getSetupStatus,
|
||||||
|
getSends,
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
getWebConfig,
|
getWebConfig,
|
||||||
listAdminInvites,
|
listAdminInvites,
|
||||||
@@ -36,12 +41,14 @@ import {
|
|||||||
setTotp,
|
setTotp,
|
||||||
setUserStatus,
|
setUserStatus,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
|
updateSend,
|
||||||
|
buildSendShareKey,
|
||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
verifyMasterPassword,
|
verifyMasterPassword,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
||||||
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||||
|
|
||||||
interface PendingTotp {
|
interface PendingTotp {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -83,6 +90,7 @@ export default function App() {
|
|||||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
||||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||||
|
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||||
|
|
||||||
function setSession(next: SessionState | null) {
|
function setSession(next: SessionState | null) {
|
||||||
setSessionState(next);
|
setSessionState(next);
|
||||||
@@ -302,6 +310,11 @@ export default function App() {
|
|||||||
queryFn: () => getFolders(authedFetch),
|
queryFn: () => getFolders(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
||||||
});
|
});
|
||||||
|
const sendsQuery = useQuery({
|
||||||
|
queryKey: ['sends', session?.accessToken],
|
||||||
|
queryFn: () => getSends(authedFetch),
|
||||||
|
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
||||||
|
});
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ['admin-users', session?.accessToken],
|
queryKey: ['admin-users', session?.accessToken],
|
||||||
queryFn: () => listAdminUsers(authedFetch),
|
queryFn: () => listAdminUsers(authedFetch),
|
||||||
@@ -322,9 +335,10 @@ export default function App() {
|
|||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
setDecryptedFolders([]);
|
setDecryptedFolders([]);
|
||||||
setDecryptedCiphers([]);
|
setDecryptedCiphers([]);
|
||||||
|
setDecryptedSends([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!foldersQuery.data || !ciphersQuery.data) return;
|
if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return;
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -440,9 +454,36 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sends = await Promise.all(
|
||||||
|
sendsQuery.data.map(async (send) => {
|
||||||
|
const nextSend: Send = { ...send };
|
||||||
|
try {
|
||||||
|
if (send.key) {
|
||||||
|
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
|
||||||
|
const sendEnc = sendKeyRaw.slice(0, 32);
|
||||||
|
const sendMac = sendKeyRaw.slice(32, 64);
|
||||||
|
nextSend.decName = await decryptField(send.name || '', sendEnc, sendMac);
|
||||||
|
nextSend.decNotes = await decryptField(send.notes || '', sendEnc, sendMac);
|
||||||
|
nextSend.decText = await decryptField(send.text?.text || '', sendEnc, sendMac);
|
||||||
|
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
|
||||||
|
nextSend.decShareKey = shareKey;
|
||||||
|
nextSend.shareUrl = `${window.location.origin}/send/${send.accessId}/${shareKey}`;
|
||||||
|
} else {
|
||||||
|
nextSend.decName = '';
|
||||||
|
nextSend.decNotes = '';
|
||||||
|
nextSend.decText = '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
nextSend.decName = '(Decrypt failed)';
|
||||||
|
}
|
||||||
|
return nextSend;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDecryptedFolders(folders);
|
setDecryptedFolders(folders);
|
||||||
setDecryptedCiphers(ciphers);
|
setDecryptedCiphers(ciphers);
|
||||||
|
setDecryptedSends(sends);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
|
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
|
||||||
@@ -452,7 +493,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
|
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
|
||||||
|
|
||||||
async function saveProfileAction(name: string, email: string) {
|
async function saveProfileAction(name: string, email: string) {
|
||||||
try {
|
try {
|
||||||
@@ -526,7 +567,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshVault() {
|
async function refreshVault() {
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
|
||||||
pushToast('success', 'Vault synced');
|
pushToast('success', 'Vault synced');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +630,64 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createSendItem(draft: SendDraft, autoCopyLink: boolean) {
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
const created = await createSend(authedFetch, session, draft);
|
||||||
|
await sendsQuery.refetch();
|
||||||
|
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
||||||
|
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
|
||||||
|
const shareUrl = `${window.location.origin}/send/${created.accessId}/${keyPart}`;
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
pushToast('success', 'Send created');
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : 'Create send failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSendItem(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateSend(authedFetch, session, send, draft);
|
||||||
|
await sendsQuery.refetch();
|
||||||
|
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
||||||
|
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
|
||||||
|
const shareUrl = `${window.location.origin}/send/${updated.accessId}/${keyPart}`;
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
pushToast('success', 'Send updated');
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : 'Update send failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSendItem(send: Send) {
|
||||||
|
try {
|
||||||
|
await deleteSend(authedFetch, send.id);
|
||||||
|
await sendsQuery.refetch();
|
||||||
|
pushToast('success', 'Send deleted');
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : 'Delete send failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDeleteSendItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
for (const id of ids) {
|
||||||
|
await deleteSend(authedFetch, id);
|
||||||
|
}
|
||||||
|
await sendsQuery.refetch();
|
||||||
|
pushToast('success', 'Deleted selected sends');
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : 'Bulk delete sends failed');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function verifyMasterPasswordAction(email: string, password: string) {
|
async function verifyMasterPasswordAction(email: string, password: string) {
|
||||||
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
|
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
|
||||||
await verifyMasterPassword(authedFetch, derived.hash);
|
await verifyMasterPassword(authedFetch, derived.hash);
|
||||||
@@ -614,6 +713,16 @@ export default function App() {
|
|||||||
if (phase === 'app' && location === '/') navigate('/vault');
|
if (phase === 'app' && location === '/') navigate('/vault');
|
||||||
}, [phase, location, navigate]);
|
}, [phase, location, navigate]);
|
||||||
|
|
||||||
|
const publicSendMatch = location.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
||||||
|
if (publicSendMatch) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PublicSendPage accessId={decodeURIComponent(publicSendMatch[1])} keyPart={publicSendMatch[2] ? decodeURIComponent(publicSendMatch[2]) : null} />
|
||||||
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (phase === 'loading') {
|
if (phase === 'loading') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -695,6 +804,10 @@ export default function App() {
|
|||||||
<Vault size={16} />
|
<Vault size={16} />
|
||||||
<span>My Vault</span>
|
<span>My Vault</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/sends" className={`side-link ${location === '/sends' ? 'active' : ''}`}>
|
||||||
|
<SendIcon size={16} />
|
||||||
|
<span>Sends</span>
|
||||||
|
</Link>
|
||||||
{profile?.role === 'admin' && (
|
{profile?.role === 'admin' && (
|
||||||
<Link href="/admin" className={`side-link ${location === '/admin' ? 'active' : ''}`}>
|
<Link href="/admin" className={`side-link ${location === '/admin' ? 'active' : ''}`}>
|
||||||
<ShieldUser size={16} />
|
<ShieldUser size={16} />
|
||||||
@@ -712,6 +825,18 @@ export default function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/sends">
|
||||||
|
<SendsPage
|
||||||
|
sends={decryptedSends}
|
||||||
|
loading={sendsQuery.isFetching}
|
||||||
|
onRefresh={refreshVault}
|
||||||
|
onCreate={createSendItem}
|
||||||
|
onUpdate={updateSendItem}
|
||||||
|
onDelete={deleteSendItem}
|
||||||
|
onBulkDelete={bulkDeleteSendItems}
|
||||||
|
onNotify={pushToast}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route path="/vault">
|
<Route path="/vault">
|
||||||
<VaultPage
|
<VaultPage
|
||||||
ciphers={decryptedCiphers}
|
ciphers={decryptedCiphers}
|
||||||
|
|||||||
@@ -77,18 +77,20 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="invite-toolbar">
|
<div className="invite-toolbar">
|
||||||
<div className="actions">
|
<div className="actions invite-create-group">
|
||||||
<input
|
<label className="field invite-hours-field">
|
||||||
className="input small"
|
<span>邀请码有效时长(小时)</span>
|
||||||
type="number"
|
<input
|
||||||
value={inviteHours}
|
className="input small"
|
||||||
min={1}
|
type="number"
|
||||||
max={720}
|
value={inviteHours}
|
||||||
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
|
min={1}
|
||||||
/>
|
max={720}
|
||||||
<span className="muted-inline">hours</span>
|
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
|
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
|
||||||
Create Invite
|
创建时效邀请码
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend } from '@/lib/api';
|
||||||
|
|
||||||
|
interface PublicSendPageProps {
|
||||||
|
accessId: string;
|
||||||
|
keyPart: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicSendPage(props: PublicSendPageProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [needPassword, setNeedPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [sendData, setSendData] = useState<any>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function loadSend(pass?: string): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await accessPublicSend(props.accessId, pass);
|
||||||
|
if (!props.keyPart) {
|
||||||
|
setError('This link is missing decryption key.');
|
||||||
|
setSendData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decrypted = await decryptPublicSend(data, props.keyPart);
|
||||||
|
setSendData(decrypted);
|
||||||
|
setNeedPassword(false);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
if (err.status === 401) {
|
||||||
|
setNeedPassword(true);
|
||||||
|
setError('This send is password protected.');
|
||||||
|
} else {
|
||||||
|
setError(err.message || 'Failed to open send');
|
||||||
|
}
|
||||||
|
setSendData(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(): Promise<void> {
|
||||||
|
if (!sendData?.id || !sendData?.file?.id) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const url = await accessPublicSendFile(sendData.id, sendData.file.id, password || undefined);
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error('Download failed');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const obj = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = obj;
|
||||||
|
a.download = sendData.decFileName || sendData.file?.fileName || 'send-file';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(obj);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error;
|
||||||
|
setError(err.message || 'Download failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSend();
|
||||||
|
}, [props.accessId, props.keyPart]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page public-send-page">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1>NodeWarden Send</h1>
|
||||||
|
{loading && <p className="muted">Loading...</p>}
|
||||||
|
|
||||||
|
{!loading && needPassword && (
|
||||||
|
<>
|
||||||
|
<label className="field">
|
||||||
|
<span>Password</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
|
||||||
|
<Lock size={14} className="btn-icon" /> Unlock Send
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && sendData && (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginTop: '8px' }}>{sendData.decName || '(No Name)'}</h2>
|
||||||
|
{sendData.type === 0 ? (
|
||||||
|
<div className="card" style={{ marginTop: '10px' }}>
|
||||||
|
<div className="notes">{sendData.decText || ''}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ marginTop: '10px' }}>
|
||||||
|
<div className="kv-line">
|
||||||
|
<span>File</span>
|
||||||
|
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || 'Encrypted File'}</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
|
||||||
|
<Download size={14} className="btn-icon" /> Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!sendData.expirationDate && <p className="muted">Expires at: {sendData.expirationDate}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !sendData && !needPassword && !error && (
|
||||||
|
<p className="muted">
|
||||||
|
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> Send unavailable.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!!error && <p className="local-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
|
||||||
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
|
|
||||||
|
interface SendsPageProps {
|
||||||
|
sends: Send[];
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onDelete: (send: Send) => Promise<void>;
|
||||||
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||||
|
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||||
|
|
||||||
|
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||||
|
if (!iso) return String(fallback);
|
||||||
|
const d = new Date(iso).getTime();
|
||||||
|
if (!Number.isFinite(d)) return String(fallback);
|
||||||
|
const diff = d - Date.now();
|
||||||
|
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
|
||||||
|
return String(Math.max(days, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultDraft(): SendDraft {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
name: '',
|
||||||
|
notes: '',
|
||||||
|
text: '',
|
||||||
|
file: null,
|
||||||
|
deletionDays: '7',
|
||||||
|
expirationDays: '0',
|
||||||
|
maxAccessCount: '',
|
||||||
|
password: '',
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromSend(send: Send): SendDraft {
|
||||||
|
return {
|
||||||
|
id: send.id,
|
||||||
|
type: Number(send.type) === 1 ? 'file' : 'text',
|
||||||
|
name: send.decName || '',
|
||||||
|
notes: send.decNotes || '',
|
||||||
|
text: send.decText || '',
|
||||||
|
file: null,
|
||||||
|
deletionDays: daysFromNow(send.deletionDate, 7),
|
||||||
|
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||||
|
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||||
|
password: '',
|
||||||
|
disabled: !!send.disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SendsPage(props: SendsPageProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}, [autoCopyLink]);
|
||||||
|
|
||||||
|
const filteredSends = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return props.sends.filter((send) => {
|
||||||
|
if (typeFilter === 'text' && Number(send.type) !== 0) return false;
|
||||||
|
if (typeFilter === 'file' && Number(send.type) !== 1) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
const name = (send.decName || '').toLowerCase();
|
||||||
|
const text = (send.decText || '').toLowerCase();
|
||||||
|
return name.includes(q) || text.includes(q);
|
||||||
|
});
|
||||||
|
}, [props.sends, search, typeFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filteredSends.length) {
|
||||||
|
setSelectedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) {
|
||||||
|
setSelectedId(filteredSends[0].id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
}, [filteredSends, selectedId]);
|
||||||
|
|
||||||
|
const selectedSend = useMemo(
|
||||||
|
() => props.sends.find((x) => x.id === selectedId) || null,
|
||||||
|
[props.sends, selectedId]
|
||||||
|
);
|
||||||
|
const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]);
|
||||||
|
const selectedCount = selectedIds.length;
|
||||||
|
|
||||||
|
async function saveDraft(): Promise<void> {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
props.onNotify('error', 'Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.type === 'text' && !draft.text.trim()) {
|
||||||
|
props.onNotify('error', 'Text is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.type === 'file' && isCreating && !draft.file) {
|
||||||
|
props.onNotify('error', 'Please select a file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
await props.onCreate(draft, autoCopyLink);
|
||||||
|
setSelectedId(null);
|
||||||
|
} else if (selectedSend) {
|
||||||
|
await props.onUpdate(selectedSend, draft, autoCopyLink);
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSend(send: Send): Promise<void> {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onDelete(send);
|
||||||
|
if (selectedId === send.id) setSelectedId(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
setDraft(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSelected(): Promise<void> {
|
||||||
|
if (!selectedCount) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onBulkDelete(selectedIds);
|
||||||
|
setSelectedMap({});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAccessUrl(send: Send): void {
|
||||||
|
const url = send.shareUrl || `${window.location.origin}/send/${send.accessId}`;
|
||||||
|
void navigator.clipboard.writeText(url);
|
||||||
|
props.onNotify('success', 'Link copied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vault-grid">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title">All Sends</div>
|
||||||
|
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||||
|
<LayoutGrid size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label">All Sends</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title">Type</div>
|
||||||
|
<button type="button" className={`tree-btn ${typeFilter === 'text' ? 'active' : ''}`} onClick={() => setTypeFilter('text')}>
|
||||||
|
<FileText size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label">Text</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${typeFilter === 'file' ? 'active' : ''}`} onClick={() => setTypeFilter('file')}>
|
||||||
|
<File size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label">File</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="list-col">
|
||||||
|
<div className="list-head">
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Search sends..."
|
||||||
|
value={search}
|
||||||
|
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar actions">
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => void removeSelected()}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> Delete Selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={!filteredSends.length}
|
||||||
|
onClick={() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
for (const send of filteredSends) map[send.id] = true;
|
||||||
|
setSelectedMap(map);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
{!!selectedCount && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setIsEditing(true);
|
||||||
|
setDraft(buildDefaultDraft());
|
||||||
|
setShowPassword(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="btn-icon" /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="list-panel">
|
||||||
|
{filteredSends.map((send) => (
|
||||||
|
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="row-check"
|
||||||
|
checked={!!selectedMap[send.id]}
|
||||||
|
onInput={(e) =>
|
||||||
|
setSelectedMap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[send.id]: (e.currentTarget as HTMLInputElement).checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="row-main"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(send.id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<span className="list-icon-fallback">
|
||||||
|
<SendIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="list-text">
|
||||||
|
<span className="list-title" title={send.decName || '(No Name)'}>{send.decName || '(No Name)'}</span>
|
||||||
|
<span className="list-sub">
|
||||||
|
{Number(send.type) === 1 ? 'File' : 'Text'} - Accessed {send.accessCount || 0} times
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!filteredSends.length && <div className="empty">No sends</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="detail-col">
|
||||||
|
{isEditing && draft && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="detail-title">{isCreating ? 'New Send' : 'Edit Send'}</h3>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>Name</span>
|
||||||
|
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>Type</span>
|
||||||
|
<div className="send-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={draft.type === 'file'}
|
||||||
|
disabled={!isCreating}
|
||||||
|
onInput={() => setDraft({ ...draft, type: 'file' })}
|
||||||
|
/>
|
||||||
|
File
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={draft.type === 'text'}
|
||||||
|
disabled={!isCreating}
|
||||||
|
onInput={() => setDraft({ ...draft, type: 'text' })}
|
||||||
|
/>
|
||||||
|
Text
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{draft.type === 'file' ? (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>File</span>
|
||||||
|
<input className="input" type="file" onInput={(e) => setDraft({ ...draft, file: (e.currentTarget as HTMLInputElement).files?.[0] || null })} />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>Text</span>
|
||||||
|
<textarea className="input textarea" rows={8} value={draft.text} onInput={(e) => setDraft({ ...draft, text: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="field">
|
||||||
|
<span>Deletion Days</span>
|
||||||
|
<input className="input" type="number" min="1" max="31" value={draft.deletionDays} onInput={(e) => setDraft({ ...draft, deletionDays: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Expiration Days (0 = never)</span>
|
||||||
|
<input className="input" type="number" min="0" max="3650" value={draft.expirationDays} onInput={(e) => setDraft({ ...draft, expirationDays: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Max Access Count</span>
|
||||||
|
<input className="input" value={draft.maxAccessCount} onInput={(e) => setDraft({ ...draft, maxAccessCount: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Password</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>Notes</span>
|
||||||
|
<textarea className="input textarea" rows={5} value={draft.notes} onInput={(e) => setDraft({ ...draft, notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>Options</span>
|
||||||
|
<div className="send-options">
|
||||||
|
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> Disable this send</label>
|
||||||
|
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> Auto copy link after save</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="detail-actions">
|
||||||
|
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>Save</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEditing && selectedSend && (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="detail-title">{selectedSend.decName || '(No Name)'}</h3>
|
||||||
|
<div className="detail-sub">{Number(selectedSend.type) === 1 ? 'File Send' : 'Text Send'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h4>Send Details</h4>
|
||||||
|
<div className="kv-line"><span>Access Count</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
|
<div className="kv-line"><span>Deletion Date</span><strong>{selectedSend.deletionDate || '-'}</strong></div>
|
||||||
|
<div className="kv-line"><span>Expiration Date</span><strong>{selectedSend.expirationDate || '-'}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{Number(selectedSend.type) === 1 ? (
|
||||||
|
<>
|
||||||
|
<h4>File</h4>
|
||||||
|
<div className="kv-line"><span>File Name</span><strong>{selectedSend.file?.fileName || 'Encrypted file'}</strong></div>
|
||||||
|
<div className="kv-line"><span>File Size</span><strong>{selectedSend.file?.sizeName || '-'}</strong></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h4>Text</h4>
|
||||||
|
<div className="notes">{selectedSend.decText || ''}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-actions">
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
||||||
|
<Copy size={14} className="btn-icon" /> Copy Link
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
|
||||||
|
<Pencil size={14} className="btn-icon" /> Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-danger small detail-delete-btn" disabled={busy} onClick={() => void removeSend(selectedSend)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+271
-1
@@ -1,4 +1,4 @@
|
|||||||
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
|
import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
|
||||||
import type {
|
import type {
|
||||||
AdminInvite,
|
AdminInvite,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
ListResponse,
|
ListResponse,
|
||||||
Profile,
|
Profile,
|
||||||
SessionState,
|
SessionState,
|
||||||
|
Send,
|
||||||
|
SendDraft,
|
||||||
SetupStatusResponse,
|
SetupStatusResponse,
|
||||||
TokenError,
|
TokenError,
|
||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
@@ -255,6 +257,13 @@ export async function getCiphers(authedFetch: (input: string, init?: RequestInit
|
|||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
|
||||||
|
const resp = await authedFetch('/api/sends');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load sends');
|
||||||
|
const body = await parseJson<ListResponse<Send>>(resp);
|
||||||
|
return body?.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateProfile(
|
export async function updateProfile(
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
payload: { name: string; email: string }
|
payload: { name: string; email: string }
|
||||||
@@ -637,3 +646,264 @@ export async function bulkMoveCiphers(
|
|||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Bulk move failed');
|
if (!resp.ok) throw new Error('Bulk move failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
if (required) throw new Error('Deletion days is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
if (required) throw new Error('Invalid deletion days');
|
||||||
|
throw new Error('Invalid expiration days');
|
||||||
|
}
|
||||||
|
if (!required && n === 0) return null;
|
||||||
|
const date = new Date(Date.now() + Math.floor(n) * 24 * 60 * 60 * 1000);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||||
|
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBytes(value: string): Uint8Array {
|
||||||
|
const raw = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4);
|
||||||
|
return base64ToBytes(padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseErrorMessage(resp: Response, fallback: string): Promise<string> {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
return body?.error_description || body?.error || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSendKeyParts(sendKeyBytes: Uint8Array): { enc: Uint8Array; mac: Uint8Array } {
|
||||||
|
if (sendKeyBytes.length >= 64) {
|
||||||
|
return { enc: sendKeyBytes.slice(0, 32), mac: sendKeyBytes.slice(32, 64) };
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(64);
|
||||||
|
merged.set(sendKeyBytes.slice(0, 32), 0);
|
||||||
|
merged.set(sendKeyBytes.slice(0, 32), 32);
|
||||||
|
return { enc: merged.slice(0, 32), mac: merged.slice(32, 64) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaxAccessCountRaw(value: string): number | null {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n < 0) throw new Error('Invalid max access count');
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSend(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
session: SessionState,
|
||||||
|
draft: SendDraft
|
||||||
|
): Promise<Send> {
|
||||||
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
|
const sendKeyRaw = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
const sendKeyForUser = await encryptBw(sendKeyRaw, userEnc, userMac);
|
||||||
|
const sendKey = toSendKeyParts(sendKeyRaw);
|
||||||
|
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
||||||
|
const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac);
|
||||||
|
|
||||||
|
const deletionIso = toIsoDateFromDays(draft.deletionDays, true)!;
|
||||||
|
const expirationIso = toIsoDateFromDays(draft.expirationDays, false);
|
||||||
|
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
|
||||||
|
const password = String(draft.password || '');
|
||||||
|
|
||||||
|
if (draft.type === 'text') {
|
||||||
|
const text = String(draft.text || '').trim();
|
||||||
|
if (!text) throw new Error('Send text is required');
|
||||||
|
const textCipher = await encryptTextValue(text, sendKey.enc, sendKey.mac);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: 0,
|
||||||
|
name: nameCipher,
|
||||||
|
notes: notesCipher,
|
||||||
|
key: sendKeyForUser,
|
||||||
|
text: {
|
||||||
|
text: textCipher,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
maxAccessCount,
|
||||||
|
password: password || null,
|
||||||
|
hideEmail: false,
|
||||||
|
disabled: !!draft.disabled,
|
||||||
|
deletionDate: deletionIso,
|
||||||
|
expirationDate: expirationIso,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await authedFetch('/api/sends', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Create send failed'));
|
||||||
|
const body = await parseJson<Send>(resp);
|
||||||
|
if (!body?.id) throw new Error('Create send failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft.file) throw new Error('File is required');
|
||||||
|
|
||||||
|
const fileResp = await authedFetch('/api/sends/file/v2', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 1,
|
||||||
|
name: nameCipher,
|
||||||
|
notes: notesCipher,
|
||||||
|
key: sendKeyForUser,
|
||||||
|
file: {
|
||||||
|
fileName: draft.file.name,
|
||||||
|
},
|
||||||
|
fileLength: draft.file.size,
|
||||||
|
maxAccessCount,
|
||||||
|
password: password || null,
|
||||||
|
hideEmail: false,
|
||||||
|
disabled: !!draft.disabled,
|
||||||
|
deletionDate: deletionIso,
|
||||||
|
expirationDate: expirationIso,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!fileResp.ok) throw new Error(await parseErrorMessage(fileResp, 'Create file send failed'));
|
||||||
|
|
||||||
|
const uploadInfo = await parseJson<{ url?: string }>(fileResp);
|
||||||
|
const uploadUrl = uploadInfo?.url;
|
||||||
|
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('data', draft.file, draft.file.name);
|
||||||
|
const uploadResp = await authedFetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed'));
|
||||||
|
const fileBody = await parseJson<{ sendResponse?: Send }>(fileResp);
|
||||||
|
if (!fileBody?.sendResponse?.id) throw new Error('Create file send failed');
|
||||||
|
return fileBody.sendResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSend(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
session: SessionState,
|
||||||
|
send: Send,
|
||||||
|
draft: SendDraft
|
||||||
|
): Promise<Send> {
|
||||||
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
if (!send.key) throw new Error('Send key unavailable');
|
||||||
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
|
const sendKeyRaw = await decryptBw(send.key, userEnc, userMac);
|
||||||
|
const sendKey = toSendKeyParts(sendKeyRaw);
|
||||||
|
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
||||||
|
const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac);
|
||||||
|
|
||||||
|
const deletionIso = toIsoDateFromDays(draft.deletionDays, true)!;
|
||||||
|
const expirationIso = toIsoDateFromDays(draft.expirationDays, false);
|
||||||
|
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
|
||||||
|
|
||||||
|
if (draft.type === 'file' && draft.file) {
|
||||||
|
throw new Error('Updating file content is not supported yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const textCipher = await encryptTextValue(String(draft.text || ''), sendKey.enc, sendKey.mac);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: send.id,
|
||||||
|
type: draft.type === 'file' ? 1 : 0,
|
||||||
|
name: nameCipher,
|
||||||
|
notes: notesCipher,
|
||||||
|
key: send.key,
|
||||||
|
text: {
|
||||||
|
text: textCipher,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
maxAccessCount,
|
||||||
|
password: String(draft.password || '') || null,
|
||||||
|
hideEmail: false,
|
||||||
|
disabled: !!draft.disabled,
|
||||||
|
deletionDate: deletionIso,
|
||||||
|
expirationDate: expirationIso,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await authedFetch(`/api/sends/${encodeURIComponent(send.id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update send failed'));
|
||||||
|
const body = await parseJson<Send>(resp);
|
||||||
|
if (!body?.id) throw new Error('Update send failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSend(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
sendId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch(`/api/sends/${encodeURIComponent(sendId)}`, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accessPublicSend(accessId: string, password?: string): Promise<any> {
|
||||||
|
const payload = password ? { password } : {};
|
||||||
|
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const message = await parseErrorMessage(resp, 'Failed to access send');
|
||||||
|
const error = new Error(message) as Error & { status?: number };
|
||||||
|
error.status = resp.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return (await parseJson<any>(resp)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accessPublicSendFile(sendId: string, fileId: string, password?: string): Promise<string> {
|
||||||
|
const payload = password ? { password } : {};
|
||||||
|
const resp = await fetch(`/api/sends/${encodeURIComponent(sendId)}/access/file/${encodeURIComponent(fileId)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const message = await parseErrorMessage(resp, 'Failed to access send file');
|
||||||
|
const error = new Error(message) as Error & { status?: number };
|
||||||
|
error.status = resp.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const body = await parseJson<{ url?: string }>(resp);
|
||||||
|
if (!body?.url) throw new Error('Missing file URL');
|
||||||
|
return body.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
|
||||||
|
const sendKeyRaw = base64UrlToBytes(urlSafeKey);
|
||||||
|
const sendKey = toSendKeyParts(sendKeyRaw);
|
||||||
|
const out: any = { ...accessData };
|
||||||
|
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
||||||
|
if (accessData?.text?.text) {
|
||||||
|
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
|
||||||
|
}
|
||||||
|
if (accessData?.file?.fileName) {
|
||||||
|
try {
|
||||||
|
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
|
||||||
|
} catch {
|
||||||
|
out.decFileName = String(accessData.file.fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSendShareKey(sendKeyEncrypted: string, userEncB64: string, userMacB64: string): Promise<string> {
|
||||||
|
const userEnc = base64ToBytes(userEncB64);
|
||||||
|
const userMac = base64ToBytes(userMacB64);
|
||||||
|
return decryptBw(sendKeyEncrypted, userEnc, userMac).then((raw) => bytesToBase64Url(raw));
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,6 +127,52 @@ export interface Cipher {
|
|||||||
decNotes?: string;
|
decNotes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendTextData {
|
||||||
|
text?: string | null;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Send {
|
||||||
|
id: string;
|
||||||
|
accessId: string;
|
||||||
|
type: number;
|
||||||
|
name?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
text?: SendTextData | null;
|
||||||
|
key?: string | null;
|
||||||
|
maxAccessCount?: number | null;
|
||||||
|
accessCount?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
revisionDate?: string;
|
||||||
|
expirationDate?: string | null;
|
||||||
|
deletionDate?: string;
|
||||||
|
decName?: string;
|
||||||
|
decNotes?: string;
|
||||||
|
decText?: string;
|
||||||
|
decShareKey?: string;
|
||||||
|
shareUrl?: string;
|
||||||
|
file?: {
|
||||||
|
id?: string;
|
||||||
|
fileName?: string;
|
||||||
|
size?: string | number;
|
||||||
|
sizeName?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendDraft {
|
||||||
|
id?: string;
|
||||||
|
type: 'text' | 'file';
|
||||||
|
name: string;
|
||||||
|
notes: string;
|
||||||
|
text: string;
|
||||||
|
file: File | null;
|
||||||
|
deletionDays: string;
|
||||||
|
expirationDays: string;
|
||||||
|
maxAccessCount: string;
|
||||||
|
password: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type CustomFieldType = 0 | 1 | 2 | 3;
|
export type CustomFieldType = 0 | 1 | 2 | 3;
|
||||||
|
|
||||||
export interface VaultDraftField {
|
export interface VaultDraftField {
|
||||||
|
|||||||
+90
-4
@@ -42,6 +42,12 @@ body,
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-send-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
width: min(640px, 100%);
|
width: min(640px, 100%);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -85,6 +91,46 @@ body,
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select.input {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
padding-right: 42px;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, #365fa8 50%),
|
||||||
|
linear-gradient(135deg, #365fa8 50%, transparent 50%);
|
||||||
|
background-position:
|
||||||
|
calc(100% - 18px) calc(50% - 3px),
|
||||||
|
calc(100% - 12px) calc(50% - 3px);
|
||||||
|
background-size: 6px 6px, 6px 6px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='file'].input {
|
||||||
|
height: auto;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='file'].input::file-selector-button {
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #3f5b9e;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #1f4ea0;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='file'].input::file-selector-button:hover {
|
||||||
|
background: #dfeaff;
|
||||||
|
border-color: #2f5fd8;
|
||||||
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
min-height: 110px;
|
min-height: 110px;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -115,6 +161,19 @@ body,
|
|||||||
padding-right: 44px;
|
padding-right: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #275ac2;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.eye-btn {
|
.eye-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
@@ -720,6 +779,10 @@ body,
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.totp-grid {
|
.totp-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 220px 1fr;
|
||||||
@@ -850,6 +913,22 @@ body,
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-delete-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: #3a4a64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-options label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.local-error {
|
.local-error {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
color: #b42318;
|
color: #b42318;
|
||||||
@@ -904,12 +983,19 @@ body,
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-row-actions {
|
.invite-create-group {
|
||||||
justify-content: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-actions-head {
|
.invite-hours-field {
|
||||||
text-align: right !important;
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-hours-field > span {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #5f6f85;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-mask {
|
.dialog-mask {
|
||||||
|
|||||||
Reference in New Issue
Block a user