Files
CookieBridge/src/relay/store.ts
徐枫 a320f7ad97 feat: add admin REST API layer for frontend management panel
Implement JWT-protected /admin/* routes on the relay server:
- Auth: login, logout, me, setup/status, setup/init (first-time config)
- Dashboard: stats overview (connections, devices, cookies, domains)
- Cookies: paginated list with domain/search filter, detail, delete, batch delete
- Devices: list with online status, revoke
- Settings: get/update (sync interval, max devices, theme)

Uses scrypt for password hashing and jsonwebtoken for JWT.
Adds listAll/revoke to DeviceRegistry, getAll/getById/deleteById to CookieBlobStore,
disconnect to ConnectionManager. Updates frontend to use /admin/* endpoints.

All 38 existing tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 20:28:56 +08:00

136 lines
4.1 KiB
TypeScript

import sodium from "sodium-native";
import type { EncryptedCookieBlob } from "../protocol/spec.js";
import { MAX_STORED_COOKIES_PER_DEVICE } from "../protocol/spec.js";
type CookieKey = string; // "domain|name|path"
function blobKey(blob: Pick<EncryptedCookieBlob, "domain" | "cookieName" | "path">): CookieKey {
return `${blob.domain}|${blob.cookieName}|${blob.path}`;
}
/**
* In-memory encrypted cookie storage.
* The server stores opaque ciphertext — it cannot read cookie values.
* Keyed by (deviceId, domain, cookieName, path).
*/
export class CookieBlobStore {
// deviceId -> (cookieKey -> blob)
private store = new Map<string, Map<CookieKey, EncryptedCookieBlob>>();
/** Upsert an encrypted cookie blob. LWW by lamportTs. */
upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): EncryptedCookieBlob {
let deviceMap = this.store.get(blob.deviceId);
if (!deviceMap) {
deviceMap = new Map();
this.store.set(blob.deviceId, deviceMap);
}
const key = blobKey(blob);
const existing = deviceMap.get(key);
if (existing && existing.lamportTs >= blob.lamportTs) {
return existing; // stale update
}
const stored: EncryptedCookieBlob = {
...blob,
id: existing?.id ?? generateId(),
updatedAt: new Date().toISOString(),
};
deviceMap.set(key, stored);
// Enforce per-device limit
if (deviceMap.size > MAX_STORED_COOKIES_PER_DEVICE) {
// Remove oldest by updatedAt
let oldest: { key: CookieKey; time: string } | null = null;
for (const [k, v] of deviceMap) {
if (!oldest || v.updatedAt < oldest.time) {
oldest = { key: k, time: v.updatedAt };
}
}
if (oldest) deviceMap.delete(oldest.key);
}
return stored;
}
/** Delete a cookie blob. */
delete(deviceId: string, domain: string, cookieName: string, path: string): boolean {
const deviceMap = this.store.get(deviceId);
if (!deviceMap) return false;
return deviceMap.delete(blobKey({ domain, cookieName, path }));
}
/** Get all blobs for a device, optionally filtered by domain. */
getByDevice(deviceId: string, domain?: string): EncryptedCookieBlob[] {
const deviceMap = this.store.get(deviceId);
if (!deviceMap) return [];
const blobs = Array.from(deviceMap.values());
if (domain) return blobs.filter((b) => b.domain === domain);
return blobs;
}
/** Get blobs across all paired devices for a set of deviceIds. */
getByDevices(deviceIds: string[], domain?: string): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = [];
for (const id of deviceIds) {
result.push(...this.getByDevice(id, domain));
}
return result;
}
/** Get all stored blobs across all devices. */
getAll(): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = [];
for (const deviceMap of this.store.values()) {
result.push(...deviceMap.values());
}
return result;
}
/** Get a single blob by its ID. */
getById(id: string): EncryptedCookieBlob | null {
for (const deviceMap of this.store.values()) {
for (const blob of deviceMap.values()) {
if (blob.id === id) return blob;
}
}
return null;
}
/** Delete a blob by its ID. Returns true if found and deleted. */
deleteById(id: string): boolean {
for (const deviceMap of this.store.values()) {
for (const [key, blob] of deviceMap) {
if (blob.id === id) {
deviceMap.delete(key);
return true;
}
}
}
return false;
}
/** Get all blobs updated after a given timestamp (for polling). */
getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] {
const result: EncryptedCookieBlob[] = [];
for (const id of deviceIds) {
const deviceMap = this.store.get(id);
if (!deviceMap) continue;
for (const blob of deviceMap.values()) {
if (blob.updatedAt > since) {
result.push(blob);
}
}
}
return result;
}
}
function generateId(): string {
const buf = Buffer.alloc(16);
sodium.randombytes_buf(buf);
return buf.toString("hex");
}