diff --git a/.env.example b/.env.example index c158a89..4c6d869 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ S3_BUCKET=pds-blobs AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin # ============================================================================= +# Backups (S3-compatible) +# ============================================================================= +# Set to enable automatic repo backups to S3 +# BACKUP_S3_BUCKET=pds-backups +# BACKUP_ENABLED=true +# ============================================================================= # Valkey (for caching and distributed rate limiting) # ============================================================================= # If not set, falls back to in-memory caching (single-node only) diff --git a/docs/install-alpine.md b/docs/install-alpine.md index 34b9658..81eb5ac 100644 --- a/docs/install-alpine.md +++ b/docs/install-alpine.md @@ -68,13 +68,14 @@ chmod +x /etc/init.d/minio rc-update add minio rc-service minio start ``` -Create the blob bucket (wait a few seconds for minio to start): +Create the buckets (wait a few seconds for minio to start): ```sh curl -O https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc mv mc /usr/local/bin/ mc alias set local http://localhost:9000 minioadmin your-minio-password mc mb local/pds-blobs +mc mb local/pds-backups ``` ## 5. Install valkey ```sh @@ -239,3 +240,26 @@ Backup database: ```sh pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql ``` + +## Custom Homepage + +Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. + +```sh +cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF' + + + + Welcome to my PDS + + + +

Welcome to my amazing zoo pen

+

This is a AT Protocol Personal Data Server.

+

Sign in or learn more at Bluesky.

+ + +EOF +``` diff --git a/docs/install-containers.md b/docs/install-containers.md index 8f76005..3b5c07e 100644 --- a/docs/install-containers.md +++ b/docs/install-containers.md @@ -82,13 +82,13 @@ systemctl start tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey sleep 10 ``` -Create the minio bucket: +Create the minio buckets: ```bash podman run --rm --pod tranquil-pds \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=your-minio-password \ docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \ - sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs" + sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups" ``` Run migrations: @@ -230,14 +230,14 @@ rc-service tranquil-pds start sleep 15 ``` -Create the minio bucket: +Create the minio buckets: ```sh source /srv/tranquil-pds/config/tranquil-pds.env podman run --rm --network tranquil-pds_default \ -e MINIO_ROOT_USER="$MINIO_ROOT_USER" \ -e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \ docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \ - sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs' + sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups' ``` Run migrations: @@ -350,3 +350,24 @@ podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(dat ```sh podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql ``` + +## Custom Homepage + +Mount a `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. + +```html + + + + Welcome to my PDS + + + +

Welcome to my dark web popsocket store

+

This is a AT Protocol Personal Data Server.

+

Sign in or learn more at Bluesky.

+ + +``` diff --git a/docs/install-debian.md b/docs/install-debian.md index 7744c08..4b09265 100644 --- a/docs/install-debian.md +++ b/docs/install-debian.md @@ -59,13 +59,14 @@ systemctl daemon-reload systemctl enable minio systemctl start minio ``` -Create the blob bucket (wait a few seconds for minio to start): +Create the buckets (wait a few seconds for minio to start): ```bash curl -O https://dl.min.io/client/mc/release/linux-amd64/mc chmod +x mc mv mc /usr/local/bin/ mc alias set local http://localhost:9000 minioadmin your-minio-password mc mb local/pds-blobs +mc mb local/pds-backups ``` ## 5. Install valkey ```bash @@ -212,3 +213,26 @@ Backup database: ```bash sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql ``` + +## Custom Homepage + +Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. + +```bash +cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF' + + + + Welcome to my PDS + + + +

Welcome to my secret PDS

+

This is a AT Protocol Personal Data Server.

+

Sign in or learn more at Bluesky.

+ + +EOF +``` diff --git a/docs/install-kubernetes.md b/docs/install-kubernetes.md index 13ce3e3..04ed72b 100644 --- a/docs/install-kubernetes.md +++ b/docs/install-kubernetes.md @@ -12,6 +12,7 @@ You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. Us The container image expects: - `DATABASE_URL` - postgres connection string - `S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET` +- `BACKUP_S3_BUCKET` - bucket for repo backups (optional but recommended) - `VALKEY_URL` - redis:// connection string - `PDS_HOSTNAME` - your PDS hostname (without protocol) - `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48` @@ -20,3 +21,24 @@ and more, check the .env.example. Health check: `GET /xrpc/_health` +## Custom Homepage + +Mount a ConfigMap with your `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: pds-homepage +data: + homepage.html: | + + + Welcome to my PDS + +

Welcome to my little evil secret lab!!!

+

Sign in

