diff --git a/donut-sync/.env.example b/donut-sync/.env.example index e58c5b9e..cd8c46f4 100644 --- a/donut-sync/.env.example +++ b/donut-sync/.env.example @@ -1,8 +1,19 @@ -# REQUIRED: a long, random shared secret used to authenticate sync clients. +# Set SYNC_TOKEN, SYNC_PUBLIC_URL, or both. +# +# SYNC_TOKEN: a long, random shared secret for Donut Browser while signed out. +# Anyone who has it can read and change everything in the bucket. # Generate one, e.g.: openssl rand -hex 32 # The server refuses to start with this placeholder or a value shorter than 24 chars. SYNC_TOKEN=CHANGE_ME_generate_a_long_random_secret +# SYNC_PUBLIC_URL: lets people with a paid Donut account sync here while signed +# in. Each account gets its own space, a team shares its owner's space with the +# team's roles and group access, and the plan's profile limit applies. Write the +# exact address people enter in Donut Browser; sign-in tokens are bound to it. +# The server checks each sign-in with the Donut API, so it must reach +# https://api.donutbrowser.com. +# SYNC_PUBLIC_URL=https://sync.example.com + PORT=12342 # REQUIRED S3 / S3-compatible (e.g. MinIO) connection. No defaults are assumed — diff --git a/donut-sync/src/app.controller.spec.ts b/donut-sync/src/app.controller.spec.ts index 5f2fcef8..0a8eacb7 100644 --- a/donut-sync/src/app.controller.spec.ts +++ b/donut-sync/src/app.controller.spec.ts @@ -1,3 +1,4 @@ +import { ConfigService } from "@nestjs/config"; import { Test, type TestingModule } from "@nestjs/testing"; import { AppController } from "./app.controller.js"; import { AppService } from "./app.service.js"; @@ -5,8 +6,10 @@ import { SyncService } from "./sync/sync.service.js"; describe("AppController", () => { let appController: AppController; + let env: Record; beforeEach(async () => { + env = {}; const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], providers: [ @@ -15,8 +18,13 @@ describe("AppController", () => { provide: SyncService, useValue: { checkS3Connectivity: jest.fn().mockResolvedValue(true), + getDiagnosticStorageEndpoint: jest.fn().mockReturnValue(undefined), }, }, + { + provide: ConfigService, + useValue: { get: (key: string) => env[key] }, + }, ], }).compile(); @@ -34,4 +42,19 @@ describe("AppController", () => { expect(appController.getHealth()).toEqual({ status: "ok" }); }); }); + + describe("readyz", () => { + it("names the address Donut accounts sign in with, when they can", async () => { + await expect(appController.getReadiness()).resolves.toEqual({ + status: "ready", + s3: true, + }); + env.SYNC_PUBLIC_URL = "https://Sync.Example.com/"; + await expect(appController.getReadiness()).resolves.toEqual({ + status: "ready", + s3: true, + accountUrl: "https://sync.example.com", + }); + }); + }); }); diff --git a/donut-sync/src/app.controller.ts b/donut-sync/src/app.controller.ts index bf6c47cf..236ee229 100644 --- a/donut-sync/src/app.controller.ts +++ b/donut-sync/src/app.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { AppService } from "./app.service.js"; +import { normalizeSyncServerUrl } from "./auth/donut-accounts.js"; import { SyncService } from "./sync/sync.service.js"; @Controller() @@ -7,6 +9,7 @@ export class AppController { constructor( private readonly appService: AppService, private readonly syncService: SyncService, + private readonly configService: ConfigService, ) {} @Get() @@ -23,15 +26,26 @@ export class AppController { // server cannot tell whether a client can reach it, so report it and let // whoever is debugging a failing sync compare it against their network. // Self-hosted only — see getDiagnosticStorageEndpoint. + // + // `accountUrl` is the address Donut accounts sign in to this server with + // (SYNC_PUBLIC_URL). The app checks it against the address it was given, + // because a token is bound to one exact address. @Get("readyz") async getReadiness(): Promise<{ status: string; s3: boolean; storageEndpoint?: string; + accountUrl?: string; }> { const s3Ready = await this.syncService.checkS3Connectivity(); const storageEndpoint = this.syncService.getDiagnosticStorageEndpoint(); - const diagnostic = storageEndpoint ? { storageEndpoint } : {}; + const accountUrl = normalizeSyncServerUrl( + this.configService.get("SYNC_PUBLIC_URL"), + ); + const diagnostic = { + ...(storageEndpoint ? { storageEndpoint } : {}), + ...(accountUrl ? { accountUrl } : {}), + }; if (!s3Ready) { throw new HttpException( { status: "not ready", s3: false, ...diagnostic }, diff --git a/donut-sync/src/auth/auth.guard.spec.ts b/donut-sync/src/auth/auth.guard.spec.ts index c002f130..6e1f29d7 100644 --- a/donut-sync/src/auth/auth.guard.spec.ts +++ b/donut-sync/src/auth/auth.guard.spec.ts @@ -2,6 +2,7 @@ import { generateKeyPairSync } from "node:crypto"; import { type ExecutionContext, Logger, + ServiceUnavailableException, UnauthorizedException, } from "@nestjs/common"; import type { ConfigService } from "@nestjs/config"; @@ -261,3 +262,158 @@ describe("AuthGuard", () => { }); }); }); + +describe("AuthGuard with Donut account sign-in", () => { + const PUBLIC_URL = "https://sync.example.com"; + const OWNER_ID = "99999999-8888-7777-6666-555555555555"; + + beforeEach(() => { + jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function accountGuard(): AuthGuard { + return guardWith({ + SYNC_TOKEN: SELF_HOSTED_TOKEN, + SYNC_PUBLIC_URL: `${PUBLIC_URL}/`, + DONUT_API_URL: "http://api.test", + }); + } + + // Signed with a key this server never sees: only the Donut API can say + // whose a token is. The claims only decide whether to ask. + const accountToken = (n = 1, claims: Record = {}) => + sign( + { sub: USER_ID, n, scope: "server-sync", aud: PUBLIC_URL, ...claims }, + otherKeys.privateKey, + ); + + function answering(...answers: unknown[]): jest.SpyInstance { + return jest.spyOn(globalThis, "fetch").mockImplementation(async () => { + const answer = answers.shift(); + if (answer instanceof Response) return answer; + return new Response(JSON.stringify(answer)); + }); + } + + it("gives an account the API vouches for its own namespace and plan limit", async () => { + const fetchSpy = answering({ + active: true, + sub: USER_ID, + profileLimit: 20, + team: null, + }); + const token = accountToken(); + const { context, request } = contextFor(token); + + await expect(accountGuard().canActivate(context)).resolves.toBe(true); + expect(request.user).toEqual({ + mode: "account", + prefix: `users/${USER_ID}/`, + profileLimit: 20, + sub: USER_ID, + }); + // The API is asked about this server's own address, never the caller's. + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("http://api.test/api/auth/server-sync/introspect"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + token, + server: PUBLIC_URL, + }); + }); + + it("puts a team member in the owner namespace with the team's rules", async () => { + answering({ + active: true, + sub: USER_ID, + profileLimit: 0, + team: { + ownerId: OWNER_ID, + teamId: "team-1", + teamProfileLimit: 100, + role: "launcher", + groupIds: ["g1", "bad/id"], + }, + }); + const { context, request } = contextFor(accountToken()); + + await expect(accountGuard().canActivate(context)).resolves.toBe(true); + expect(request.user).toEqual({ + mode: "account", + prefix: `users/${OWNER_ID}/`, + profileLimit: 100, + sub: USER_ID, + teamRole: "launcher", + groupIds: ["g1"], + }); + }); + + it("refuses what the API does not vouch for, and asks once per token", async () => { + // Refused, then active without a limit: 0 is unlimited, so no limit + // refuses rather than reads as 0. + const fetchSpy = answering( + { active: false }, + { active: true, sub: USER_ID }, + { active: false }, + ); + const token = accountToken(); + + for (let i = 0; i < 2; i++) { + await expect( + accountGuard().canActivate(contextFor(token).context), + ).rejects.toBeInstanceOf(UnauthorizedException); + } + const guard = accountGuard(); + const other = accountToken(2); + await expect( + guard.canActivate(contextFor(other).context), + ).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + guard.canActivate(contextFor(other).context), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("fails closed when the API cannot be asked", async () => { + answering(new Response("down", { status: 502 })); + const { context, request } = contextFor(accountToken()); + + await expect(accountGuard().canActivate(context)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(request.user).toBeUndefined(); + }); + + it("never asks about a token that is not for this server", async () => { + const fetchSpy = answering(); + for (const token of [ + accountToken(1, { aud: "https://other.example.com" }), + accountToken(2, { scope: "sync" }), + sign( + { sub: USER_ID, scope: "server-sync", aud: PUBLIC_URL }, + otherKeys.privateKey, + -10, + ), + ]) { + await expect( + accountGuard().canActivate(contextFor(token).context), + ).rejects.toBeInstanceOf(UnauthorizedException); + } + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("never sends the shared token or other strings to the API", async () => { + const fetchSpy = answering(); + await expect( + accountGuard().canActivate(contextFor(SELF_HOSTED_TOKEN).context), + ).resolves.toBe(true); + await expect( + accountGuard().canActivate(contextFor("not a token").context), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/donut-sync/src/auth/auth.guard.ts b/donut-sync/src/auth/auth.guard.ts index 3df21f10..c1153368 100644 --- a/donut-sync/src/auth/auth.guard.ts +++ b/donut-sync/src/auth/auth.guard.ts @@ -4,11 +4,13 @@ import { type ExecutionContext, Injectable, Logger, + ServiceUnavailableException, UnauthorizedException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { Request } from "express"; import * as jwt from "jsonwebtoken"; +import { type DonutAccounts, donutAccountsFromEnv } from "./donut-accounts.js"; import type { UserContext } from "./user-context.interface.js"; /** Constant-time string compare; false on length mismatch (no early return). */ @@ -36,12 +38,60 @@ function teamRoleOf(role: unknown): TeamRole { return TEAM_ROLES.includes(role as TeamRole) ? (role as TeamRole) : "member"; } +/** + * A user's effective namespace: their own, or for a team member the shared + * owner namespace with the team's role, group access and limit. + */ +function scopedContext( + mode: "cloud" | "account", + sub: string, + profileLimit: number, + scope: TeamScope | null, +): UserContext { + let prefix = `users/${sub}/`; + let teamRole: TeamRole | undefined; + let groupIds: string[] | undefined; + let limit = profileLimit; + if ( + scope && + typeof scope.ownerId === "string" && + /^[^/]+$/.test(scope.ownerId) + ) { + prefix = `users/${scope.ownerId}/`; + teamRole = teamRoleOf(scope.role); + // A malformed id cannot name a group, so it grants nothing; it never + // turns a limited member into an unlimited one. + if (Array.isArray(scope.groupIds)) { + groupIds = scope.groupIds.filter( + (id): id is string => typeof id === "string" && GROUP_ID.test(id), + ); + } + if (scope.teamProfileLimit > 0) { + limit = scope.teamProfileLimit; + } + } + return { + mode, + prefix, + profileLimit: limit, + sub, + ...(teamRole ? { teamRole } : {}), + ...(groupIds ? { groupIds } : {}), + }; +} + +/** Only a JWT can be a Donut account token; anything else skips the API. */ +function looksLikeJwt(token: string): boolean { + return /^[\w-]+\.[\w-]+\.[\w-]+$/.test(token); +} + @Injectable() export class AuthGuard implements CanActivate { private readonly logger = new Logger(AuthGuard.name); private jwtPublicKey: string | null = null; private readonly backendInternalUrl: string | undefined; private readonly backendInternalKey: string | undefined; + private readonly accounts: DonutAccounts | null; // Short-lived cache of the per-user team scope so membership revocation takes // effect quickly (within TTL) without a backend round-trip on every request. @@ -63,6 +113,14 @@ export class AuthGuard implements CanActivate { this.backendInternalKey = this.configService.get( "BACKEND_INTERNAL_KEY", ); + this.accounts = donutAccountsFromEnv((key) => + this.configService.get(key), + ); + if (this.accounts) { + this.logger.log( + `Donut account sign-in enabled for ${this.accounts.publicUrl}`, + ); + } } /** @@ -154,28 +212,11 @@ export class AuthGuard implements CanActivate { // to the shared team owner namespace. The JWT carries no team data — the // backend is the sole authority. On any resolver error we fail CLOSED: // fall back to the user's own namespace, never widening to a team one. - let effectivePrefix = ownPrefix; - let teamRole: TeamRole | undefined; - let groupIds: string[] | undefined; - let effectiveProfileLimit = + const ownProfileLimit = typeof decoded.profileLimit === "number" ? decoded.profileLimit : 0; + let scope: TeamScope | null = null; try { - const scope = sub ? await this.resolveTeamScope(sub) : null; - if (scope && /^[^/]+$/.test(scope.ownerId)) { - effectivePrefix = `users/${scope.ownerId}/`; - teamRole = teamRoleOf(scope.role); - // A malformed id cannot name a group, so it grants nothing; it - // never turns a limited member into an unlimited one. - if (Array.isArray(scope.groupIds)) { - groupIds = scope.groupIds.filter( - (id): id is string => - typeof id === "string" && GROUP_ID.test(id), - ); - } - if (scope.teamProfileLimit > 0) { - effectiveProfileLimit = scope.teamProfileLimit; - } - } + scope = sub ? await this.resolveTeamScope(sub) : null; } catch (err) { this.logger.warn( `Team scope resolution failed for ${sub}; using own namespace: ${ @@ -184,14 +225,10 @@ export class AuthGuard implements CanActivate { ); } - (request as unknown as Record).user = { - mode: "cloud", - prefix: effectivePrefix, - profileLimit: effectiveProfileLimit, - sub, - ...(teamRole ? { teamRole } : {}), - ...(groupIds ? { groupIds } : {}), - } satisfies UserContext; + const user = scopedContext("cloud", sub, ownProfileLimit, scope); + // The prefix claim, already checked above, names the own namespace. + if (!scope) user.prefix = ownPrefix; + (request as unknown as Record).user = user; return true; } catch (err) { this.logger.warn( @@ -200,10 +237,34 @@ export class AuthGuard implements CanActivate { } } + // A Donut account on this server: the API says whose the token is. + if (this.accounts && looksLikeJwt(token)) { + let account: Awaited>; + try { + account = await this.accounts.resolve(token); + } catch (err) { + this.logger.warn( + `Donut account check failed: ${err instanceof Error ? err.message : err}`, + ); + throw new ServiceUnavailableException( + "The Donut account service could not be reached", + ); + } + if (account) { + (request as unknown as Record).user = scopedContext( + "account", + account.sub, + account.profileLimit, + account.team, + ); + return true; + } + } + // If SYNC_TOKEN is configured but didn't match, or JWT failed - if (!expectedToken && !this.jwtPublicKey) { + if (!expectedToken && !this.jwtPublicKey && !this.accounts) { throw new UnauthorizedException( - "No auth method configured on server (set SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY)", + "No auth method configured on server (set SYNC_TOKEN, SYNC_PUBLIC_URL or SYNC_JWT_PUBLIC_KEY)", ); } diff --git a/donut-sync/src/auth/donut-accounts.ts b/donut-sync/src/auth/donut-accounts.ts new file mode 100644 index 00000000..6b874566 --- /dev/null +++ b/donut-sync/src/auth/donut-accounts.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; + +export const DEFAULT_DONUT_API_URL = "https://api.donutbrowser.com"; + +/** + * The canonical form of a sync server address, or null when the value cannot + * be one. The desktop app and the Donut API write addresses the same way, so a + * token bound to what a person typed matches what this server says it is. + */ +export function normalizeSyncServerUrl(raw: unknown): string | null { + if (typeof raw !== "string" || raw.length > 2048) return null; + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + if (!url.hostname || url.username || url.password) return null; + if (url.search || url.hash) return null; + return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`; +} + +export type AccountTeamScope = { + ownerId: string; + teamId: string; + teamProfileLimit: number; + role?: string; + groupIds?: string[] | null; +}; + +/** Whose a token is, as the Donut API answers for this server. */ +export type AccountScope = { + sub: string; + /** The plan's profile limit. 0 is unlimited. */ + profileLimit: number; + team: AccountTeamScope | null; +}; + +/** + * Signs Donut accounts in to this server. + * + * A paid account's app sends a token the Donut API issued for this server's + * address. The API says whose it is, with the plan's profile limit and the + * team scope, so this server applies the same namespace, limit and team rules + * as Donut's own sync server. Answers are cached briefly: a seat or plan + * change applies within the cache time. + */ +export class DonutAccounts { + static readonly CACHE_TTL_MS = 30_000; + private static readonly REQUEST_TIMEOUT_MS = 10_000; + private readonly cache = new Map< + string, + { value: AccountScope | null; expires: number } + >(); + + constructor( + readonly publicUrl: string, + private readonly apiUrl: string, + ) {} + + /** + * The account behind `token`, or null when the API refuses it. Throws when + * the API cannot be asked, so the caller fails closed. + */ + async resolve(token: string): Promise { + // Only the API can verify the signature. Asking it about tokens that are + // plainly not for this server would let anyone who reaches this server + // spend its share of the API's request limit. + if (!this.claimsFitThisServer(token)) return null; + const key = createHash("sha256").update(token).digest("hex"); + const now = Date.now(); + const cached = this.cache.get(key); + if (cached && cached.expires > now) return cached.value; + + const response = await fetch( + `${this.apiUrl}/api/auth/server-sync/introspect`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, server: this.publicUrl }), + signal: AbortSignal.timeout(DonutAccounts.REQUEST_TIMEOUT_MS), + }, + ); + if (!response.ok) { + throw new Error(`introspection returned ${response.status}`); + } + const value = accountScopeOf(await response.json()); + + if (this.cache.size > 10_000) this.cache.clear(); + this.cache.set(key, { + value, + expires: now + DonutAccounts.CACHE_TTL_MS, + }); + return value; + } + + /** The token's own, unverified claims name this server and are current. */ + private claimsFitThisServer(token: string): boolean { + const payload = token.split(".")[1]; + if (!payload) return false; + let claims: Record; + try { + claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return false; + } + return ( + claims?.scope === SERVER_SYNC_SCOPE && + claims.aud === this.publicUrl && + typeof claims.exp === "number" && + claims.exp * 1000 > Date.now() + ); + } +} + +/** The scope of a token the Donut API issues for a customer's server. */ +const SERVER_SYNC_SCOPE = "server-sync"; + +function accountScopeOf(body: unknown): AccountScope | null { + if (!body || typeof body !== "object") return null; + const answer = body as Record; + if (answer.active !== true) return null; + if (typeof answer.sub !== "string" || !/^[^/]+$/.test(answer.sub)) { + return null; + } + // 0 means unlimited, so a missing limit refuses rather than reads as 0. + if (typeof answer.profileLimit !== "number") return null; + const team = + answer.team && typeof answer.team === "object" + ? (answer.team as AccountTeamScope) + : null; + return { sub: answer.sub, profileLimit: answer.profileLimit, team }; +} + +/** The account sign-in this server's environment turns on, or null. */ +export function donutAccountsFromEnv( + get: (key: string) => string | undefined, +): DonutAccounts | null { + const publicUrl = normalizeSyncServerUrl(get("SYNC_PUBLIC_URL")); + if (!publicUrl) return null; + const apiUrl = (get("DONUT_API_URL") || DEFAULT_DONUT_API_URL).replace( + /\/+$/, + "", + ); + return new DonutAccounts(publicUrl, apiUrl); +} diff --git a/donut-sync/src/auth/user-context.interface.ts b/donut-sync/src/auth/user-context.interface.ts index 2f8373eb..57a77a99 100644 --- a/donut-sync/src/auth/user-context.interface.ts +++ b/donut-sync/src/auth/user-context.interface.ts @@ -1,12 +1,15 @@ export interface UserContext { - mode: "self-hosted" | "cloud"; - // The EFFECTIVE namespace for this request: '' for self-hosted, and for cloud + // `self-hosted`: the static SYNC_TOKEN, the whole bucket. `cloud`: Donut's + // own sync server. `account`: a Donut account on a server the customer runs, + // with the same namespace, limit and team rules as `cloud`. + mode: "self-hosted" | "cloud" | "account"; + // The EFFECTIVE namespace for this request: '' for self-hosted, and otherwise // either the user's own 'users/{sub}/' or, for a team member, the shared team // owner's 'users/{ownerId}/' — resolved server-side by the AuthGuard from the // backend (never carried in the JWT). All key scoping uses this directly. prefix: string; profileLimit: number; // 0 for unlimited (self-hosted); effective (team) limit for team members - sub?: string; // the authenticated user id (cloud only) + sub?: string; // the authenticated user id (cloud and account only) // The caller's role on the team whose namespace `prefix` is, from the // backend. A `launcher` may run profiles and sync what a session changed, // never change a profile's settings, proxies or groups. diff --git a/donut-sync/src/main.ts b/donut-sync/src/main.ts index ff1b53b4..85bb23b4 100644 --- a/donut-sync/src/main.ts +++ b/donut-sync/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { AppModule } from "./app.module.js"; +import { normalizeSyncServerUrl } from "./auth/donut-accounts.js"; const INSECURE_DEFAULT_TOKENS = new Set([ "secret-sync-token", @@ -10,8 +11,17 @@ const INSECURE_DEFAULT_TOKENS = new Set([ function validateEnv() { const token = process.env.SYNC_TOKEN; - if (!token && !process.env.SYNC_JWT_PUBLIC_KEY) { - console.error("Either SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY must be set"); + const publicUrl = process.env.SYNC_PUBLIC_URL; + if (publicUrl && !normalizeSyncServerUrl(publicUrl)) { + console.error( + "SYNC_PUBLIC_URL must be the http(s) address Donut Browser uses for this server, e.g. https://sync.example.com", + ); + process.exit(1); + } + if (!token && !publicUrl && !process.env.SYNC_JWT_PUBLIC_KEY) { + console.error( + "Set SYNC_TOKEN, SYNC_PUBLIC_URL (Donut account sign-in), or both", + ); process.exit(1); } // A static SYNC_TOKEN is the only credential on a self-hosted server that is diff --git a/donut-sync/src/sync/sync.service.spec.ts b/donut-sync/src/sync/sync.service.spec.ts index d042b781..ebf34fa0 100644 --- a/donut-sync/src/sync/sync.service.spec.ts +++ b/donut-sync/src/sync/sync.service.spec.ts @@ -415,3 +415,110 @@ describe("SyncService restore points", () => { } }); }); + +describe("SyncService profile limit", () => { + const ACCOUNT: UserContext = { + mode: "account", + prefix: OWNER_PREFIX, + profileLimit: 2, + sub: "u1", + }; + + function withStoredProfiles(...ids: string[]) { + const { service, send } = serviceWithStubbedS3(); + send.mockImplementation( + async (command: { input?: { Prefix?: string } }) => { + if (command.input?.Prefix === `${OWNER_PREFIX}profiles/`) { + return { + CommonPrefixes: ids.map((id) => ({ + Prefix: `${OWNER_PREFIX}profiles/${id}/`, + })), + }; + } + return {}; + }, + ); + return service; + } + + beforeEach(() => { + jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("keeps a full plan syncing its profiles and settings, but adds none", async () => { + const service = withStoredProfiles("p1", "p2"); + + for (const key of [ + "profiles/p1/files/Default/Cookies", + "profiles/p2/metadata.json", + "proxies/x.json", + "groups/g.json", + ]) { + await expect( + service.presignUpload({ key }, ACCOUNT), + ).resolves.toHaveProperty("url"); + } + await expect( + service.presignUpload({ key: "profiles/p3/metadata.json" }, ACCOUNT), + ).rejects.toBeInstanceOf(ForbiddenException); + await expect( + service.presignUploadBatch( + { + items: [ + { key: "profiles/p1/files/a" }, + { key: "profiles/p3/files/a" }, + ], + }, + ACCOUNT, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("counts a batch's new profiles together", async () => { + const service = withStoredProfiles("p1"); + + await expect( + service.presignUploadBatch( + { + items: [ + { key: "profiles/p2/files/a" }, + { key: "profiles/p2/files/b" }, + ], + }, + ACCOUNT, + ), + ).resolves.toHaveProperty("items"); + await expect( + service.presignUploadBatch( + { + items: [ + { key: "profiles/p2/files/a" }, + { key: "profiles/p3/files/a" }, + ], + }, + ACCOUNT, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("writes no profile over the limit, and never limits the shared token", async () => { + const service = withStoredProfiles("p1", "p2", "p3"); + + await expect( + service.presignUpload({ key: "profiles/p1/files/a" }, ACCOUNT), + ).rejects.toBeInstanceOf(ForbiddenException); + await expect( + service.presignUpload({ key: "proxies/x.json" }, ACCOUNT), + ).resolves.toHaveProperty("url"); + await expect( + service.presignUpload( + { key: "profiles/p9/files/a" }, + { mode: "self-hosted", prefix: "", profileLimit: 0 }, + ), + ).resolves.toHaveProperty("url"); + }); +}); diff --git a/donut-sync/src/sync/sync.service.ts b/donut-sync/src/sync/sync.service.ts index e0426582..5fea56fe 100644 --- a/donut-sync/src/sync/sync.service.ts +++ b/donut-sync/src/sync/sync.service.ts @@ -117,6 +117,9 @@ const GROUP_CACHE_MAX = 10_000; const LAUNCHER_WRITABLE = /^(teams\/[^/]+\/)?profiles\/[^/]+\/(files\/.+|manifest\.json)$/; +/** A key inside a profile that counts against the plan, relative to the namespace. */ +const COUNTED_PROFILE_KEY = /^profiles\/([^/]+)\//; + @Injectable() export class SyncService implements OnModuleInit { private readonly logger = new Logger(SyncService.name); @@ -579,10 +582,7 @@ export class SyncService implements OnModuleInit { const metadata = sanitizeMetadata(dto.metadata); await this.assertGroupWrite(ctx, key, metadata); - // Check profile limit for cloud users - if (ctx.mode === "cloud" && ctx.profileLimit > 0) { - await this.checkProfileLimit(ctx); - } + await this.checkProfileLimit(ctx, [key]); const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); @@ -764,10 +764,10 @@ export class SyncService implements OnModuleInit { `items must be an array of at most ${SyncService.MAX_BATCH_ITEMS} entries`, ); } - // Check profile limit for cloud users - if (ctx.mode === "cloud" && ctx.profileLimit > 0) { - await this.checkProfileLimit(ctx); - } + await this.checkProfileLimit( + ctx, + dto.items.map((item) => this.scopeKey(ctx, item.key)), + ); const expiresIn = clampExpiresIn(dto.expiresIn); const expiresAt = new Date(Date.now() + expiresIn * 1000); @@ -1119,7 +1119,7 @@ export class SyncService implements OnModuleInit { // refuse an empty scoped prefix (which would match the whole scope). this.validateWriteAccess(ctx, prefix); await this.assertGroupWrite(ctx, prefix); - if (ctx.mode === "cloud" && prefix.length === 0) { + if (ctx.mode !== "self-hosted" && prefix.length === 0) { throw new ForbiddenException("Refusing to delete an empty prefix"); } const scopedTombstoneKey = dto.tombstoneKey @@ -1503,39 +1503,47 @@ export class SyncService implements OnModuleInit { } /** - * Check if the user has reached their profile limit. - * Counts objects in the profiles/ prefix. + * Refuse writes that would store more profiles than the plan allows. + * + * Only a new profile counts. A stored profile keeps syncing at the limit, + * and proxies, groups and other settings never count: refusing every write + * at the limit stopped a full plan from syncing any change at all. Over the + * limit (after a downgrade), nothing under profiles/ is written until + * profiles are removed. + * + * ctx.prefix is the effective namespace (the team owner's, for a team + * member) and ctx.profileLimit the effective (team) limit, so this covers + * personal and team accounts alike. */ - private async checkProfileLimit(ctx: UserContext): Promise { - if (ctx.profileLimit <= 0) return; // 0 = unlimited + private async checkProfileLimit( + ctx: UserContext, + scopedKeys: string[], + ): Promise { + if (ctx.mode === "self-hosted" || ctx.profileLimit <= 0) return; // 0 = unlimited - let count = 0; + const written = new Set(); + for (const key of scopedKeys) { + const match = COUNTED_PROFILE_KEY.exec(key.slice(ctx.prefix.length)); + if (match) written.add(match[1]); + } + if (written.size === 0) return; - const userResult = await this.s3Client.send( - new ListObjectsV2Command({ - Bucket: this.bucket, - Prefix: `${ctx.prefix}profiles/`, - Delimiter: "/", - }), - ); - count += userResult.CommonPrefixes?.length || 0; - - // ctx.prefix is already the effective namespace (the team owner's, for a - // team member) and ctx.profileLimit the effective (team) limit, so this - // single check covers both personal and team accounts. - if (count >= ctx.profileLimit) { + const stored = await this.storedProfileIds(ctx); + let total = stored.size; + for (const id of written) { + if (!stored.has(id)) total += 1; + } + if (total > ctx.profileLimit) { throw new ForbiddenException( `Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`, ); } } - /** - * Count the number of distinct profile directories for a user. - */ - private async countProfiles(ctx: UserContext): Promise { + /** The ids of the profiles stored in the user's namespace. */ + private async storedProfileIds(ctx: UserContext): Promise> { const profilePrefix = `${ctx.prefix}profiles/`; - let count = 0; + const ids = new Set(); let continuationToken: string | undefined; do { @@ -1548,11 +1556,21 @@ export class SyncService implements OnModuleInit { ContinuationToken: continuationToken, }), ); - count += result.CommonPrefixes?.length || 0; + for (const common of result.CommonPrefixes ?? []) { + const id = common.Prefix?.slice(profilePrefix.length, -1); + if (id) ids.add(id); + } continuationToken = result.NextContinuationToken; } while (continuationToken); - return count; + return ids; + } + + /** + * Count the number of distinct profile directories for a user. + */ + private async countProfiles(ctx: UserContext): Promise { + return (await this.storedProfileIds(ctx)).size; } /** diff --git a/donut-sync/test/accounts.e2e-spec.ts b/donut-sync/test/accounts.e2e-spec.ts new file mode 100644 index 00000000..12aa5f42 --- /dev/null +++ b/donut-sync/test/accounts.e2e-spec.ts @@ -0,0 +1,177 @@ +import { randomUUID } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { INestApplication } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { Test, type TestingModule } from "@nestjs/testing"; +import * as jwt from "jsonwebtoken"; +import request from "supertest"; +import type { App } from "supertest/types"; +import { AppController } from "./../src/app.controller.js"; +import { AppService } from "./../src/app.service.js"; +import { SyncModule } from "./../src/sync/sync.module.js"; +import { + configureTestEnv, + TEST_SYNC_TOKEN, + waitForTestS3, +} from "./test-env.js"; + +// Donut accounts on a server the customer runs: the Donut API (a stand-in +// here) says whose a token is, and this server applies the namespace, the +// plan's profile limit and the team scope against real storage. +describe("Donut account sign-in (e2e)", () => { + const PUBLIC_URL = "https://sync.example.test"; + // Shaped like the Donut API's tokens for this server. The stand-in API + // vouches for them by value; this server never sees the signing key. + const tokenFor = (name: string) => + jwt.sign({ sub: name, scope: "server-sync", aud: PUBLIC_URL }, "test-key", { + expiresIn: 300, + }); + const OWNER_TOKEN = tokenFor("owner"); + const OTHER_TOKEN = tokenFor("other"); + const MEMBER_TOKEN = tokenFor("member"); + const STRANGER_TOKEN = tokenFor("stranger"); + const run = randomUUID().slice(0, 8); + const OWNER = `owner-${run}`; + const OTHER = `other-${run}`; + const MEMBER = `member-${run}`; + const tokens: Record = { + [OWNER_TOKEN]: { + active: true, + sub: OWNER, + profileLimit: 1, + team: null, + }, + [OTHER_TOKEN]: { + active: true, + sub: OTHER, + profileLimit: 5, + team: null, + }, + [MEMBER_TOKEN]: { + active: true, + sub: MEMBER, + profileLimit: 0, + team: { + ownerId: OWNER, + teamId: "team-1", + teamProfileLimit: 1, + role: "member", + groupIds: null, + }, + }, + }; + const asked: Array<{ token: string; server: string }> = []; + const saved = { + SYNC_PUBLIC_URL: process.env.SYNC_PUBLIC_URL, + DONUT_API_URL: process.env.DONUT_API_URL, + }; + let api: Server; + let app: INestApplication; + + beforeAll(async () => { + api = createServer((req, res) => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + const question = JSON.parse(body) as { token: string; server: string }; + asked.push(question); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(tokens[question.token] ?? { active: false })); + }); + }); + await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve)); + const { port } = api.address() as AddressInfo; + + configureTestEnv(); + process.env.SYNC_PUBLIC_URL = `${PUBLIC_URL}/`; + process.env.DONUT_API_URL = `http://127.0.0.1:${port}`; + await waitForTestS3(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true }), SyncModule], + controllers: [AppController], + providers: [AppService], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.listen(0); + }); + + afterAll(async () => { + await app.close(); + api.closeAllConnections(); + await new Promise((resolve) => api.close(resolve)); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + function upload(token: string, key: string) { + return request(app.getHttpServer()) + .post("/v1/objects/presign-upload") + .set("Authorization", `Bearer ${token}`) + .send({ key, contentType: "application/json" }); + } + + async function put(token: string, key: string): Promise { + const response = await upload(token, key).expect(200); + const stored = await fetch((response.body as { url: string }).url, { + method: "PUT", + body: "{}", + headers: { "Content-Type": "application/json" }, + }); + expect(stored.ok).toBe(true); + } + + async function listed(token: string): Promise { + const response = await request(app.getHttpServer()) + .post("/v1/objects/list") + .set("Authorization", `Bearer ${token}`) + .send({ prefix: "profiles/" }) + .expect(200); + return (response.body as { objects: Array<{ key: string }> }).objects.map( + (object) => object.key, + ); + } + + it("names the sign-in address on /readyz", async () => { + const response = await request(app.getHttpServer()) + .get("/readyz") + .expect(200); + expect(response.body).toMatchObject({ accountUrl: PUBLIC_URL }); + }); + + it("keeps each account in its own space, apart from the shared token", async () => { + await put(OWNER_TOKEN, "profiles/p1/metadata.json"); + + expect(await listed(OWNER_TOKEN)).toContain("profiles/p1/metadata.json"); + expect(await listed(OTHER_TOKEN)).not.toContain( + "profiles/p1/metadata.json", + ); + // The shared token sees the bucket root, where account spaces are not. + expect(await listed(TEST_SYNC_TOKEN)).not.toContain( + "profiles/p1/metadata.json", + ); + expect(asked.every((question) => question.server === PUBLIC_URL)).toBe( + true, + ); + }); + + it("applies the plan's profile limit, and lets a full plan keep syncing", async () => { + await put(OWNER_TOKEN, "profiles/p1/files/Default/Cookies"); + await upload(OWNER_TOKEN, "profiles/p2/metadata.json").expect(403); + await upload(OWNER_TOKEN, "proxies/x.json").expect(200); + }); + + it("gives a team member the owner's space and the team's limit", async () => { + expect(await listed(MEMBER_TOKEN)).toContain("profiles/p1/metadata.json"); + await upload(MEMBER_TOKEN, "profiles/p2/metadata.json").expect(403); + }); + + it("refuses a token the API does not vouch for", async () => { + await upload(STRANGER_TOKEN, "profiles/p1/metadata.json").expect(401); + }); +}); diff --git a/donut-sync/test/app.e2e-spec.ts b/donut-sync/test/app.e2e-spec.ts index 1988fa04..f360a4d3 100644 --- a/donut-sync/test/app.e2e-spec.ts +++ b/donut-sync/test/app.e2e-spec.ts @@ -1,4 +1,5 @@ import { INestApplication } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; import request from "supertest"; import { App } from "supertest/types"; @@ -11,6 +12,7 @@ describe("AppController (e2e)", () => { beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true })], controllers: [AppController], providers: [ AppService, diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 92507ac0..b270fa93 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -280,6 +280,8 @@ export const commandCoverage = { "get_sync_settings", "save_sync_settings", "check_sync_server_connection", + "sync::account_server::get_account_sync_server", + "sync::account_server::set_account_sync_server", "cloud_auth::restart_sync_service", "set_profile_sync_mode", "cancel_profile_sync", diff --git a/e2e/tests/sync.test.mjs b/e2e/tests/sync.test.mjs index f1921c95..916fd3e8 100644 --- a/e2e/tests/sync.test.mjs +++ b/e2e/tests/sync.test.mjs @@ -1160,3 +1160,63 @@ test("the connection check fails a server whose storage host this device cannot await app.close(); } }); + +// A paid account syncs with its own server only while signed in, with a token +// the Donut API binds to that server's address. The suites cannot sign in, so +// this covers what a device sees on its own: the address the server names on +// /readyz, and the refusals that keep a signed-out device from storing one. +test("an own sync server is read from /readyz and needs a signed-in account", async () => { + const app = appFromEnvironment("sync-account-server"); + const accountServer = createServer((request, response) => { + if (request.url === "/readyz") { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + status: "ready", + s3: true, + accountUrl: "https://sync.example.test", + }), + ); + return; + } + response.writeHead(404); + response.end(); + }); + await new Promise((resolve) => accountServer.listen(0, "127.0.0.1", resolve)); + const accountServerUrl = `http://127.0.0.1:${accountServer.address().port}`; + + try { + await app.start(); + + const check = await app.invoke("check_sync_server_connection", { + serverUrl: accountServerUrl, + }); + assert.equal(check.server_reachable, true); + assert.equal(check.account_url, "https://sync.example.test"); + + assert.equal(await app.invoke("get_account_sync_server"), null); + const refused = await app.invokeError("set_account_sync_server", { + serverUrl: accountServerUrl, + }); + assert.match(refused, /"code":"ACCOUNT_SYNC_SIGN_IN_REQUIRED"/); + // Going back to Donut cloud is always allowed, and stores nothing. + assert.equal( + await app.invoke("set_account_sync_server", { serverUrl: null }), + null, + ); + assert.equal(await app.invoke("get_account_sync_server"), null); + + // A settings save from the frontend never writes the field. + const settings = await app.invoke("get_app_settings"); + await app.invoke("save_app_settings", { + settings: { ...settings, account_sync_server_url: accountServerUrl }, + }); + assert.equal(await app.invoke("get_account_sync_server"), null); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + await new Promise((resolve) => accountServer.close(resolve)); + await app.close(); + } +}); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index f18dd6e0..6e3dae5e 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -5559,3 +5559,86 @@ test("feedback goes from the More menu to the team, with the logs the person pre await capture.close(); } }); + +test("a signed-in paid account picks its own sync server on the account page", async () => { + await withApp("ui-account-sync-server", async (app) => { + await app.waitForText("No profiles yet"); + const own = en.account.ownServer; + const server = "https://sync.example.test"; + try { + await stubCommand(app, "get_account_sync_server", null); + await stubCommand(app, "check_sync_server_connection", { + server_reachable: true, + storage_ready: true, + storage_endpoint: null, + storage_reachable: null, + storage_error: null, + account_url: server, + }); + await stubCommand(app, "set_account_sync_server", server); + await stubCommand(app, "restart_sync_service", null); + await stubCommand(app, "cloud_get_user", { + logged_in_at: new Date().toISOString(), + user: { + id: "ui-account-sync-server", + email: "solo@example.test", + plan: "solo", + planPeriod: "monthly", + subscriptionStatus: "active", + profileLimit: 20, + cloudProfilesUsed: 0, + proxyBandwidthLimitMb: 0, + proxyBandwidthUsedMb: 0, + proxyBandwidthExtraMb: 0, + isPrimaryDevice: true, + }, + }); + await app.invoke("plugin:event|emit", { + event: "cloud-auth-changed", + payload: null, + }); + // A paid account opens the one-time welcome; close it when it comes. + const welcomed = await app + .waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[role="dialog"]'));`, + ), + { timeoutMs: 4000, description: "the paid welcome" }, + ) + .catch(() => false); + if (welcomed) await dismissSurface(app); + + await app.clickSelector(`[aria-label="${en.rail.account}"]`); + await app.clickText(en.account.tabs.selfHosted, { roles: ["tab"] }); + await app.waitForText(own.title); + assert.ok(await app.visibleTextIncludes(own.currentCloud)); + assert.ok(await app.visibleTextIncludes(own.cloudOnly)); + // Signed in, the shared-token form is not offered. + assert.equal( + await app.execute( + `return Boolean(document.querySelector('#self-hosted-token'));`, + ), + false, + ); + + await app.fillSelector("#account-sync-server-url", `${server}/`); + await app.clickText(en.account.selfHosted.testConnection, { + roles: ["button"], + }); + await app.waitForText(own.accountsOn); + await app.clickText(en.common.buttons.save, { roles: ["button"] }); + await app.waitForText(own.current.replace("{{server}}", server)); + assert.ok(await app.visibleTextIncludes(own.useDonutCloud)); + await app.capture("account-sync-server"); + + const calls = await app.execute( + `return (window.__donutStubbedCalls ?? []).filter((call) => call.command === "set_account_sync_server");`, + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].payload.serverUrl, `${server}/`); + } finally { + await restoreStubs(app); + } + }); +}); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 816fadfa..a05148f9 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -3746,6 +3746,9 @@ fn sync_mode_error_response(err: String) -> (StatusCode, String) { pub async fn remote_launch_precondition( profile: &crate::profile::types::BrowserProfile, ) -> Result<(), String> { + if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() { + return Err(refusal); + } remote_launch_profile_rules(profile)?; // The manifest is written last, so a host pulling mid-upload gets files @@ -4219,7 +4222,7 @@ fn cookie_bot_eligible_profile( .find(|p| p.id.to_string() == profile_id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; - crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile)) + crate::cookie_bot::run_precondition(&profile) .map_err(|reason| (StatusCode::BAD_REQUEST, reason))?; Ok(profile) } diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 45c87370..5593994b 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -27,6 +27,13 @@ const MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60); static ACCESS_TOKEN_REFRESH: Streak = Streak::new(module_path!(), "Access token refresh"); static SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Sync token fetch"); +static SERVER_SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Own server sync token fetch"); + +/// Tokens for the customer's own sync server, by address. In memory only: +/// they last minutes and cost one request to replace. +static SERVER_SYNC_TOKENS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(Default::default); static PROFILE_REFRESH: Streak = Streak::new(module_path!(), "Cloud profile refresh"); static PROXY_CONFIG_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy config fetch"); static PROXY_USAGE_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy usage fetch"); @@ -557,7 +564,8 @@ impl CloudAuthManager { // --- JWT expiry check --- - fn jwt_expiry(token: &str) -> Option { + /// A JWT's claims, unverified. + fn jwt_claims(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return None; @@ -572,8 +580,21 @@ impl CloudAuthManager { } }; - let json: serde_json::Value = serde_json::from_slice(&payload).ok()?; - json.get("exp").and_then(|v| v.as_i64()) + serde_json::from_slice(&payload).ok() + } + + fn jwt_expiry(token: &str) -> Option { + Self::jwt_claims(token)?.get("exp").and_then(|v| v.as_i64()) + } + + /// Whether `token` is one the Donut API bound to `server`. Anything else, + /// such as a token for Donut cloud from an API that does not know the + /// `server` field yet, must never be sent to a third party's server. + fn is_server_sync_token_for(token: &str, server: &str) -> bool { + Self::jwt_claims(token).is_some_and(|claims| { + claims.get("scope").and_then(|v| v.as_str()) == Some("server-sync") + && claims.get("aud").and_then(|v| v.as_str()) == Some(server) + }) } fn jwt_expires_within(token: &str, margin_secs: i64, now: i64) -> bool { @@ -1148,7 +1169,86 @@ impl CloudAuthManager { Ok(Some(sync_token)) } + /// A token for the customer's own donut-sync at `server`, which the Donut + /// API binds to that one address (`sync::account_server`). + pub async fn server_sync_token(&self, server: &str) -> Result { + if !self.is_logged_in().await { + return Err(crate::backend_error("ACCOUNT_SYNC_SIGN_IN_REQUIRED")); + } + let cached = SERVER_SYNC_TOKENS + .lock() + .ok() + .and_then(|tokens| tokens.get(server).cloned()); + if let Some(token) = cached.filter(|token| !Self::is_jwt_expiring_soon(token)) { + return Ok(token); + } + + let fetched = self + .api_call_with_retry(|access_token| { + let url = format!("{CLOUD_API_URL}/api/auth/sync-token"); + let client = self.client.clone(); + let body = serde_json::json!({ "server": server }); + async move { + let response = client + .post(&url) + .header("Authorization", format!("Bearer {access_token}")) + .json(&body) + .send() + .await + .map_err(|e| format!("Failed to get sync token: {e}"))?; + let status = response.status(); + if status == reqwest::StatusCode::FORBIDDEN { + return Err(crate::backend_error("ACCOUNT_SYNC_PLAN_REQUIRED")); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("Sync token request failed ({status}): {body}")); + } + let result: SyncTokenResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse sync token response: {e}"))?; + Ok(result.sync_token) + } + }) + .await; + let fetched = fetched.and_then(|token| { + if Self::is_server_sync_token_for(&token, server) { + Ok(token) + } else { + Err(crate::backend_error("ACCOUNT_SYNC_UNAVAILABLE")) + } + }); + match fetched { + Ok(token) => { + SERVER_SYNC_TOKEN_FETCH.succeeded(); + if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() { + tokens.insert(server.to_string(), token.clone()); + } + Ok(token) + } + Err(e) => { + SERVER_SYNC_TOKEN_FETCH.failed(&e); + Err(e) + } + } + } + + /// Forget a token the own server refused, so the next request fetches one. + pub(crate) fn discard_server_sync_token(refused: &str) { + if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() { + tokens.retain(|_, token| token != refused); + } + } + pub async fn logout(&self) -> Result<(), String> { + if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() { + tokens.clear(); + } + // The own server is this account's choice; the next account starts on + // Donut cloud. + crate::sync::account_server::clear(); + // Clear wayfern token self.clear_wayfern_token().await; self.set_wayfern_device_refusal(None); @@ -2150,6 +2250,36 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St mod tests { use super::*; + fn jwt_with(claims: serde_json::Value) -> String { + use base64::{engine::general_purpose, Engine as _}; + let encode = + |value: &serde_json::Value| general_purpose::URL_SAFE_NO_PAD.encode(value.to_string()); + format!( + "{}.{}.signature", + encode(&serde_json::json!({ "alg": "RS256" })), + encode(&claims) + ) + } + + #[test] + fn only_a_token_bound_to_the_server_goes_to_it() { + let server = "https://sync.example.com"; + let bound = jwt_with(serde_json::json!({ "scope": "server-sync", "aud": server })); + assert!(CloudAuthManager::is_server_sync_token_for(&bound, server)); + // An API that ignores `server` answers with a token for Donut cloud, which + // must never reach someone else's server. + let cloud = jwt_with(serde_json::json!({ "scope": "sync", "prefix": "users/u1/" })); + assert!(!CloudAuthManager::is_server_sync_token_for(&cloud, server)); + assert!(!CloudAuthManager::is_server_sync_token_for( + &bound, + "https://other.example.com" + )); + assert!(!CloudAuthManager::is_server_sync_token_for( + "not-a-jwt", + server + )); + } + #[test] fn a_refused_token_request_yields_the_servers_message() { let refused = r#"Wayfern token request failed (403 Forbidden): {"message":"Browser automation is restricted to your primary device. Log out other devices to use it here.","error":"Forbidden","statusCode":403}"#; diff --git a/src-tauri/src/cookie_bot.rs b/src-tauri/src/cookie_bot.rs index 9a2fc043..a0e0b5f4 100644 --- a/src-tauri/src/cookie_bot.rs +++ b/src-tauri/src/cookie_bot.rs @@ -603,6 +603,15 @@ pub struct CookieBotUsage { // --- Client-side preconditions --------------------------------------------- +/// [`bot_precondition`] for this device as it is now: also refused while the +/// account syncs with its own server, which the bot's hosts cannot reach. +pub fn run_precondition(profile: &BrowserProfile) -> Result<(), String> { + if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() { + return Err(refusal); + } + bot_precondition(profile, &exit_reachability(profile)) +} + /// Whether this profile could ever be warmed by the bot. /// /// The server is authoritative — it re-checks all of this and owns the parts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b4ae08f..fc9c0a53 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2117,7 +2117,7 @@ async fn save_cookie_bot_schedule( // Refused here rather than when the run is due: a profile that can never be // warmed should never reach an enrolment, an hour of quota or a leased host. let profile = cookie_bot_profile(&profile_id)?; - cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?; + cookie_bot::run_precondition(&profile)?; // The frontend sends the user's choices; the profile facts the server refuses // a run on are stamped here, from the profile itself, so a caller cannot // assert them. @@ -2178,7 +2178,7 @@ async fn run_cookie_bot_now( max_minutes: Option, ) -> Result { let profile = cookie_bot_profile(&profile_id)?; - cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?; + cookie_bot::run_precondition(&profile)?; cookie_bot::run_now(&profile_id, max_minutes) .await .map_err(|e| cookie_bot_error("run start", e)) @@ -3541,6 +3541,8 @@ pub fn run_with_builder( get_sync_settings, save_sync_settings, check_sync_server_connection, + sync::account_server::get_account_sync_server, + sync::account_server::set_account_sync_server, set_profile_sync_mode, cancel_profile_sync, request_profile_sync, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 3692a9b4..9385ca55 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -10878,12 +10878,11 @@ impl McpServer { data: None, })?; - crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile)) - .map_err(|message| McpError { - code: -32000, - message, - data: None, - })?; + crate::cookie_bot::run_precondition(&profile).map_err(|message| McpError { + code: -32000, + message, + data: None, + })?; Ok(profile) } diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index 01788250..9a57c59d 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -259,6 +259,9 @@ pub async fn start_remote_session( .to_string(); let profile_id = profile.id.to_string(); + if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() { + return Err(RemoteSessionError::Other(refusal)); + } if let Some(refusal) = local_launch_refusal(profile, &platform) { return Err(refusal); } diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 5ae3666b..e7ba1784 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -152,6 +152,11 @@ pub struct AppSettings { pub api_token: Option, // Displayed token for user to copy #[serde(default)] pub sync_server_url: Option, // URL of the sync server + /// The customer's own donut-sync a signed-in paid account syncs with, + /// instead of Donut cloud (`sync::account_server`). Owned by + /// `set_account_sync_server`, never by a settings save. + #[serde(default)] + pub account_sync_server_url: Option, #[serde(default)] pub first_launch_timestamp: Option, // Unix epoch seconds when app was first launched #[serde(default)] @@ -302,6 +307,7 @@ impl Default for AppSettings { api_port: 10108, api_token: None, sync_server_url: None, + account_sync_server_url: None, first_launch_timestamp: None, commercial_trial_acknowledged: false, mcp_remote_enabled: false, @@ -711,10 +717,12 @@ pub async fn save_app_settings( settings.mcp_remote_enabled = current.mcp_remote_enabled; settings.mcp_remote_key_id = current.mcp_remote_key_id; settings.feedback_sent_at = current.feedback_sent_at; + settings.account_sync_server_url = current.account_sync_server_url; } } else { settings.mcp_remote_enabled = false; settings.mcp_remote_key_id = None; + settings.account_sync_server_url = None; } let mut persist_settings = settings.clone(); @@ -893,6 +901,16 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul #[tauri::command] pub async fn get_sync_settings(app_handle: tauri::AppHandle) -> Result { // Cloud auth takes priority over self-hosted settings + if let Some(server) = crate::sync::account_server::configured().await { + let sync_token = crate::cloud_auth::CLOUD_AUTH + .server_sync_token(&server) + .await + .ok(); + return Ok(SyncSettings { + sync_server_url: Some(server), + sync_token, + }); + } if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { let sync_token = crate::cloud_auth::CLOUD_AUTH .get_or_refresh_sync_token() @@ -1449,6 +1467,7 @@ mod tests { api_port: 10108, api_token: None, sync_server_url: None, + account_sync_server_url: None, first_launch_timestamp: None, commercial_trial_acknowledged: false, mcp_remote_enabled: false, diff --git a/src-tauri/src/sync/account_server.rs b/src-tauri/src/sync/account_server.rs new file mode 100644 index 00000000..2b199b05 --- /dev/null +++ b/src-tauri/src/sync/account_server.rs @@ -0,0 +1,191 @@ +//! A donut-sync server the customer runs, used while signed in to a paid +//! Donut account. +//! +//! The app signs in to that server with a short token the Donut API issues for +//! its one address, and the server asks the API whose the token is. The plan's +//! profile limit and the team's namespace, roles and group access thus apply +//! there as on Donut cloud, and every person who syncs needs their own seat. A +//! shared `SYNC_TOKEN` opens the whole bucket to anyone who has it, so it stays +//! the signed-out way to use a server. + +use crate::settings_manager::SettingsManager; + +/// The address in the one form the Donut API and donut-sync also write: scheme, +/// host and port as the URL parser writes them, then the path without trailing +/// slashes. A token is bound to this exact string. +pub fn normalize_server_url(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() || raw.len() > 2048 { + return None; + } + let url = url::Url::parse(raw).ok()?; + if !matches!(url.scheme(), "http" | "https") { + return None; + } + let host = url.host_str().filter(|host| !host.is_empty())?; + if !url.username().is_empty() || url.password().is_some() { + return None; + } + if url.query().is_some_and(|q| !q.is_empty()) || url.fragment().is_some_and(|f| !f.is_empty()) { + return None; + } + let port = url + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + Some(format!( + "{}://{host}{port}{}", + url.scheme(), + url.path().trim_end_matches('/') + )) +} + +/// The own server set on this device, signed in or not. +fn stored() -> Option { + SettingsManager::instance() + .load_settings() + .ok()? + .account_sync_server_url +} + +/// The own server this signed-in account syncs with, or `None` for Donut +/// cloud. +pub async fn configured() -> Option { + if !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + return None; + } + stored() +} + +/// The refusal for work that runs a profile on Donut's hosts. They take the +/// profile from Donut cloud and cannot reach the customer's server. +pub fn cloud_run_refusal() -> Option { + stored().map(|_| crate::backend_error("REMOTE_NEEDS_DONUT_SYNC")) +} + +pub(crate) fn clear() { + let manager = SettingsManager::instance(); + if let Ok(mut settings) = manager.load_settings() { + if settings.account_sync_server_url.take().is_some() { + let _ = manager.save_settings(&settings); + } + } +} + +/// Check that `raw` is a server this account can sync with, and return its +/// address. +async fn check(raw: &str) -> Result { + let auth = &crate::cloud_auth::CLOUD_AUTH; + if !auth.is_logged_in().await { + return Err(crate::backend_error("ACCOUNT_SYNC_SIGN_IN_REQUIRED")); + } + if !auth.can_use_cloud_backup().await { + return Err(crate::backend_error("ACCOUNT_SYNC_PLAN_REQUIRED")); + } + let server = + normalize_server_url(raw).ok_or_else(|| crate::backend_error("SYNC_SERVER_URL_INVALID"))?; + + let probe = super::preflight::check_sync_server(&server).await; + if !probe.server_reachable || probe.storage_ready == Some(false) { + return Err(crate::backend_error("SYNC_SERVER_UNREACHABLE")); + } + if probe.storage_reachable == Some(false) { + return Err( + serde_json::json!({ + "code": "SYNC_SERVER_STORAGE_UNREACHABLE", + "params": { "endpoint": probe.storage_endpoint.unwrap_or_default() }, + }) + .to_string(), + ); + } + match probe.account_url.as_deref().and_then(normalize_server_url) { + None => return Err(crate::backend_error("SYNC_SERVER_NO_ACCOUNTS")), + Some(address) if address != server => { + return Err( + serde_json::json!({ + "code": "SYNC_SERVER_ADDRESS_MISMATCH", + "params": { "address": address }, + }) + .to_string(), + ); + } + Some(_) => {} + } + + // The API decides who may sync. Asking now names a refusal here, not at the + // first sync. + auth.server_sync_token(&server).await?; + Ok(server) +} + +#[tauri::command] +pub async fn get_account_sync_server() -> Result, String> { + Ok(stored()) +} + +/// Sync this signed-in account with its own server, or with Donut cloud again +/// when `server_url` is empty. +#[tauri::command] +pub async fn set_account_sync_server(server_url: Option) -> Result, String> { + let server = match server_url.as_deref().map(str::trim) { + Some(raw) if !raw.is_empty() => Some(check(raw).await?), + _ => None, + }; + let manager = SettingsManager::instance(); + let mut settings = manager + .load_settings() + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; + settings.account_sync_server_url = server.clone(); + manager + .save_settings(&settings) + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?; + log::info!("Account sync server set own_server={}", server.is_some()); + Ok(server) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_one_form_for_every_way_to_type_the_same_address() { + // The same cases as the API and donut-sync: a token only matches when all + // three write the address alike. + for typed in [ + "https://Sync.Example.com", + "https://sync.example.com/", + " https://sync.example.com:443// ", + "https://sync.example.com/?", + ] { + assert_eq!( + normalize_server_url(typed).as_deref(), + Some("https://sync.example.com"), + "{typed}" + ); + } + assert_eq!( + normalize_server_url("http://192.168.1.5:3929/donut/").as_deref(), + Some("http://192.168.1.5:3929/donut") + ); + assert_eq!( + normalize_server_url("http://[::1]:3929").as_deref(), + Some("http://[::1]:3929") + ); + } + + #[test] + fn refuses_what_cannot_be_a_server_address() { + let long = format!("https://{}.com", "a".repeat(2100)); + for bad in [ + "", + "sync.example.com", + "ftp://sync.example.com", + "https://user:pass@sync.example.com", + "https://sync.example.com/?a=1", + "https://sync.example.com/#x", + long.as_str(), + ] { + assert_eq!(normalize_server_url(bad), None, "{bad}"); + } + } +} diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 5b33d26d..efdc0c3e 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -574,12 +574,23 @@ impl SyncEngine { pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result { // Cloud auth takes priority if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { - let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string(); - let token = crate::cloud_auth::CLOUD_AUTH - .get_or_refresh_sync_token() - .await - .map_err(|e| format!("Failed to get cloud sync token: {e}"))? - .ok_or_else(|| "Cloud sync token not available".to_string())?; + let (url, token) = match super::account_server::configured().await { + Some(server) => { + let token = crate::cloud_auth::CLOUD_AUTH + .server_sync_token(&server) + .await + .map_err(|e| format!("Failed to get own server sync token: {e}"))?; + (server, token) + } + None => { + let token = crate::cloud_auth::CLOUD_AUTH + .get_or_refresh_sync_token() + .await + .map_err(|e| format!("Failed to get cloud sync token: {e}"))? + .ok_or_else(|| "Cloud sync token not available".to_string())?; + (crate::cloud_auth::CLOUD_SYNC_URL.to_string(), token) + } + }; let mut engine = Self::new(url, token); engine.read_only = crate::team_roles::is_read_only(); return Ok(engine); @@ -3923,7 +3934,10 @@ async fn apply_profile_sync_mode( ); } - if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { + // The cloud counts what it stores; an own server counts for itself. + if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await + && super::account_server::configured().await.is_none() + { let sync_count = profile_manager .list_profiles() .map(|profiles| profiles.iter().filter(|p| p.is_sync_enabled()).count()) diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 06f829b6..e695a3d9 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -1,3 +1,4 @@ +pub mod account_server; mod client; pub mod config_entities; pub mod encryption; diff --git a/src-tauri/src/sync/preflight.rs b/src-tauri/src/sync/preflight.rs index 33fdcf50..15a213e4 100644 --- a/src-tauri/src/sync/preflight.rs +++ b/src-tauri/src/sync/preflight.rs @@ -41,6 +41,10 @@ pub struct SyncServerCheck { pub storage_reachable: Option, /// Why the storage probe failed, for the log and the error surface. pub storage_error: Option, + /// The address Donut accounts sign in to the server with, when it lets + /// them (`SYNC_PUBLIC_URL`). + #[serde(default)] + pub account_url: Option, } impl SyncServerCheck { @@ -62,6 +66,8 @@ struct ReadyzBody { s3: Option, #[serde(default, rename = "storageEndpoint")] storage_endpoint: Option, + #[serde(default, rename = "accountUrl")] + account_url: Option, } fn probe_client() -> reqwest::Client { @@ -113,6 +119,7 @@ pub async fn check_sync_server(server_url: &str) -> SyncServerCheck { let body = readyz.json::().await.ok(); check.storage_ready = body.as_ref().and_then(|b| b.s3); + check.account_url = body.as_ref().and_then(|b| b.account_url.clone()); check.storage_endpoint = body.and_then(|b| b.storage_endpoint); if let Some(endpoint) = check.storage_endpoint.clone() { @@ -226,6 +233,7 @@ mod tests { storage_endpoint: Some("http://minio:9000".to_string()), storage_reachable: Some(false), storage_error: Some("connection failed: dns error".to_string()), + account_url: None, }; assert!(!check.is_usable()); } @@ -238,6 +246,7 @@ mod tests { storage_endpoint: Some("http://localhost:9101".to_string()), storage_reachable: Some(true), storage_error: None, + account_url: None, }; assert!(check.is_usable()); } @@ -253,6 +262,7 @@ mod tests { storage_endpoint: None, storage_reachable: None, storage_error: None, + account_url: None, }; assert!(check.is_usable()); } @@ -267,6 +277,20 @@ mod tests { assert!(!check.is_usable()); } + #[test] + fn readyz_names_the_account_address() { + let body: ReadyzBody = serde_json::from_str( + r#"{"status":"ready","s3":true,"accountUrl":"https://sync.example.com"}"#, + ) + .unwrap(); + assert_eq!( + body.account_url.as_deref(), + Some("https://sync.example.com") + ); + let older: ReadyzBody = serde_json::from_str(r#"{"status":"ready","s3":true}"#).unwrap(); + assert_eq!(older.account_url, None); + } + #[test] fn unreachable_server_is_not_usable() { assert!(!SyncServerCheck::default().is_usable()); diff --git a/src-tauri/src/sync/subscription.rs b/src-tauri/src/sync/subscription.rs index fbef52bd..230eba37 100644 --- a/src-tauri/src/sync/subscription.rs +++ b/src-tauri/src/sync/subscription.rs @@ -36,6 +36,8 @@ pub enum SyncWorkItem { #[derive(Clone, Copy)] enum TokenSource { Cloud, + /// The customer's own server, with a token the Donut API binds to it. + Account, SelfHosted, } @@ -134,6 +136,18 @@ impl SyncSubscription { app_handle: &tauri::AppHandle, work_tx: mpsc::UnboundedSender, ) -> Result, String> { + if let Some(server) = super::account_server::configured().await { + let token = crate::cloud_auth::CLOUD_AUTH + .server_sync_token(&server) + .await?; + return Ok(Some(Self::new( + server, + token, + TokenSource::Account, + work_tx, + ))); + } + // Cloud auth takes priority if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string(); @@ -221,8 +235,14 @@ impl SyncSubscription { } Err(StreamFailure::Refused(status)) => { log::warn!("Sync subscription refused status={status}"); - if matches!(source, TokenSource::Cloud) { - crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(¤t); + match source { + TokenSource::Cloud => { + crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(¤t) + } + TokenSource::Account => { + crate::cloud_auth::CloudAuthManager::discard_server_sync_token(¤t) + } + TokenSource::SelfHosted => {} } refused = Some(current); token = None; @@ -289,6 +309,13 @@ impl SyncSubscription { .get_or_refresh_sync_token() .await } + TokenSource::Account => match super::account_server::configured().await { + Some(server) => crate::cloud_auth::CLOUD_AUTH + .server_sync_token(&server) + .await + .map(Some), + None => Ok(None), + }, TokenSource::SelfHosted => SettingsManager::instance() .get_sync_token(app_handle) .await diff --git a/src/components/account-sync-server.tsx b/src/components/account-sync-server.tsx new file mode 100644 index 00000000..a84e1f95 --- /dev/null +++ b/src/components/account-sync-server.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuServer } from "react-icons/lu"; +import { LoadingButton } from "@/components/loading-button"; +import { Button } from "@/components/ui/button"; +import { SoftFields } from "@/components/ui/field-variant"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + FeatureIcon, + SettingsPanel, + StatusLight, +} from "@/components/ui/settings-panel"; +import { translateBackendError } from "@/lib/backend-errors"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import type { SyncServerCheck } from "@/types"; + +type Probe = + | { state: "unknown" | "testing" | "unreachable" | "no-accounts" } + | { state: "ready" } + | { state: "mismatch"; address: string }; + +/** A quick check before saving. The backend compares the exact form. */ +function sameAddress(a: string, b: string): boolean { + const trim = (value: string) => + value.trim().replace(/\/+$/, "").toLowerCase(); + return trim(a) === trim(b); +} + +/** + * A signed-in paid account's own donut-sync server. The app signs in to it + * with a token the Donut API binds to its address, so the plan's limit and + * the team's rules apply there as on Donut cloud. + */ +export function AccountSyncServer({ + paid, + teamSeat, +}: { + paid: boolean; + teamSeat: boolean; +}) { + const { t } = useTranslation(); + const [current, setCurrent] = useState(null); + const [serverUrl, setServerUrl] = useState(""); + const [probe, setProbe] = useState({ state: "unknown" }); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + void invoke("get_account_sync_server") + .then((server) => { + setCurrent(server); + setServerUrl(server ?? ""); + }) + .catch((error: unknown) => { + console.error("Failed to load the account sync server:", error); + }); + }, []); + + const restartSync = useCallback(async () => { + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } + }, []); + + const handleTest = useCallback(async () => { + setProbe({ state: "testing" }); + try { + const result = await invoke( + "check_sync_server_connection", + { serverUrl }, + ); + if ( + !result.server_reachable || + result.storage_ready === false || + result.storage_reachable === false + ) { + setProbe({ state: "unreachable" }); + } else if (!result.account_url) { + setProbe({ state: "no-accounts" }); + } else if (!sameAddress(result.account_url, serverUrl)) { + setProbe({ state: "mismatch", address: result.account_url }); + } else { + setProbe({ state: "ready" }); + } + } catch { + setProbe({ state: "unreachable" }); + } + }, [serverUrl]); + + const save = useCallback( + async (next: string | null) => { + setIsSaving(true); + try { + const saved = await invoke("set_account_sync_server", { + serverUrl: next, + }); + setCurrent(saved); + setServerUrl(saved ?? ""); + setProbe({ state: saved ? "ready" : "unknown" }); + await restartSync(); + showSuccessToast( + saved + ? t("account.ownServer.saved") + : t("account.ownServer.backToCloud"), + ); + } catch (error) { + showErrorToast(translateBackendError(t as never, error)); + } finally { + setIsSaving(false); + } + }, + [restartSync, t], + ); + + const probeTone = { + unknown: "muted", + testing: "muted", + ready: "success", + unreachable: "destructive", + "no-accounts": "destructive", + mismatch: "destructive", + } as const; + const probeLabel = + probe.state === "mismatch" + ? t("backendErrors.syncServerAddressMismatch", { address: probe.address }) + : { + unknown: t("account.selfHosted.statusUnknown"), + testing: t("appFeedback.checking"), + ready: t("account.ownServer.accountsOn"), + unreachable: t("backendErrors.syncServerUnreachable"), + "no-accounts": t("backendErrors.syncServerNoAccounts"), + }[probe.state]; + + return ( + +
+ + + +
+