+ + +``` + diff --git a/docs/install-openbsd.md b/docs/install-openbsd.md index 996f9db..15febd2 100644 --- a/docs/install-openbsd.md +++ b/docs/install-openbsd.md @@ -72,12 +72,13 @@ chmod +x /etc/rc.d/minio rcctl enable minio rcctl start minio ``` -Create the blob bucket: +Create the buckets: ```sh ftp -o /usr/local/bin/mc https://dl.min.io/client/mc/release/openbsd-amd64/mc chmod +x /usr/local/bin/mc mc alias set local http://localhost:9000 minioadmin your-minio-password mc mb local/pds-blobs +mc mb local/pds-backups ``` ## 5. Install redis OpenBSD has redis in ports (valkey not available yet): @@ -251,3 +252,26 @@ Backup database: ```sh pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql ``` + +## Custom Homepage + +Drop a `homepage.html` in `/var/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything. + +```sh +cat > /var/tranquil-pds/frontend/homepage.html << 'EOF' + + + + Welcome to my PDS + + + +

Welcome to my uma musume shipping site!

+

This is a AT Protocol Personal Data Server.

+

Sign in or learn more at Bluesky.

+ + +EOF +``` diff --git a/frontend/public/homepage.html b/frontend/public/homepage.html new file mode 100644 index 0000000..4917365 --- /dev/null +++ b/frontend/public/homepage.html @@ -0,0 +1,696 @@ + + + + + + Tranquil PDS + + + +
+
+
+
+ + + +
+
+

+ A home for your Bluesky account +

+ +

+ Tranquil PDS is a Personal Data Server, the thing that stores your + posts, profile, and keys. Bluesky runs one for you, but you can run + your own. +

+ + + +
+

"Nature does not hurry, yet everything is accomplished."

+ Lao Tzu +
+
+ +
+

What you get

+ +
+
+

Real security

+

+ Sign in with passkeys, add two-factor authentication, set up + backup codes, and mark devices you trust. Your account stays + yours. +

+
+ +
+

Your own identity

+

+ Use your own domain as your handle, or get a subdomain on ours. + Either way, your identity moves with you if you ever leave. +

+
+ +
+

Stay in the loop

+

+ Get important alerts where you actually see them: email, Discord, + Telegram, or Signal. +

+
+ +
+

You decide what apps can do

+

+ When an app asks for access, you'll see exactly what it wants in + plain language. Grant what makes sense, deny what doesn't. +

+
+ +
+

App passwords with guardrails

+

+ Create app passwords that can only do specific things: read-only + for feed readers, post-only for bots. Full control over what each + password can access. +

+
+ +
+

Delegate without sharing passwords

+

+ Let team members or tools manage your account with specific + permission levels. They authenticate with their own credentials, + you see everything they do in an audit log. +

+
+ +
+

Automatic backups

+

+ Your repository is backed up daily to object storage. Download any + backup or restore with one click. You own your data, even if the + worst happens. +

+
+
+ +

Everything in one place

+ +

+ Manage your profile, security settings, connected apps, and more from + a clean dashboard. No command line or 3rd party apps required. +

+ +

Works with everything

+ +

+ Use any ATProto app you already like. Tranquil PDS speaks the same + language as Bluesky's servers, so all your favorite clients and tools + just work. +

+ +

Ready to try it?

+ +

+ Join this server, or grab the source and run your own. Either way, you + can migrate an existing account over and your followers, posts, and + identity come with you. +

+ + +
+ + +
+ + + + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6119e60..5bac107 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -2,7 +2,7 @@ import { getCurrentPath, navigate } from './lib/router.svelte' import { initAuth, getAuthState } from './lib/auth.svelte' import { initServerConfig } from './lib/serverConfig.svelte' - import { initI18n, _ } from './lib/i18n' + import { initI18n } from './lib/i18n' import { isLoading as i18nLoading } from 'svelte-i18n' import Login from './routes/Login.svelte' import Register from './routes/Register.svelte' @@ -34,13 +34,6 @@ import ActAs from './routes/ActAs.svelte' import Migration from './routes/Migration.svelte' import DidDocumentEditor from './routes/DidDocumentEditor.svelte' - import Home from './routes/Home.svelte' - - if (window.location.pathname === '/migrate') { - const newUrl = `${window.location.origin}/${window.location.search}#/migrate` - window.location.replace(newUrl) - } - initI18n() const auth = getAuthState() @@ -48,7 +41,7 @@ let oauthCallbackPending = $state(hasOAuthCallback()) function hasOAuthCallback(): boolean { - if (window.location.hash === '#/migrate') { + if (window.location.pathname === '/app/migrate') { return false } const params = new URLSearchParams(window.location.search) @@ -59,12 +52,24 @@ initServerConfig() initAuth().then(({ oauthLoginCompleted }) => { if (oauthLoginCompleted) { - navigate('/dashboard') + navigate('/dashboard', true) } oauthCallbackPending = false }) }) + $effect(() => { + if (auth.loading) return + const path = getCurrentPath() + if (path === '/') { + if (auth.session) { + navigate('/dashboard', true) + } else { + navigate('/login', true) + } + } + }) + function getComponent(path: string) { switch (path) { case '/login': @@ -128,7 +133,7 @@ case '/did-document': return DidDocumentEditor default: - return Home + return Login } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b51cc0d..3cf10c8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -237,7 +237,7 @@ export const api = { return data; }, - async confirmSignup( + confirmSignup( did: string, verificationCode: string, ): Promise { @@ -247,32 +247,32 @@ export const api = { }); }, - async resendVerification(did: string): Promise<{ success: boolean }> { + resendVerification(did: string): Promise<{ success: boolean }> { return xrpc("com.atproto.server.resendVerification", { method: "POST", body: { did }, }); }, - async createSession(identifier: string, password: string): Promise { + createSession(identifier: string, password: string): Promise { return xrpc("com.atproto.server.createSession", { method: "POST", body: { identifier, password }, }); }, - async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { + checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { return xrpc("_checkEmailVerified", { method: "POST", body: { identifier }, }); }, - async getSession(token: string): Promise { + getSession(token: string): Promise { return xrpc("com.atproto.server.getSession", { token }); }, - async refreshSession(refreshJwt: string): Promise { + refreshSession(refreshJwt: string): Promise { return xrpc("com.atproto.server.refreshSession", { method: "POST", token: refreshJwt, @@ -286,11 +286,11 @@ export const api = { }); }, - async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { + listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { return xrpc("com.atproto.server.listAppPasswords", { token }); }, - async createAppPassword( + createAppPassword( token: string, name: string, scopes?: string, @@ -312,11 +312,11 @@ export const api = { }); }, - async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { + getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { return xrpc("com.atproto.server.getAccountInviteCodes", { token }); }, - async createInviteCode( + createInviteCode( token: string, useCount: number = 1, ): Promise<{ code: string }> { @@ -341,7 +341,7 @@ export const api = { }); }, - async requestEmailUpdate( + requestEmailUpdate( token: string, ): Promise<{ tokenRequired: boolean }> { return xrpc("com.atproto.server.requestEmailUpdate", { @@ -388,7 +388,7 @@ export const api = { }); }, - async describeServer(): Promise<{ + describeServer(): Promise<{ availableUserDomains: string[]; inviteCodeRequired: boolean; links?: { privacyPolicy?: string; termsOfService?: string }; @@ -399,7 +399,7 @@ export const api = { return xrpc("com.atproto.server.describeServer"); }, - async listRepos(limit?: number): Promise<{ + listRepos(limit?: number): Promise<{ repos: Array<{ did: string; head: string; rev: string }>; cursor?: string; }> { @@ -408,7 +408,7 @@ export const api = { return xrpc("com.atproto.sync.listRepos", { params }); }, - async getNotificationPrefs(token: string): Promise<{ + getNotificationPrefs(token: string): Promise<{ preferredChannel: string; email: string; discordId: string | null; @@ -421,7 +421,7 @@ export const api = { return xrpc("_account.getNotificationPrefs", { token }); }, - async updateNotificationPrefs(token: string, prefs: { + updateNotificationPrefs(token: string, prefs: { preferredChannel?: string; discordId?: string; telegramUsername?: string; @@ -434,7 +434,7 @@ export const api = { }); }, - async confirmChannelVerification( + confirmChannelVerification( token: string, channel: string, identifier: string, @@ -447,7 +447,7 @@ export const api = { }); }, - async getNotificationHistory(token: string): Promise<{ + getNotificationHistory(token: string): Promise<{ notifications: Array<{ createdAt: string; channel: string; @@ -460,7 +460,7 @@ export const api = { return xrpc("_account.getNotificationHistory", { token }); }, - async getServerStats(token: string): Promise<{ + getServerStats(token: string): Promise<{ userCount: number; repoCount: number; recordCount: number; @@ -469,7 +469,7 @@ export const api = { return xrpc("_admin.getServerStats", { token }); }, - async getServerConfig(): Promise<{ + getServerConfig(): Promise<{ serverName: string; primaryColor: string | null; primaryColorDark: string | null; @@ -480,7 +480,7 @@ export const api = { return xrpc("_server.getConfig"); }, - async updateServerConfig( + updateServerConfig( token: string, config: { serverName?: string; @@ -541,24 +541,24 @@ export const api = { }); }, - async removePassword(token: string): Promise<{ success: boolean }> { + removePassword(token: string): Promise<{ success: boolean }> { return xrpc("_account.removePassword", { method: "POST", token, }); }, - async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { + getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { return xrpc("_account.getPasswordStatus", { token }); }, - async getLegacyLoginPreference( + getLegacyLoginPreference( token: string, ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { return xrpc("_account.getLegacyLoginPreference", { token }); }, - async updateLegacyLoginPreference( + updateLegacyLoginPreference( token: string, allowLegacyLogin: boolean, ): Promise<{ allowLegacyLogin: boolean }> { @@ -569,7 +569,7 @@ export const api = { }); }, - async updateLocale( + updateLocale( token: string, preferredLocale: string, ): Promise<{ preferredLocale: string }> { @@ -580,7 +580,7 @@ export const api = { }); }, - async listSessions(token: string): Promise<{ + listSessions(token: string): Promise<{ sessions: Array<{ id: string; sessionType: string; @@ -601,14 +601,14 @@ export const api = { }); }, - async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { + revokeAllSessions(token: string): Promise<{ revokedCount: number }> { return xrpc("_account.revokeAllSessions", { method: "POST", token, }); }, - async searchAccounts(token: string, options?: { + searchAccounts(token: string, options?: { handle?: string; cursor?: string; limit?: number; @@ -630,7 +630,7 @@ export const api = { return xrpc("com.atproto.admin.searchAccounts", { token, params }); }, - async getInviteCodes(token: string, options?: { + getInviteCodes(token: string, options?: { sort?: "recent" | "usage"; cursor?: string; limit?: number; @@ -665,7 +665,7 @@ export const api = { }); }, - async getAccountInfo(token: string, did: string): Promise<{ + getAccountInfo(token: string, did: string): Promise<{ did: string; handle: string; email?: string; @@ -701,7 +701,7 @@ export const api = { }); }, - async describeRepo(token: string, repo: string): Promise<{ + describeRepo(token: string, repo: string): Promise<{ handle: string; did: string; didDoc: unknown; @@ -714,7 +714,7 @@ export const api = { }); }, - async listRecords(token: string, repo: string, collection: string, options?: { + listRecords(token: string, repo: string, collection: string, options?: { limit?: number; cursor?: string; reverse?: boolean; @@ -729,7 +729,7 @@ export const api = { return xrpc("com.atproto.repo.listRecords", { token, params }); }, - async getRecord( + getRecord( token: string, repo: string, collection: string, @@ -745,7 +745,7 @@ export const api = { }); }, - async createRecord( + createRecord( token: string, repo: string, collection: string, @@ -762,7 +762,7 @@ export const api = { }); }, - async putRecord( + putRecord( token: string, repo: string, collection: string, @@ -792,13 +792,13 @@ export const api = { }); }, - async getTotpStatus( + getTotpStatus( token: string, ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { return xrpc("com.atproto.server.getTotpStatus", { token }); }, - async createTotpSecret( + createTotpSecret( token: string, ): Promise<{ uri: string; qrBase64: string }> { return xrpc("com.atproto.server.createTotpSecret", { @@ -807,7 +807,7 @@ export const api = { }); }, - async enableTotp( + enableTotp( token: string, code: string, ): Promise<{ success: boolean; backupCodes: string[] }> { @@ -818,7 +818,7 @@ export const api = { }); }, - async disableTotp( + disableTotp( token: string, password: string, code: string, @@ -830,7 +830,7 @@ export const api = { }); }, - async regenerateBackupCodes( + regenerateBackupCodes( token: string, password: string, code: string, @@ -842,7 +842,7 @@ export const api = { }); }, - async startPasskeyRegistration( + startPasskeyRegistration( token: string, friendlyName?: string, ): Promise<{ options: unknown }> { @@ -853,7 +853,7 @@ export const api = { }); }, - async finishPasskeyRegistration( + finishPasskeyRegistration( token: string, credential: unknown, friendlyName?: string, @@ -865,7 +865,7 @@ export const api = { }); }, - async listPasskeys(token: string): Promise<{ + listPasskeys(token: string): Promise<{ passkeys: Array<{ id: string; credentialId: string; @@ -897,7 +897,7 @@ export const api = { }); }, - async listTrustedDevices(token: string): Promise<{ + listTrustedDevices(token: string): Promise<{ devices: Array<{ id: string; userAgent: string | null; @@ -910,7 +910,7 @@ export const api = { return xrpc("_account.listTrustedDevices", { token }); }, - async revokeTrustedDevice( + revokeTrustedDevice( token: string, deviceId: string, ): Promise<{ success: boolean }> { @@ -921,7 +921,7 @@ export const api = { }); }, - async updateTrustedDevice( + updateTrustedDevice( token: string, deviceId: string, friendlyName: string, @@ -933,7 +933,7 @@ export const api = { }); }, - async getReauthStatus(token: string): Promise<{ + getReauthStatus(token: string): Promise<{ requiresReauth: boolean; lastReauthAt: string | null; availableMethods: string[]; @@ -941,7 +941,7 @@ export const api = { return xrpc("_account.getReauthStatus", { token }); }, - async reauthPassword( + reauthPassword( token: string, password: string, ): Promise<{ success: boolean; reauthAt: string }> { @@ -952,7 +952,7 @@ export const api = { }); }, - async reauthTotp( + reauthTotp( token: string, code: string, ): Promise<{ success: boolean; reauthAt: string }> { @@ -963,14 +963,14 @@ export const api = { }); }, - async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { + reauthPasskeyStart(token: string): Promise<{ options: unknown }> { return xrpc("_account.reauthPasskeyStart", { method: "POST", token, }); }, - async reauthPasskeyFinish( + reauthPasskeyFinish( token: string, credential: unknown, ): Promise<{ success: boolean; reauthAt: string }> { @@ -981,14 +981,14 @@ export const api = { }); }, - async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { + reserveSigningKey(did?: string): Promise<{ signingKey: string }> { return xrpc("com.atproto.server.reserveSigningKey", { method: "POST", body: { did }, }); }, - async getRecommendedDidCredentials(token: string): Promise<{ + getRecommendedDidCredentials(token: string): Promise<{ rotationKeys?: string[]; alsoKnownAs?: string[]; verificationMethods?: { atproto?: string }; @@ -1043,7 +1043,7 @@ export const api = { return res.json(); }, - async startPasskeyRegistrationForSetup( + startPasskeyRegistrationForSetup( did: string, setupToken: string, friendlyName?: string, @@ -1054,7 +1054,7 @@ export const api = { }); }, - async completePasskeySetup( + completePasskeySetup( did: string, setupToken: string, passkeyCredential: unknown, @@ -1071,14 +1071,14 @@ export const api = { }); }, - async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { + requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { return xrpc("_account.requestPasskeyRecovery", { method: "POST", body: { email }, }); }, - async recoverPasskeyAccount( + recoverPasskeyAccount( did: string, recoveryToken: string, newPassword: string, @@ -1089,7 +1089,7 @@ export const api = { }); }, - async verifyMigrationEmail( + verifyMigrationEmail( token: string, email: string, ): Promise<{ success: boolean; did: string }> { @@ -1099,14 +1099,14 @@ export const api = { }); }, - async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { + resendMigrationVerification(email: string): Promise<{ sent: boolean }> { return xrpc("com.atproto.server.resendMigrationVerification", { method: "POST", body: { email }, }); }, - async verifyToken( + verifyToken( token: string, identifier: string, accessToken?: string, @@ -1123,11 +1123,11 @@ export const api = { }); }, - async getDidDocument(token: string): Promise { + getDidDocument(token: string): Promise { return xrpc("_account.getDidDocument", { token }); }, - async updateDidDocument( + updateDidDocument( token: string, params: { verificationMethods?: VerificationMethod[]; @@ -1170,7 +1170,7 @@ export const api = { return res.arrayBuffer(); }, - async listBackups(token: string): Promise<{ + listBackups(token: string): Promise<{ backups: Array<{ id: string; repoRev: string; @@ -1199,7 +1199,7 @@ export const api = { return res.blob(); }, - async createBackup(token: string): Promise<{ + createBackup(token: string): Promise<{ id: string; repoRev: string; sizeBytes: number; @@ -1219,7 +1219,7 @@ export const api = { }); }, - async setBackupEnabled( + setBackupEnabled( token: string, enabled: boolean, ): Promise<{ enabled: boolean }> { diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index 79ba0a5..2f1f4ae 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -40,7 +40,7 @@ interface AuthState { savedAccounts: SavedAccount[]; } -let state = $state({ +const state = $state({ session: null, loading: true, error: null, @@ -318,11 +318,18 @@ export function setSession( export async function logout(): Promise { if (state.session) { + const did = state.session.did; + const refreshToken = state.session.refreshJwt; try { - await api.deleteSession(state.session.accessJwt); + await fetch("/oauth/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: refreshToken }), + }); } catch { // Ignore errors on logout } + removeSavedAccount(did); } state.session = null; saveSession(null); diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index 5b07350..4d9f583 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -188,11 +188,11 @@ export class AtprotoClient { return session; } - async describeServer(): Promise { + describeServer(): Promise { return this.xrpc("com.atproto.server.describeServer"); } - async getServiceAuth( + getServiceAuth( aud: string, lxm?: string, ): Promise<{ token: string }> { @@ -203,7 +203,7 @@ export class AtprotoClient { return this.xrpc("com.atproto.server.getServiceAuth", { params }); } - async getRepo(did: string): Promise { + getRepo(did: string): Promise { return this.xrpc("com.atproto.sync.getRepo", { params: { did }, }); @@ -662,6 +662,61 @@ export function buildOAuthAuthorizationUrl( return url.toString(); } +export async function initiateOAuthWithPAR( + metadata: OAuthServerMetadata, + params: { + clientId: string; + redirectUri: string; + codeChallenge: string; + state: string; + scope?: string; + dpopJkt?: string; + loginHint?: string; + }, +): Promise { + if (!metadata.pushed_authorization_request_endpoint) { + return buildOAuthAuthorizationUrl(metadata, params); + } + + const body = new URLSearchParams({ + response_type: "code", + client_id: params.clientId, + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + state: params.state, + scope: params.scope ?? "atproto", + }); + + if (params.dpopJkt) { + body.set("dpop_jkt", params.dpopJkt); + } + if (params.loginHint) { + body.set("login_hint", params.loginHint); + } + + const res = await fetch(metadata.pushed_authorization_request_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ + error: "par_error", + error_description: res.statusText, + })); + throw new Error(err.error_description || err.error || "PAR request failed"); + } + + const { request_uri } = await res.json(); + + const authUrl = new URL(metadata.authorization_endpoint); + authUrl.searchParams.set("client_id", params.clientId); + authUrl.searchParams.set("request_uri", request_uri); + return authUrl.toString(); +} + export async function exchangeOAuthCode( metadata: OAuthServerMetadata, params: { @@ -839,7 +894,7 @@ export function getMigrationOAuthClientId(): string { } export function getMigrationOAuthRedirectUri(): string { - return `${globalThis.location.origin}/migrate`; + return `${globalThis.location.origin}/app/migrate`; } export interface DPoPKeyPair { diff --git a/frontend/src/lib/migration/blob-migration.ts b/frontend/src/lib/migration/blob-migration.ts index 09630f6..54079d5 100644 --- a/frontend/src/lib/migration/blob-migration.ts +++ b/frontend/src/lib/migration/blob-migration.ts @@ -107,8 +107,7 @@ export async function migrateBlobs( errorMessage, ); - const isNetworkError = - errorMessage.includes("fetch") || + const isNetworkError = errorMessage.includes("fetch") || errorMessage.includes("network") || errorMessage.includes("CORS") || errorMessage.includes("Failed to fetch") || @@ -124,7 +123,9 @@ export async function migrateBlobs( if (migrated > 0) { onProgress({ currentOperation: - `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`, + `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${ + remaining + 1 + } could not be fetched - these may need to be re-uploaded.`, }); } else { onProgress({ diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index a9086bd..4eafd48 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -8,7 +8,6 @@ import type { } from "./types"; import { AtprotoClient, - buildOAuthAuthorizationUrl, clearDPoPKey, createLocalClient, exchangeOAuthCode, @@ -18,6 +17,7 @@ import { getMigrationOAuthClientId, getMigrationOAuthRedirectUri, getOAuthServerMetadata, + initiateOAuthWithPAR, loadDPoPKey, resolvePdsUrl, saveDPoPKey, @@ -84,8 +84,6 @@ export function createInboundMigrationFlow() { let sourceClient: AtprotoClient | null = null; let localClient: AtprotoClient | null = null; let localServerInfo: ServerDescription | null = null; - let sourceOAuthMetadata: Awaited> = - null; function setStep(step: InboundStep) { state.step = step; @@ -141,7 +139,6 @@ export function createInboundMigrationFlow() { "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", ); } - sourceOAuthMetadata = metadata; const { codeVerifier, codeChallenge } = await generatePKCE(); const oauthState = generateOAuthState(); @@ -155,8 +152,11 @@ export function createInboundMigrationFlow() { localStorage.setItem("migration_source_did", state.sourceDid); localStorage.setItem("migration_source_handle", state.sourceHandle); localStorage.setItem("migration_oauth_issuer", metadata.issuer); + if (state.resumeToStep) { + localStorage.setItem("migration_resume_to_step", state.resumeToStep); + } - const authUrl = buildOAuthAuthorizationUrl(metadata, { + const authUrl = await initiateOAuthWithPAR(metadata, { clientId: getMigrationOAuthClientId(), redirectUri: getMigrationOAuthRedirectUri(), codeChallenge, @@ -185,6 +185,7 @@ export function createInboundMigrationFlow() { localStorage.removeItem("migration_source_did"); localStorage.removeItem("migration_source_handle"); localStorage.removeItem("migration_oauth_issuer"); + localStorage.removeItem("migration_resume_to_step"); } async function handleOAuthCallback( @@ -199,6 +200,12 @@ export function createInboundMigrationFlow() { const sourceDid = localStorage.getItem("migration_source_did"); const sourceHandle = localStorage.getItem("migration_source_handle"); const oauthIssuer = localStorage.getItem("migration_oauth_issuer"); + const savedResumeToStep = localStorage.getItem("migration_resume_to_step"); + + if (savedResumeToStep) { + state.needsReauth = true; + state.resumeToStep = savedResumeToStep as InboundMigrationState["step"]; + } if (returnedState !== savedState) { cleanupOAuthSessionData(); @@ -229,7 +236,6 @@ export function createInboundMigrationFlow() { cleanupOAuthSessionData(); throw new Error("Could not fetch OAuth server metadata"); } - sourceOAuthMetadata = metadata; migrationLog("handleOAuthCallback: Exchanging code for tokens"); @@ -269,8 +275,8 @@ export function createInboundMigrationFlow() { ]; if (postEmailSteps.includes(targetStep)) { + localClient = createLocalClient(); if (state.authMethod === "passkey" && state.passkeySetupToken) { - localClient = createLocalClient(); setStep("passkey-setup"); migrationLog( "handleOAuthCallback: Resuming passkey flow at passkey-setup", @@ -281,6 +287,10 @@ export function createInboundMigrationFlow() { "handleOAuthCallback: Resuming at email-verify for re-auth", ); } + } else if (targetStep === "email-verify") { + localClient = createLocalClient(); + setStep("email-verify"); + migrationLog("handleOAuthCallback: Resuming at email-verify"); } else { setStep(targetStep); } @@ -550,21 +560,34 @@ export function createInboundMigrationFlow() { async function checkEmailVerifiedAndProceed(): Promise { if (checkingEmailVerification) return false; - if (!sourceClient || !localClient) return false; - - if (state.authMethod === "passkey") { - return false; - } + if (!localClient) return false; checkingEmailVerification = true; try { const verified = await localClient.checkEmailVerified(state.targetEmail); if (!verified) return false; + if (state.authMethod === "passkey") { + migrationLog( + "checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup", + ); + setStep("passkey-setup"); + return true; + } + await localClient.loginDeactivated( state.targetEmail, state.targetPassword, ); + + if (!sourceClient) { + setStep("source-handle"); + setError( + "Email verified! Please log in to your old account again to complete the migration.", + ); + return true; + } + if (state.sourceDid.startsWith("did:web:")) { const credentials = await localClient.getRecommendedDidCredentials(); state.targetVerificationMethod = @@ -856,7 +879,6 @@ export function createInboundMigrationFlow() { }; sourceClient = null; passkeySetup = null; - sourceOAuthMetadata = null; clearMigrationState(); clearDPoPKey(); } diff --git a/frontend/src/lib/migration/types.ts b/frontend/src/lib/migration/types.ts index beb701e..96ae12a 100644 --- a/frontend/src/lib/migration/types.ts +++ b/frontend/src/lib/migration/types.ts @@ -254,11 +254,13 @@ export interface OAuthServerMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; + pushed_authorization_request_endpoint?: string; scopes_supported?: string[]; response_types_supported?: string[]; grant_types_supported?: string[]; code_challenge_methods_supported?: string[]; dpop_signing_alg_values_supported?: string[]; + require_pushed_authorization_requests?: boolean; } export interface OAuthTokenResponse { diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index dc47896..52a6d6b 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -10,7 +10,7 @@ const SCOPES = [ const CLIENT_ID = !(import.meta.env.DEV) ? `${globalThis.location.origin}/oauth/client-metadata.json` : `http://localhost/?scope=${SCOPES}`; -const REDIRECT_URI = `${globalThis.location.origin}/`; +const REDIRECT_URI = `${globalThis.location.origin}/app/`; interface OAuthState { state: string; @@ -26,7 +26,7 @@ function generateRandomString(length: number): string { ); } -async function sha256(plain: string): Promise { +function sha256(plain: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(plain); return crypto.subtle.digest("SHA-256", data); @@ -191,7 +191,7 @@ export async function refreshOAuthToken( export function checkForOAuthCallback(): | { code: string; state: string } | null { - if (globalThis.location.hash === "#/migrate") { + if (globalThis.location.pathname === "/app/migrate") { return null; } diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts index 7a2ca4b..dafb962 100644 --- a/frontend/src/lib/registration/flow.svelte.ts +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -29,7 +29,7 @@ export function createRegistrationFlow( mode: RegistrationMode, pdsHostname: string, ) { - let state = $state({ + const state = $state({ mode, step: "info", info: { @@ -80,7 +80,7 @@ export function createRegistrationFlow( } } - async function proceedFromInfo() { + function proceedFromInfo() { state.error = null; if (state.info.didType === "web-external") { state.step = "key-choice"; @@ -130,7 +130,7 @@ export function createRegistrationFlow( } } - async function confirmInitialDidDoc() { + function confirmInitialDidDoc() { state.step = "creating"; } diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts index 2fdc535..61f35d2 100644 --- a/frontend/src/lib/router.svelte.ts +++ b/frontend/src/lib/router.svelte.ts @@ -1,21 +1,34 @@ -let currentPath = $state( - getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"), -); +const APP_BASE = "/app"; -function getPathWithoutQuery(hash: string): string { - const queryIndex = hash.indexOf("?"); - return queryIndex === -1 ? hash : hash.slice(0, queryIndex); +function getAppPath(): string { + const pathname = globalThis.location.pathname; + if (pathname.startsWith(APP_BASE)) { + const path = pathname.slice(APP_BASE.length) || "/"; + return path.startsWith("/") ? path : "/" + path; + } + return "/"; } -globalThis.addEventListener("hashchange", () => { - currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"); +let currentPath = $state(getAppPath()); + +globalThis.addEventListener("popstate", () => { + currentPath = getAppPath(); }); -export function navigate(path: string) { - currentPath = path; - globalThis.location.hash = path; +export function navigate(path: string, replace = false) { + const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path); + if (replace) { + globalThis.history.replaceState(null, "", fullPath); + } else { + globalThis.history.pushState(null, "", fullPath); + } + currentPath = path.startsWith("/") ? path : "/" + path; } export function getCurrentPath() { return currentPath; } + +export function getFullUrl(path: string): string { + return APP_BASE + (path.startsWith("/") ? path : "/" + path); +} diff --git a/frontend/src/lib/serverConfig.svelte.ts b/frontend/src/lib/serverConfig.svelte.ts index c3965ff..734f85c 100644 --- a/frontend/src/lib/serverConfig.svelte.ts +++ b/frontend/src/lib/serverConfig.svelte.ts @@ -10,7 +10,7 @@ interface ServerConfigState { loading: boolean; } -let state = $state({ +const state = $state({ serverName: null, primaryColor: null, primaryColorDark: null, diff --git a/frontend/src/routes/ActAs.svelte b/frontend/src/routes/ActAs.svelte index 5b50ad3..77a8bb3 100644 --- a/frontend/src/routes/ActAs.svelte +++ b/frontend/src/routes/ActAs.svelte @@ -10,7 +10,7 @@ let actAsInProgress = $state(false) function getDid(): string | null { - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const params = new URLSearchParams(window.location.search) return params.get('did') } @@ -89,7 +89,7 @@ const parData = await parResponse.json() if (parData.request_uri) { - window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` + window.location.href = `/app/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` } else { error = $_('actAs.invalidResponse') loading = false diff --git a/frontend/src/routes/Admin.svelte b/frontend/src/routes/Admin.svelte index f8063e5..c1fcba4 100644 --- a/frontend/src/routes/Admin.svelte +++ b/frontend/src/routes/Admin.svelte @@ -308,7 +308,7 @@ {#if auth.session?.isAdmin}
- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('admin.title')}

{#if loading} diff --git a/frontend/src/routes/AppPasswords.svelte b/frontend/src/routes/AppPasswords.svelte index 8b1f3d4..684ceb7 100644 --- a/frontend/src/routes/AppPasswords.svelte +++ b/frontend/src/routes/AppPasswords.svelte @@ -99,7 +99,7 @@
- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('appPasswords.title')}

diff --git a/frontend/src/routes/Comms.svelte b/frontend/src/routes/Comms.svelte index 916797b..cae57ee 100644 --- a/frontend/src/routes/Comms.svelte +++ b/frontend/src/routes/Comms.svelte @@ -168,7 +168,7 @@

- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('comms.title')}

{$_('comms.description')}

diff --git a/frontend/src/routes/Controllers.svelte b/frontend/src/routes/Controllers.svelte index 4fb53db..21da8fa 100644 --- a/frontend/src/routes/Controllers.svelte +++ b/frontend/src/routes/Controllers.svelte @@ -232,7 +232,7 @@
- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('delegation.title')}

@@ -358,7 +358,7 @@
@@ -423,7 +423,7 @@

{$_('delegation.auditLog')}

{$_('delegation.auditLogDesc')}

- {$_('delegation.viewAuditLog')} + {$_('delegation.viewAuditLog')} {/if}
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index bcd1dd1..0f77033 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -166,73 +166,73 @@