{t("account.ownServer.title")}

+

+ {t("account.ownServer.description")} +

+

+ {current + ? t("account.ownServer.current", { server: current }) + : t("account.ownServer.currentCloud")} +

+
+
+ + {paid ? ( + <> + +
+ + { + setServerUrl(e.target.value); + setProbe({ state: "unknown" }); + }} + autoComplete="off" + spellCheck={false} + /> +
+
+ +
    +
  • {t("account.ownServer.cloudOnly")}
  • + {teamSeat &&
  • {t("account.ownServer.teamNote")}
  • } +
+ +
+ + + + {probeLabel} + + + {current && ( + + )} + void handleTest()} + className="h-8 rounded-lg text-xs" + > + {t("account.selfHosted.testConnection")} + + void save(serverUrl)} + className="h-8 rounded-lg text-xs" + > + {t("common.buttons.save")} + +
+ + ) : ( +

+ {t("backendErrors.accountSyncPlanRequired")} +

+ )} +
+ ); +} diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 4eef1c20..935c3069 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -76,6 +76,7 @@ export function SyncConfigDialog({ } = useCloudAuth(); const [activeTab, setActiveTab] = useState("cloud"); + const [accountServer, setAccountServer] = useState(null); const [, setLiveProxyUsage] = useState(null); const [connectionStatus, setConnectionStatus] = useState< @@ -146,6 +147,11 @@ export function SyncConfigDialog({ if (isOpen) { setConnectionStatus("unknown"); void loadSettings(); + void invoke("get_account_sync_server") + .then(setAccountServer) + .catch(() => { + setAccountServer(null); + }); void invoke("cloud_get_proxy_usage") .then(setLiveProxyUsage) .catch(() => { @@ -309,6 +315,14 @@ export function SyncConfigDialog({ {user.planPeriod ? ` (${user.planPeriod})` : ""} +
+ + {t("sync.cloud.server")} + + + {accountServer ?? t("account.ownServer.donutCloud")} + +
{t("sync.cloud.profiles")} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6cb683b7..2ec7c092 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -825,7 +825,8 @@ "logout": "Abmelden", "logoutConfirm": "Möchten Sie sich wirklich abmelden? Die Cloud-Synchronisierung wird beendet.", "loginSuccess": "Erfolgreich angemeldet!", - "logoutSuccess": "Erfolgreich abgemeldet." + "logoutSuccess": "Erfolgreich abgemeldet.", + "server": "Server" }, "team": { "title": "Team", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "Diesen Wiederherstellungspunkt gibt es nicht mehr.", "restorePointsUnsupported": "Der Sync-Server ist zu alt für Wiederherstellungspunkte. Aktualisiere ihn.", "restorePointFailed": "Der Wiederherstellungspunkt ist fehlgeschlagen: {{detail}}", - "restorePointsNeedSync": "Wiederherstellungspunkte brauchen eingeschaltete Synchronisierung für dieses Profil." + "restorePointsNeedSync": "Wiederherstellungspunkte brauchen eingeschaltete Synchronisierung für dieses Profil.", + "accountSyncSignInRequired": "Melden Sie sich bei Ihrem Donut-Konto an, um einen eigenen Sync-Server zu verwenden.", + "accountSyncPlanRequired": "Ein eigener Sync-Server erfordert einen kostenpflichtigen Plan.", + "syncServerUrlInvalid": "Geben Sie die vollständige Serveradresse ein, zum Beispiel https://sync.example.com.", + "syncServerUnreachable": "Keine Verbindung zum Sync-Server möglich.", + "syncServerStorageUnreachable": "Der Sync-Server antwortet, aber dieser Computer kann seinen Speicher unter {{endpoint}} nicht erreichen.", + "syncServerNoAccounts": "Dieser Server akzeptiert keine Donut-Konten. Setzen Sie SYNC_PUBLIC_URL auf dem Server.", + "syncServerAddressMismatch": "Die Adresse des Servers ist {{address}}. Geben Sie diese Adresse ein.", + "remoteNeedsDonutSync": "Remote-Sitzungen und Cookie Bot erhalten Profile aus der Donut-Cloud-Synchronisierung. Dieser Computer synchronisiert mit Ihrem eigenen Server.", + "accountSyncUnavailable": "Donut kann sich noch nicht bei Ihrem eigenen Server anmelden. Versuchen Sie es später erneut." }, "rail": { "profiles": "Profile", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "Selbst gehosteter Sync-Server", "description": "Verbinden Sie Donut mit Ihrem eigenen donut-sync-Server, um Profile, Proxys, Gruppen und Erweiterungen ohne die gehostete Cloud zu synchronisieren.", - "disabledWhileLoggedIn": "Die selbst gehostete Synchronisierung ist nicht verfügbar, solange Sie bei Ihrem Donut-Konto angemeldet sind. Melden Sie sich ab, um einen eigenen Server zu verwenden.", "connectionStatus": "Verbindung:", "statusUnknown": "Nicht getestet", "testConnection": "Verbindung testen", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "{{ordinal}} von {{count}}", "automationPrimaryOnly": "Die Browser-Automatisierung läuft nur auf Ihrem Hauptgerät (Gerät 1). Melden Sie sich dort ab, um sie hier zu nutzen.", "automationActiveHere": "Die Browser-Automatisierung ist auf diesem Gerät aktiv.", - "viewTeamUsage": "Team-Nutzung anzeigen" + "viewTeamUsage": "Team-Nutzung anzeigen", + "ownServer": { + "title": "Eigener Sync-Server", + "description": "Synchronisieren Sie dieses Konto mit einem donut-sync-Server, den Sie selbst betreiben, statt mit der Donut-Cloud. Das Profillimit Ihres Plans sowie die Rollen und der Gruppenzugriff Ihres Teams bleiben gleich.", + "current": "Synchronisiert mit {{server}}", + "currentCloud": "Synchronisiert mit der Donut-Cloud", + "donutCloud": "Donut-Cloud", + "useDonutCloud": "Donut-Cloud verwenden", + "accountsOn": "Dieser Server akzeptiert Donut-Konten.", + "cloudOnly": "Remote-Sitzungen und Cookie Bot benötigen die Synchronisierung mit der Donut-Cloud.", + "teamNote": "Alle Mitglieder Ihres Teams müssen denselben Server verwenden.", + "saved": "Sync-Server gespeichert.", + "backToCloud": "Synchronisiert wieder mit der Donut-Cloud." + } }, "shortcutsPage": { "title": "Tastenkürzel", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9a1dac9f..b15f8928 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -825,7 +825,8 @@ "logout": "Log Out", "logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.", "loginSuccess": "Successfully logged in!", - "logoutSuccess": "Successfully logged out." + "logoutSuccess": "Successfully logged out.", + "server": "Server" }, "team": { "title": "Team", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "This restore point no longer exists.", "restorePointsUnsupported": "The sync server is too old for restore points. Update it.", "restorePointFailed": "The restore point failed: {{detail}}", - "restorePointsNeedSync": "Restore points need sync on for this profile." + "restorePointsNeedSync": "Restore points need sync on for this profile.", + "accountSyncSignInRequired": "Sign in to your Donut account to use your own sync server.", + "accountSyncPlanRequired": "Your own sync server needs a paid plan.", + "syncServerUrlInvalid": "Enter the full server address, for example https://sync.example.com.", + "syncServerUnreachable": "Could not connect to the sync server.", + "syncServerStorageUnreachable": "The sync server answers, but this computer cannot connect to its storage at {{endpoint}}.", + "syncServerNoAccounts": "This server does not accept Donut accounts. Set SYNC_PUBLIC_URL on the server.", + "syncServerAddressMismatch": "The server's address is {{address}}. Enter that address.", + "remoteNeedsDonutSync": "Remote sessions and Cookie Bot get profiles from Donut cloud sync. This computer syncs with your own server.", + "accountSyncUnavailable": "Donut cannot sign in to your own server yet. Try again later." }, "rail": { "profiles": "Profiles", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "Self-hosted sync server", "description": "Point Donut at your own donut-sync server to sync profiles, proxies, groups, and extensions without using the hosted cloud.", - "disabledWhileLoggedIn": "Self-hosted sync is unavailable while you're signed into your Donut account. Sign out to use a custom server.", "connectionStatus": "Connection:", "statusUnknown": "Untested", "testConnection": "Test connection", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "{{ordinal}} of {{count}}", "automationPrimaryOnly": "Browser automation runs only on your primary device (Device 1). Sign out there to use it here.", "automationActiveHere": "Browser automation is active on this device.", - "viewTeamUsage": "View team usage" + "viewTeamUsage": "View team usage", + "ownServer": { + "title": "Your own sync server", + "description": "Sync this account with a donut-sync server that you run, not with Donut cloud. Your plan's profile limit and your team's roles and group access stay the same.", + "current": "Syncing with {{server}}", + "currentCloud": "Syncing with Donut cloud", + "donutCloud": "Donut cloud", + "useDonutCloud": "Use Donut cloud", + "accountsOn": "This server accepts Donut accounts.", + "cloudOnly": "Remote sessions and Cookie Bot need Donut cloud sync.", + "teamNote": "All members of your team must use the same server.", + "saved": "Sync server saved.", + "backToCloud": "Syncing with Donut cloud again." + } }, "shortcutsPage": { "title": "Keyboard shortcuts", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3acc98f8..039de074 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -830,7 +830,8 @@ "logout": "Cerrar Sesión", "logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.", "loginSuccess": "¡Sesión iniciada exitosamente!", - "logoutSuccess": "Sesión cerrada exitosamente." + "logoutSuccess": "Sesión cerrada exitosamente.", + "server": "Servidor" }, "team": { "title": "Equipo", @@ -2684,7 +2685,16 @@ "snapshotNotFound": "Este punto de restauración ya no existe.", "restorePointsUnsupported": "El servidor de sincronización es demasiado antiguo para los puntos de restauración. Actualízalo.", "restorePointFailed": "El punto de restauración falló: {{detail}}", - "restorePointsNeedSync": "Los puntos de restauración necesitan la sincronización activada en este perfil." + "restorePointsNeedSync": "Los puntos de restauración necesitan la sincronización activada en este perfil.", + "accountSyncSignInRequired": "Inicia sesión en tu cuenta de Donut para usar tu propio servidor de sincronización.", + "accountSyncPlanRequired": "Tu propio servidor de sincronización requiere un plan de pago.", + "syncServerUrlInvalid": "Introduce la dirección completa del servidor, por ejemplo https://sync.example.com.", + "syncServerUnreachable": "No se pudo conectar con el servidor de sincronización.", + "syncServerStorageUnreachable": "El servidor de sincronización responde, pero este equipo no puede conectarse a su almacenamiento en {{endpoint}}.", + "syncServerNoAccounts": "Este servidor no acepta cuentas de Donut. Define SYNC_PUBLIC_URL en el servidor.", + "syncServerAddressMismatch": "La dirección del servidor es {{address}}. Introduce esa dirección.", + "remoteNeedsDonutSync": "Las sesiones remotas y Cookie Bot obtienen los perfiles de la sincronización con Donut cloud. Este equipo se sincroniza con tu propio servidor.", + "accountSyncUnavailable": "Donut todavía no puede iniciar sesión en tu propio servidor. Inténtalo más tarde." }, "rail": { "profiles": "Perfiles", @@ -2780,7 +2790,6 @@ "selfHosted": { "title": "Servidor de sincronización autoalojado", "description": "Conecta Donut a tu propio servidor donut-sync para sincronizar perfiles, proxies, grupos y extensiones sin usar la nube alojada.", - "disabledWhileLoggedIn": "La sincronización autoalojada no está disponible mientras estás conectado a tu cuenta de Donut. Cierra sesión para usar un servidor personalizado.", "connectionStatus": "Conexión:", "statusUnknown": "Sin probar", "testConnection": "Probar conexión", @@ -2789,7 +2798,20 @@ "deviceOrdinal": "{{ordinal}} de {{count}}", "automationPrimaryOnly": "La automatización del navegador solo funciona en tu dispositivo principal (Dispositivo 1). Cierra sesión allí para usarla aquí.", "automationActiveHere": "La automatización del navegador está activa en este dispositivo.", - "viewTeamUsage": "Ver el uso del equipo" + "viewTeamUsage": "Ver el uso del equipo", + "ownServer": { + "title": "Tu propio servidor de sincronización", + "description": "Sincroniza esta cuenta con un servidor donut-sync que gestionas tú, no con Donut cloud. El límite de perfiles de tu plan y los roles y el acceso por grupos de tu equipo no cambian.", + "current": "Sincronizando con {{server}}", + "currentCloud": "Sincronizando con Donut cloud", + "donutCloud": "Donut cloud", + "useDonutCloud": "Usar Donut cloud", + "accountsOn": "Este servidor acepta cuentas de Donut.", + "cloudOnly": "Las sesiones remotas y Cookie Bot necesitan la sincronización con Donut cloud.", + "teamNote": "Todos los miembros de tu equipo deben usar el mismo servidor.", + "saved": "Servidor de sincronización guardado.", + "backToCloud": "Sincronizando de nuevo con Donut cloud." + } }, "shortcutsPage": { "title": "Atajos de teclado", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index cc4f7d0f..606c2a24 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -830,7 +830,8 @@ "logout": "Se Déconnecter", "logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.", "loginSuccess": "Connexion réussie !", - "logoutSuccess": "Déconnexion réussie." + "logoutSuccess": "Déconnexion réussie.", + "server": "Serveur" }, "team": { "title": "Équipe", @@ -2684,7 +2685,16 @@ "snapshotNotFound": "Ce point de restauration n’existe plus.", "restorePointsUnsupported": "Le serveur de synchronisation est trop ancien pour les points de restauration. Mettez-le à jour.", "restorePointFailed": "Le point de restauration a échoué : {{detail}}", - "restorePointsNeedSync": "Les points de restauration nécessitent la synchronisation activée pour ce profil." + "restorePointsNeedSync": "Les points de restauration nécessitent la synchronisation activée pour ce profil.", + "accountSyncSignInRequired": "Connectez-vous à votre compte Donut pour utiliser votre propre serveur de synchronisation.", + "accountSyncPlanRequired": "Votre propre serveur de synchronisation nécessite une offre payante.", + "syncServerUrlInvalid": "Saisissez l'adresse complète du serveur, par exemple https://sync.example.com.", + "syncServerUnreachable": "Impossible de se connecter au serveur de synchronisation.", + "syncServerStorageUnreachable": "Le serveur de synchronisation répond, mais cet ordinateur ne peut pas joindre son stockage à {{endpoint}}.", + "syncServerNoAccounts": "Ce serveur n'accepte pas les comptes Donut. Définissez SYNC_PUBLIC_URL sur le serveur.", + "syncServerAddressMismatch": "L'adresse du serveur est {{address}}. Saisissez cette adresse.", + "remoteNeedsDonutSync": "Les sessions à distance et Cookie Bot obtiennent les profils depuis la synchronisation Donut cloud. Cet ordinateur se synchronise avec votre propre serveur.", + "accountSyncUnavailable": "Donut ne peut pas encore se connecter à votre propre serveur. Réessayez plus tard." }, "rail": { "profiles": "Profils", @@ -2780,7 +2790,6 @@ "selfHosted": { "title": "Serveur de synchronisation auto-hébergé", "description": "Connectez Donut à votre propre serveur donut-sync pour synchroniser profils, proxys, groupes et extensions sans utiliser le cloud hébergé.", - "disabledWhileLoggedIn": "La synchronisation auto-hébergée n'est pas disponible lorsque vous êtes connecté à votre compte Donut. Déconnectez-vous pour utiliser un serveur personnalisé.", "connectionStatus": "Connexion :", "statusUnknown": "Non testé", "testConnection": "Tester la connexion", @@ -2789,7 +2798,20 @@ "deviceOrdinal": "{{ordinal}} sur {{count}}", "automationPrimaryOnly": "L'automatisation du navigateur ne fonctionne que sur votre appareil principal (Appareil 1). Déconnectez-vous là-bas pour l'utiliser ici.", "automationActiveHere": "L'automatisation du navigateur est active sur cet appareil.", - "viewTeamUsage": "Voir l'utilisation de l'équipe" + "viewTeamUsage": "Voir l'utilisation de l'équipe", + "ownServer": { + "title": "Votre propre serveur de synchronisation", + "description": "Synchronisez ce compte avec un serveur donut-sync que vous gérez, et non avec Donut cloud. La limite de profils de votre offre ainsi que les rôles et l'accès par groupe de votre équipe restent les mêmes.", + "current": "Synchronisation avec {{server}}", + "currentCloud": "Synchronisation avec Donut cloud", + "donutCloud": "Donut cloud", + "useDonutCloud": "Utiliser Donut cloud", + "accountsOn": "Ce serveur accepte les comptes Donut.", + "cloudOnly": "Les sessions à distance et Cookie Bot nécessitent la synchronisation avec Donut cloud.", + "teamNote": "Tous les membres de votre équipe doivent utiliser le même serveur.", + "saved": "Serveur de synchronisation enregistré.", + "backToCloud": "Synchronisation de nouveau avec Donut cloud." + } }, "shortcutsPage": { "title": "Raccourcis clavier", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index df4e4709..0bbe10f3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -825,7 +825,8 @@ "logout": "ログアウト", "logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。", "loginSuccess": "ログインに成功しました!", - "logoutSuccess": "ログアウトしました。" + "logoutSuccess": "ログアウトしました。", + "server": "サーバー" }, "team": { "title": "チーム", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "この復元ポイントはもう存在しません。", "restorePointsUnsupported": "同期サーバーが古いため、復元ポイントを使えません。更新してください。", "restorePointFailed": "復元ポイントの処理に失敗しました: {{detail}}", - "restorePointsNeedSync": "復元ポイントには、このプロファイルの同期をオンにする必要があります。" + "restorePointsNeedSync": "復元ポイントには、このプロファイルの同期をオンにする必要があります。", + "accountSyncSignInRequired": "独自の同期サーバーを使用するには、Donut アカウントにサインインしてください。", + "accountSyncPlanRequired": "独自の同期サーバーには有料プランが必要です。", + "syncServerUrlInvalid": "サーバーの完全なアドレスを入力してください(例: https://sync.example.com)。", + "syncServerUnreachable": "同期サーバーに接続できませんでした。", + "syncServerStorageUnreachable": "同期サーバーは応答していますが、このコンピューターは {{endpoint}} のストレージに接続できません。", + "syncServerNoAccounts": "このサーバーは Donut アカウントを受け付けません。サーバーで SYNC_PUBLIC_URL を設定してください。", + "syncServerAddressMismatch": "サーバーのアドレスは {{address}} です。そのアドレスを入力してください。", + "remoteNeedsDonutSync": "リモートセッションと Cookie Bot は Donut クラウド同期からプロファイルを取得します。このコンピューターは独自のサーバーと同期しています。", + "accountSyncUnavailable": "Donut はまだ独自のサーバーにサインインできません。後でもう一度お試しください。" }, "rail": { "profiles": "プロファイル", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "セルフホスト同期サーバー", "description": "Donut を独自の donut-sync サーバーに接続して、ホスト型クラウドを使わずにプロファイル、プロキシ、グループ、拡張機能を同期します。", - "disabledWhileLoggedIn": "Donut アカウントにサインインしている間はセルフホスト同期を利用できません。カスタムサーバーを使うにはサインアウトしてください。", "connectionStatus": "接続:", "statusUnknown": "未テスト", "testConnection": "接続をテスト", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "{{count}} 台中 {{ordinal}} 台目", "automationPrimaryOnly": "ブラウザの自動化はプライマリデバイス(デバイス1)でのみ実行できます。ここで使用するには、そのデバイスでサインアウトしてください。", "automationActiveHere": "ブラウザの自動化はこのデバイスで有効です。", - "viewTeamUsage": "チームの使用状況を表示" + "viewTeamUsage": "チームの使用状況を表示", + "ownServer": { + "title": "独自の同期サーバー", + "description": "このアカウントを Donut クラウドではなく、自分で運用する donut-sync サーバーと同期します。プランのプロファイル上限と、チームのロールおよびグループアクセスは変わりません。", + "current": "{{server}} と同期中", + "currentCloud": "Donut クラウドと同期中", + "donutCloud": "Donut クラウド", + "useDonutCloud": "Donut クラウドを使用", + "accountsOn": "このサーバーは Donut アカウントを受け付けます。", + "cloudOnly": "リモートセッションと Cookie Bot には Donut クラウド同期が必要です。", + "teamNote": "チームの全メンバーが同じサーバーを使用する必要があります。", + "saved": "同期サーバーを保存しました。", + "backToCloud": "再び Donut クラウドと同期しています。" + } }, "shortcutsPage": { "title": "キーボードショートカット", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d44e28ae..003dd360 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -825,7 +825,8 @@ "logout": "로그아웃", "logoutConfirm": "정말 로그아웃하시겠습니까? 클라우드 동기화가 중지됩니다.", "loginSuccess": "로그인 성공!", - "logoutSuccess": "로그아웃되었습니다." + "logoutSuccess": "로그아웃되었습니다.", + "server": "서버" }, "team": { "title": "팀", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "이 복원 지점은 더 이상 없습니다.", "restorePointsUnsupported": "동기화 서버가 너무 오래되어 복원 지점을 쓸 수 없습니다. 업데이트하세요.", "restorePointFailed": "복원 지점 작업이 실패했습니다: {{detail}}", - "restorePointsNeedSync": "복원 지점을 쓰려면 이 프로필의 동기화를 켜야 합니다." + "restorePointsNeedSync": "복원 지점을 쓰려면 이 프로필의 동기화를 켜야 합니다.", + "accountSyncSignInRequired": "자체 동기화 서버를 사용하려면 Donut 계정에 로그인하세요.", + "accountSyncPlanRequired": "자체 동기화 서버에는 유료 플랜이 필요합니다.", + "syncServerUrlInvalid": "서버의 전체 주소를 입력하세요. 예: https://sync.example.com", + "syncServerUnreachable": "동기화 서버에 연결할 수 없습니다.", + "syncServerStorageUnreachable": "동기화 서버는 응답하지만 이 컴퓨터가 {{endpoint}}의 저장소에 연결할 수 없습니다.", + "syncServerNoAccounts": "이 서버는 Donut 계정을 허용하지 않습니다. 서버에서 SYNC_PUBLIC_URL을 설정하세요.", + "syncServerAddressMismatch": "서버 주소는 {{address}}입니다. 그 주소를 입력하세요.", + "remoteNeedsDonutSync": "원격 세션과 Cookie Bot은 Donut 클라우드 동기화에서 프로필을 가져옵니다. 이 컴퓨터는 자체 서버와 동기화합니다.", + "accountSyncUnavailable": "Donut이 아직 자체 서버에 로그인할 수 없습니다. 나중에 다시 시도하세요." }, "rail": { "profiles": "프로필", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "자체 호스팅 동기화 서버", "description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 Donut을 자체 donut-sync 서버로 연결하세요.", - "disabledWhileLoggedIn": "Donut 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.", "connectionStatus": "연결:", "statusUnknown": "테스트 안 됨", "testConnection": "연결 테스트", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "{{count}}대 중 {{ordinal}}번째", "automationPrimaryOnly": "브라우저 자동화는 기본 기기(기기 1)에서만 실행됩니다. 여기서 사용하려면 해당 기기에서 로그아웃하세요.", "automationActiveHere": "이 기기에서 브라우저 자동화가 활성화되어 있습니다.", - "viewTeamUsage": "팀 사용량 보기" + "viewTeamUsage": "팀 사용량 보기", + "ownServer": { + "title": "자체 동기화 서버", + "description": "이 계정을 Donut 클라우드가 아니라 직접 운영하는 donut-sync 서버와 동기화합니다. 플랜의 프로필 한도와 팀의 역할 및 그룹 접근 권한은 그대로 유지됩니다.", + "current": "{{server}}와(과) 동기화 중", + "currentCloud": "Donut 클라우드와 동기화 중", + "donutCloud": "Donut 클라우드", + "useDonutCloud": "Donut 클라우드 사용", + "accountsOn": "이 서버는 Donut 계정을 허용합니다.", + "cloudOnly": "원격 세션과 Cookie Bot에는 Donut 클라우드 동기화가 필요합니다.", + "teamNote": "팀의 모든 구성원이 같은 서버를 사용해야 합니다.", + "saved": "동기화 서버를 저장했습니다.", + "backToCloud": "다시 Donut 클라우드와 동기화합니다." + } }, "shortcutsPage": { "title": "키보드 단축키", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7f5c8e5b..047eb592 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -830,7 +830,8 @@ "logout": "Sair", "logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.", "loginSuccess": "Login realizado com sucesso!", - "logoutSuccess": "Logout realizado com sucesso." + "logoutSuccess": "Logout realizado com sucesso.", + "server": "Servidor" }, "team": { "title": "Equipe", @@ -2684,7 +2685,16 @@ "snapshotNotFound": "Este ponto de restauração não existe mais.", "restorePointsUnsupported": "O servidor de sincronização é antigo demais para pontos de restauração. Atualize-o.", "restorePointFailed": "O ponto de restauração falhou: {{detail}}", - "restorePointsNeedSync": "Pontos de restauração precisam da sincronização ativada neste perfil." + "restorePointsNeedSync": "Pontos de restauração precisam da sincronização ativada neste perfil.", + "accountSyncSignInRequired": "Entre na sua conta Donut para usar o seu próprio servidor de sincronização.", + "accountSyncPlanRequired": "O seu próprio servidor de sincronização requer um plano pago.", + "syncServerUrlInvalid": "Digite o endereço completo do servidor, por exemplo https://sync.example.com.", + "syncServerUnreachable": "Não foi possível conectar ao servidor de sincronização.", + "syncServerStorageUnreachable": "O servidor de sincronização responde, mas este computador não consegue conectar ao armazenamento em {{endpoint}}.", + "syncServerNoAccounts": "Este servidor não aceita contas Donut. Defina SYNC_PUBLIC_URL no servidor.", + "syncServerAddressMismatch": "O endereço do servidor é {{address}}. Digite esse endereço.", + "remoteNeedsDonutSync": "Sessões remotas e o Cookie Bot obtêm os perfis da sincronização com a Donut cloud. Este computador sincroniza com o seu próprio servidor.", + "accountSyncUnavailable": "O Donut ainda não consegue entrar no seu próprio servidor. Tente novamente mais tarde." }, "rail": { "profiles": "Perfis", @@ -2780,7 +2790,6 @@ "selfHosted": { "title": "Servidor de sincronização auto-hospedado", "description": "Conecte o Donut ao seu próprio servidor donut-sync para sincronizar perfis, proxies, grupos e extensões sem usar a nuvem hospedada.", - "disabledWhileLoggedIn": "A sincronização auto-hospedada não está disponível enquanto você está conectado à sua conta Donut. Saia para usar um servidor personalizado.", "connectionStatus": "Conexão:", "statusUnknown": "Não testado", "testConnection": "Testar conexão", @@ -2789,7 +2798,20 @@ "deviceOrdinal": "{{ordinal}} de {{count}}", "automationPrimaryOnly": "A automação do navegador funciona apenas no seu dispositivo principal (Dispositivo 1). Saia da conta nele para usá-la aqui.", "automationActiveHere": "A automação do navegador está ativa neste dispositivo.", - "viewTeamUsage": "Ver o uso da equipe" + "viewTeamUsage": "Ver o uso da equipe", + "ownServer": { + "title": "O seu próprio servidor de sincronização", + "description": "Sincronize esta conta com um servidor donut-sync que você opera, e não com a Donut cloud. O limite de perfis do seu plano e as funções e o acesso por grupo da sua equipe continuam iguais.", + "current": "Sincronizando com {{server}}", + "currentCloud": "Sincronizando com a Donut cloud", + "donutCloud": "Donut cloud", + "useDonutCloud": "Usar a Donut cloud", + "accountsOn": "Este servidor aceita contas Donut.", + "cloudOnly": "Sessões remotas e o Cookie Bot precisam da sincronização com a Donut cloud.", + "teamNote": "Todos os membros da sua equipe devem usar o mesmo servidor.", + "saved": "Servidor de sincronização salvo.", + "backToCloud": "Sincronizando com a Donut cloud novamente." + } }, "shortcutsPage": { "title": "Atalhos de teclado", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 000c3c73..17ecabbd 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -835,7 +835,8 @@ "logout": "Выйти", "logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.", "loginSuccess": "Вход выполнен успешно!", - "logoutSuccess": "Выход выполнен успешно." + "logoutSuccess": "Выход выполнен успешно.", + "server": "Сервер" }, "team": { "title": "Команда", @@ -2704,7 +2705,16 @@ "snapshotNotFound": "Этой точки восстановления больше нет.", "restorePointsUnsupported": "Сервер синхронизации слишком старый для точек восстановления. Обновите его.", "restorePointFailed": "Ошибка точки восстановления: {{detail}}", - "restorePointsNeedSync": "Для точек восстановления нужна включённая синхронизация этого профиля." + "restorePointsNeedSync": "Для точек восстановления нужна включённая синхронизация этого профиля.", + "accountSyncSignInRequired": "Войдите в аккаунт Donut, чтобы использовать свой сервер синхронизации.", + "accountSyncPlanRequired": "Для своего сервера синхронизации нужен платный тариф.", + "syncServerUrlInvalid": "Введите полный адрес сервера, например https://sync.example.com.", + "syncServerUnreachable": "Не удалось подключиться к серверу синхронизации.", + "syncServerStorageUnreachable": "Сервер синхронизации отвечает, но этот компьютер не может подключиться к его хранилищу по адресу {{endpoint}}.", + "syncServerNoAccounts": "Этот сервер не принимает аккаунты Donut. Задайте SYNC_PUBLIC_URL на сервере.", + "syncServerAddressMismatch": "Адрес сервера — {{address}}. Введите этот адрес.", + "remoteNeedsDonutSync": "Удалённые сеансы и Cookie Bot получают профили из синхронизации Donut cloud. Этот компьютер синхронизируется с вашим сервером.", + "accountSyncUnavailable": "Donut пока не может войти на ваш сервер. Попробуйте позже." }, "rail": { "profiles": "Профили", @@ -2800,7 +2810,6 @@ "selfHosted": { "title": "Свой сервер синхронизации", "description": "Подключите Donut к собственному серверу donut-sync, чтобы синхронизировать профили, прокси, группы и расширения без использования облака.", - "disabledWhileLoggedIn": "Свой сервер синхронизации недоступен, пока вы вошли в аккаунт Donut. Выйдите из аккаунта, чтобы использовать собственный сервер.", "connectionStatus": "Соединение:", "statusUnknown": "Не проверено", "testConnection": "Проверить соединение", @@ -2809,7 +2818,20 @@ "deviceOrdinal": "{{ordinal}} из {{count}}", "automationPrimaryOnly": "Автоматизация браузера работает только на вашем основном устройстве (Устройство 1). Выйдите из аккаунта на нём, чтобы использовать её здесь.", "automationActiveHere": "Автоматизация браузера активна на этом устройстве.", - "viewTeamUsage": "Посмотреть использование командой" + "viewTeamUsage": "Посмотреть использование командой", + "ownServer": { + "title": "Свой сервер синхронизации для аккаунта", + "description": "Синхронизируйте этот аккаунт со своим сервером donut-sync, а не с Donut cloud. Лимит профилей вашего тарифа, роли и доступ по группам в команде остаются прежними.", + "current": "Синхронизация с {{server}}", + "currentCloud": "Синхронизация с Donut cloud", + "donutCloud": "Donut cloud", + "useDonutCloud": "Использовать Donut cloud", + "accountsOn": "Этот сервер принимает аккаунты Donut.", + "cloudOnly": "Для удалённых сеансов и Cookie Bot нужна синхронизация с Donut cloud.", + "teamNote": "Все участники команды должны использовать один и тот же сервер.", + "saved": "Сервер синхронизации сохранён.", + "backToCloud": "Снова синхронизация с Donut cloud." + } }, "shortcutsPage": { "title": "Сочетания клавиш", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 6697ef0a..60e3982d 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -825,7 +825,8 @@ "logout": "Çıkış Yap", "logoutConfirm": "Çıkış yapmak istediğinizden emin misiniz? Bulut eşitlemesi duracak.", "loginSuccess": "Başarıyla giriş yapıldı!", - "logoutSuccess": "Başarıyla çıkış yapıldı." + "logoutSuccess": "Başarıyla çıkış yapıldı.", + "server": "Sunucu" }, "team": { "title": "Ekip", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "Bu geri yükleme noktası artık yok.", "restorePointsUnsupported": "Eşitleme sunucusu geri yükleme noktaları için çok eski. Güncelle.", "restorePointFailed": "Geri yükleme noktası başarısız oldu: {{detail}}", - "restorePointsNeedSync": "Geri yükleme noktaları için bu profilde eşitleme açık olmalı." + "restorePointsNeedSync": "Geri yükleme noktaları için bu profilde eşitleme açık olmalı.", + "accountSyncSignInRequired": "Kendi eşitleme sunucunuzu kullanmak için Donut hesabınızla oturum açın.", + "accountSyncPlanRequired": "Kendi eşitleme sunucunuz için ücretli bir plan gerekir.", + "syncServerUrlInvalid": "Sunucunun tam adresini girin, örneğin https://sync.example.com.", + "syncServerUnreachable": "Eşitleme sunucusuna bağlanılamadı.", + "syncServerStorageUnreachable": "Eşitleme sunucusu yanıt veriyor, ancak bu bilgisayar {{endpoint}} adresindeki depolamaya bağlanamıyor.", + "syncServerNoAccounts": "Bu sunucu Donut hesaplarını kabul etmiyor. Sunucuda SYNC_PUBLIC_URL değerini ayarlayın.", + "syncServerAddressMismatch": "Sunucunun adresi {{address}}. Bu adresi girin.", + "remoteNeedsDonutSync": "Uzak oturumlar ve Cookie Bot profilleri Donut cloud eşitlemesinden alır. Bu bilgisayar kendi sunucunuzla eşitleniyor.", + "accountSyncUnavailable": "Donut henüz kendi sunucunuzda oturum açamıyor. Daha sonra tekrar deneyin." }, "rail": { "profiles": "Profiller", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "Kendi eşitleme sunucunuz", "description": "Barındırılan bulutu kullanmadan profilleri, proxy'leri, grupları ve uzantıları eşitlemek için Donut'u kendi donut-sync sunucunuza yönlendirin.", - "disabledWhileLoggedIn": "Donut hesabınızda oturum açıkken kendi sunucunuzla eşitleme kullanılamaz. Özel bir sunucu kullanmak için çıkış yapın.", "connectionStatus": "Bağlantı:", "statusUnknown": "Test edilmedi", "testConnection": "Bağlantıyı test et", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "{{ordinal}} / {{count}}", "automationPrimaryOnly": "Tarayıcı otomasyonu yalnızca birincil cihazınızda (Cihaz 1) çalışır. Burada kullanmak için oradaki oturumu kapatın.", "automationActiveHere": "Tarayıcı otomasyonu bu cihazda etkin.", - "viewTeamUsage": "Ekip kullanımını görüntüle" + "viewTeamUsage": "Ekip kullanımını görüntüle", + "ownServer": { + "title": "Kendi eşitleme sunucunuz (hesapla)", + "description": "Bu hesabı Donut cloud yerine kendi çalıştırdığınız bir donut-sync sunucusuyla eşitleyin. Planınızın profil sınırı ve ekibinizin rolleri ile grup erişimi aynı kalır.", + "current": "{{server}} ile eşitleniyor", + "currentCloud": "Donut cloud ile eşitleniyor", + "donutCloud": "Donut cloud", + "useDonutCloud": "Donut cloud'u kullan", + "accountsOn": "Bu sunucu Donut hesaplarını kabul ediyor.", + "cloudOnly": "Uzak oturumlar ve Cookie Bot, Donut cloud eşitlemesi gerektirir.", + "teamNote": "Ekibinizin tüm üyeleri aynı sunucuyu kullanmalıdır.", + "saved": "Eşitleme sunucusu kaydedildi.", + "backToCloud": "Yeniden Donut cloud ile eşitleniyor." + } }, "shortcutsPage": { "title": "Klavye kısayolları", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index ddf8d376..902cdaa4 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -825,7 +825,8 @@ "logout": "Đăng xuất", "logoutConfirm": "Bạn có chắc muốn đăng xuất? Đồng bộ đám mây sẽ dừng lại.", "loginSuccess": "Đăng nhập thành công!", - "logoutSuccess": "Đăng xuất thành công." + "logoutSuccess": "Đăng xuất thành công.", + "server": "Máy chủ" }, "team": { "title": "Nhóm", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "Điểm khôi phục này không còn nữa.", "restorePointsUnsupported": "Máy chủ đồng bộ quá cũ để dùng điểm khôi phục. Hãy cập nhật.", "restorePointFailed": "Điểm khôi phục thất bại: {{detail}}", - "restorePointsNeedSync": "Điểm khôi phục cần bật đồng bộ cho hồ sơ này." + "restorePointsNeedSync": "Điểm khôi phục cần bật đồng bộ cho hồ sơ này.", + "accountSyncSignInRequired": "Hãy đăng nhập tài khoản Donut để dùng máy chủ đồng bộ riêng.", + "accountSyncPlanRequired": "Máy chủ đồng bộ riêng cần gói trả phí.", + "syncServerUrlInvalid": "Nhập địa chỉ đầy đủ của máy chủ, ví dụ https://sync.example.com.", + "syncServerUnreachable": "Không kết nối được tới máy chủ đồng bộ.", + "syncServerStorageUnreachable": "Máy chủ đồng bộ có phản hồi, nhưng máy tính này không kết nối được tới bộ lưu trữ tại {{endpoint}}.", + "syncServerNoAccounts": "Máy chủ này không chấp nhận tài khoản Donut. Hãy đặt SYNC_PUBLIC_URL trên máy chủ.", + "syncServerAddressMismatch": "Địa chỉ của máy chủ là {{address}}. Hãy nhập địa chỉ đó.", + "remoteNeedsDonutSync": "Phiên từ xa và Cookie Bot lấy hồ sơ từ đồng bộ Donut cloud. Máy tính này đồng bộ với máy chủ riêng của bạn.", + "accountSyncUnavailable": "Donut chưa thể đăng nhập vào máy chủ riêng của bạn. Hãy thử lại sau." }, "rail": { "profiles": "Profile", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "Máy chủ đồng bộ tự lưu trữ", "description": "Trỏ Donut đến máy chủ donut-sync của riêng bạn để đồng bộ profile, proxy, nhóm và tiện ích mà không cần sử dụng đám mây.", - "disabledWhileLoggedIn": "Đồng bộ tự lưu trữ không khả dụng khi bạn đã đăng nhập vào tài khoản Donut. Đăng xuất để sử dụng máy chủ tùy chỉnh.", "connectionStatus": "Kết nối:", "statusUnknown": "Chưa kiểm tra", "testConnection": "Kiểm tra kết nối", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "{{ordinal}} trên {{count}}", "automationPrimaryOnly": "Tự động hóa trình duyệt chỉ chạy trên thiết bị chính của bạn (Thiết bị 1). Đăng xuất ở đó để sử dụng tại đây.", "automationActiveHere": "Tự động hóa trình duyệt đang hoạt động trên thiết bị này.", - "viewTeamUsage": "Xem mức dùng của nhóm" + "viewTeamUsage": "Xem mức dùng của nhóm", + "ownServer": { + "title": "Máy chủ đồng bộ riêng của bạn", + "description": "Đồng bộ tài khoản này với máy chủ donut-sync do bạn vận hành, thay vì Donut cloud. Giới hạn hồ sơ của gói và vai trò, quyền truy cập nhóm của đội bạn vẫn giữ nguyên.", + "current": "Đang đồng bộ với {{server}}", + "currentCloud": "Đang đồng bộ với Donut cloud", + "donutCloud": "Donut cloud", + "useDonutCloud": "Dùng Donut cloud", + "accountsOn": "Máy chủ này chấp nhận tài khoản Donut.", + "cloudOnly": "Phiên từ xa và Cookie Bot cần đồng bộ với Donut cloud.", + "teamNote": "Tất cả thành viên trong đội phải dùng cùng một máy chủ.", + "saved": "Đã lưu máy chủ đồng bộ.", + "backToCloud": "Đang đồng bộ lại với Donut cloud." + } }, "shortcutsPage": { "title": "Phím tắt", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 105d0eaf..6203f0f2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -825,7 +825,8 @@ "logout": "退出登录", "logoutConfirm": "您确定要退出登录吗?云同步将会停止。", "loginSuccess": "登录成功!", - "logoutSuccess": "已成功退出登录。" + "logoutSuccess": "已成功退出登录。", + "server": "服务器" }, "team": { "title": "团队", @@ -2664,7 +2665,16 @@ "snapshotNotFound": "此还原点已不存在。", "restorePointsUnsupported": "同步服务器版本太旧,无法使用还原点。请更新它。", "restorePointFailed": "还原点操作失败:{{detail}}", - "restorePointsNeedSync": "使用还原点需要为此配置文件开启同步。" + "restorePointsNeedSync": "使用还原点需要为此配置文件开启同步。", + "accountSyncSignInRequired": "请登录 Donut 账户以使用你自己的同步服务器。", + "accountSyncPlanRequired": "使用你自己的同步服务器需要付费套餐。", + "syncServerUrlInvalid": "请输入服务器的完整地址,例如 https://sync.example.com。", + "syncServerUnreachable": "无法连接到同步服务器。", + "syncServerStorageUnreachable": "同步服务器有响应,但此计算机无法连接到其位于 {{endpoint}} 的存储。", + "syncServerNoAccounts": "此服务器不接受 Donut 账户。请在服务器上设置 SYNC_PUBLIC_URL。", + "syncServerAddressMismatch": "服务器的地址是 {{address}}。请输入该地址。", + "remoteNeedsDonutSync": "远程会话和 Cookie Bot 从 Donut 云同步获取配置文件。此计算机正在与你自己的服务器同步。", + "accountSyncUnavailable": "Donut 暂时无法登录你自己的服务器。请稍后重试。" }, "rail": { "profiles": "配置文件", @@ -2760,7 +2770,6 @@ "selfHosted": { "title": "自托管同步服务器", "description": "将 Donut 连接到您自己的 donut-sync 服务器,无需使用托管云即可同步配置文件、代理、组和扩展程序。", - "disabledWhileLoggedIn": "登录 Donut 账户时无法使用自托管同步。请先退出登录以使用自定义服务器。", "connectionStatus": "连接:", "statusUnknown": "未测试", "testConnection": "测试连接", @@ -2769,7 +2778,20 @@ "deviceOrdinal": "第 {{ordinal}} 台,共 {{count}} 台", "automationPrimaryOnly": "浏览器自动化仅在您的主设备(设备 1)上运行。请在该设备上退出登录,才能在此设备上使用。", "automationActiveHere": "浏览器自动化已在此设备上启用。", - "viewTeamUsage": "查看团队用量" + "viewTeamUsage": "查看团队用量", + "ownServer": { + "title": "你自己的同步服务器", + "description": "将此账户与你自己运行的 donut-sync 服务器同步,而不是与 Donut 云同步。你的套餐配置文件上限以及团队的角色和分组访问权限保持不变。", + "current": "正在与 {{server}} 同步", + "currentCloud": "正在与 Donut 云同步", + "donutCloud": "Donut 云", + "useDonutCloud": "使用 Donut 云", + "accountsOn": "此服务器接受 Donut 账户。", + "cloudOnly": "远程会话和 Cookie Bot 需要 Donut 云同步。", + "teamNote": "团队的所有成员必须使用同一台服务器。", + "saved": "同步服务器已保存。", + "backToCloud": "已重新与 Donut 云同步。" + } }, "shortcutsPage": { "title": "键盘快捷键", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 5add285c..47381dea 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -56,6 +56,15 @@ export type BackendErrorCode = | "COOKIE_IMPORT_REMOTE_SESSION" | "COOKIE_IMPORT_NO_COOKIES" | "SELF_HOSTED_REQUIRES_LOGOUT" + | "ACCOUNT_SYNC_SIGN_IN_REQUIRED" + | "ACCOUNT_SYNC_PLAN_REQUIRED" + | "ACCOUNT_SYNC_UNAVAILABLE" + | "SYNC_SERVER_URL_INVALID" + | "SYNC_SERVER_UNREACHABLE" + | "SYNC_SERVER_STORAGE_UNREACHABLE" + | "SYNC_SERVER_NO_ACCOUNTS" + | "SYNC_SERVER_ADDRESS_MISMATCH" + | "REMOTE_NEEDS_DONUT_SYNC" | "PROXY_NOT_FOUND" | "GROUP_NOT_FOUND" | "GROUP_ALREADY_EXISTS" @@ -487,6 +496,28 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.cookieImportNoCookies"); case "SELF_HOSTED_REQUIRES_LOGOUT": return t("backendErrors.selfHostedRequiresLogout"); + case "ACCOUNT_SYNC_SIGN_IN_REQUIRED": + return t("backendErrors.accountSyncSignInRequired"); + case "ACCOUNT_SYNC_PLAN_REQUIRED": + return t("backendErrors.accountSyncPlanRequired"); + case "ACCOUNT_SYNC_UNAVAILABLE": + return t("backendErrors.accountSyncUnavailable"); + case "SYNC_SERVER_URL_INVALID": + return t("backendErrors.syncServerUrlInvalid"); + case "SYNC_SERVER_UNREACHABLE": + return t("backendErrors.syncServerUnreachable"); + case "SYNC_SERVER_STORAGE_UNREACHABLE": + return t("backendErrors.syncServerStorageUnreachable", { + endpoint: parsed.params?.endpoint ?? "", + }); + case "SYNC_SERVER_NO_ACCOUNTS": + return t("backendErrors.syncServerNoAccounts"); + case "SYNC_SERVER_ADDRESS_MISMATCH": + return t("backendErrors.syncServerAddressMismatch", { + address: parsed.params?.address ?? "", + }); + case "REMOTE_NEEDS_DONUT_SYNC": + return t("backendErrors.remoteNeedsDonutSync"); case "PROXY_NOT_FOUND": return t("backendErrors.proxyNotFound"); case "GROUP_NOT_FOUND": diff --git a/src/types.ts b/src/types.ts index ad461040..a71a27f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,8 @@ export interface SyncServerCheck { storage_endpoint: string | null; storage_reachable: boolean | null; storage_error: string | null; + /** The address Donut accounts sign in to the server with, when allowed. */ + account_url?: string | null; } /**