Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

feat(signal): add admin UI, frontend, and build changes #93

merged opened by oyster.cafe targeting main from feat/signal-client-in-house
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhlxir6exb22
+173 -36
Diff #0
+8
.config/nextest.toml
··· 29 29 filter = "binary(ripple_cluster)" 30 30 test-group = "serial-env-tests" 31 31 32 + [[profile.default.overrides]] 33 + filter = "package(tranquil-signal)" 34 + test-group = "serial-env-tests" 35 + 32 36 [[profile.default.overrides]] 33 37 filter = "binary(whole_story)" 34 38 test-group = "heavy-load-tests" ··· 53 57 filter = "binary(ripple_cluster)" 54 58 test-group = "serial-env-tests" 55 59 60 + [[profile.ci.overrides]] 61 + filter = "package(tranquil-signal)" 62 + test-group = "serial-env-tests" 63 + 56 64 [[profile.ci.overrides]] 57 65 filter = "binary(whole_story)" 58 66 test-group = "heavy-load-tests"
+2 -2
.tangled/workflows/publish-image.yml
··· 20 20 21 21 - name: Publish image 22 22 command: | 23 - podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil-pds/tranquil:latest" 24 - podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil-pds/tranquil:$TANGLED_COMMIT_SHA" 23 + podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest" 24 + podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
+8
Cargo.lock
··· 7073 7073 "futures", 7074 7074 "hex", 7075 7075 "http 1.4.0", 7076 + "image", 7076 7077 "infer", 7077 7078 "ipld-core", 7078 7079 "jacquard-common", ··· 7080 7081 "k256", 7081 7082 "multibase", 7082 7083 "multihash", 7084 + "qrcodegen", 7083 7085 "rand 0.8.5", 7084 7086 "reqwest", 7085 7087 "serde", ··· 7096 7098 "tranquil-lexicon", 7097 7099 "tranquil-pds", 7098 7100 "tranquil-scopes", 7101 + "tranquil-signal", 7099 7102 "tranquil-types", 7100 7103 "urlencoding", 7101 7104 "uuid", ··· 7148 7151 "base64 0.22.1", 7149 7152 "reqwest", 7150 7153 "serde_json", 7154 + "sqlx", 7151 7155 "thiserror 2.0.17", 7152 7156 "tokio", 7157 + "tracing", 7153 7158 "tranquil-config", 7154 7159 "tranquil-db-traits", 7160 + "tranquil-signal", 7155 7161 "uuid", 7156 7162 ] 7157 7163 ··· 7375 7381 "tranquil-repo", 7376 7382 "tranquil-ripple", 7377 7383 "tranquil-scopes", 7384 + "tranquil-signal", 7378 7385 "tranquil-storage", 7379 7386 "tranquil-sync", 7380 7387 "tranquil-types", ··· 7455 7462 "tranquil-config", 7456 7463 "tranquil-oauth-server", 7457 7464 "tranquil-pds", 7465 + "tranquil-signal", 7458 7466 "tranquil-sync", 7459 7467 ] 7460 7468
+1
Cargo.toml
··· 141 141 lto = "fat" 142 142 strip = true 143 143 codegen-units = 1 144 + panic = "abort"
+4 -13
Dockerfile
··· 4 4 RUN deno task build 5 5 6 6 FROM rust:1.92-alpine AS builder 7 - RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang 7 + RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc 8 8 ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold" 9 9 WORKDIR /app 10 10 ARG SLIM="false" ··· 29 29 COPY crates/tranquil-sync ./crates/tranquil-sync 30 30 COPY crates/tranquil-api ./crates/tranquil-api 31 31 COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server 32 + COPY crates/tranquil-signal ./crates/tranquil-signal 32 33 COPY crates/tranquil-server ./crates/tranquil-server 33 34 COPY migrations ./crates/tranquil-pds/migrations 34 35 RUN --mount=type=cache,target=/usr/local/cargo/registry \ ··· 40 41 fi && \ 41 42 cp target/release/tranquil-server /tmp/tranquil-pds 42 43 43 - FROM alpine:3.23 AS signal-cli 44 - RUN apk add --no-cache curl tar 45 - ARG SIGNAL_CLI_VERSION=0.13.24 46 - RUN curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ 47 - | tar xz -C /usr/local/bin 48 - 49 - FROM debian:trixie-slim 50 - RUN apt-get update && apt-get install -y --no-install-recommends msmtp ca-certificates \ 51 - && rm -rf /var/lib/apt/lists/* \ 44 + FROM alpine:3.23 45 + RUN apk add --no-cache msmtp ca-certificates \ 52 46 && ln -sf /usr/bin/msmtp /usr/sbin/sendmail 53 - COPY --from=signal-cli /usr/local/bin/signal-cli /usr/local/bin/signal-cli 54 - VOLUME /var/lib/signal-cli 55 47 COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds 56 48 COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend 57 49 COPY migrations /app/migrations 58 50 WORKDIR /app 59 - ENV SIGNAL_CLI_CONFIG=/var/lib/signal-cli 60 51 ENV SERVER_HOST=0.0.0.0 61 52 ENV SERVER_PORT=3000 62 53 EXPOSE 3000
+3 -5
crates/tranquil-oauth-server/src/endpoints/token/grants.rs
··· 155 155 156 156 let final_scope = if let Some(ref scope) = raw_scope { 157 157 if scope.contains("include:") { 158 - Some( 159 - expand_include_scopes(scope) 160 - .await 161 - .map_err(|e| OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")))?, 162 - ) 158 + Some(expand_include_scopes(scope).await.map_err(|e| { 159 + OAuthError::InvalidScope(format!("Failed to expand permission set: {e}")) 160 + })?) 163 161 } else { 164 162 raw_scope 165 163 }
+2 -5
crates/tranquil-scopes/src/permission_set.rs
··· 73 73 aud: Option<String>, 74 74 } 75 75 76 - pub async fn expand_include_scopes( 77 - scope_string: &str, 78 - ) -> Result<String, ScopeExpansionError> { 76 + pub async fn expand_include_scopes(scope_string: &str) -> Result<String, ScopeExpansionError> { 79 77 let futures: Vec<_> = scope_string 80 78 .split_whitespace() 81 79 .map(|scope| async move { ··· 571 569 572 570 #[tokio::test] 573 571 async fn test_expand_include_scopes_fails_on_unresolvable_nsid() { 574 - let result = 575 - expand_include_scopes("atproto include:nonexistent.fake.permissionSet").await; 572 + let result = expand_include_scopes("atproto include:nonexistent.fake.permissionSet").await; 576 573 assert!(result.is_err()); 577 574 } 578 575
+106 -1
frontend/src/components/dashboard/AdminContent.svelte
··· 65 65 let logoPreview = $state<string | null>(null) 66 66 let serverConfigLoading = $state(false) 67 67 68 + let signalEnabled = $state(false) 69 + let signalLinked = $state(false) 70 + let signalQr = $state<string | null>(null) 71 + let signalLoading = $state(false) 72 + let signalPollTimer: ReturnType<typeof setInterval> | null = null 73 + let signalLinkTimeout: ReturnType<typeof setTimeout> | null = null 74 + 75 + function stopSignalPolling() { 76 + if (signalPollTimer) { 77 + clearInterval(signalPollTimer) 78 + signalPollTimer = null 79 + } 80 + if (signalLinkTimeout) { 81 + clearTimeout(signalLinkTimeout) 82 + signalLinkTimeout = null 83 + } 84 + } 85 + 68 86 onMount(async () => { 69 - await Promise.all([loadStats(), loadServerConfig(), loadUsers(true)]) 87 + await Promise.all([loadStats(), loadServerConfig(), loadUsers(true), loadSignalStatus()]) 88 + return () => stopSignalPolling() 70 89 }) 71 90 72 91 async function loadStats() { ··· 210 229 logoChanged 211 230 } 212 231 232 + let signalPollErrors = $state(0) 233 + 234 + async function loadSignalStatus() { 235 + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return 236 + try { 237 + const status = await api.getSignalStatus(session.accessJwt) 238 + signalEnabled = status.enabled 239 + signalLinked = status.linked 240 + signalPollErrors = 0 241 + if (signalLinked && signalQr) { 242 + signalQr = null 243 + stopSignalPolling() 244 + toast.success($_('admin.signalLinkSuccess')) 245 + } 246 + } catch (e) { 247 + signalPollErrors += 1 248 + if (signalPollErrors >= 3 && signalQr) { 249 + stopSignalPolling() 250 + signalQr = null 251 + toast.error(e instanceof ApiError ? e.message : $_('admin.signalFailedToLoad')) 252 + } 253 + } 254 + } 255 + 256 + async function linkSignal() { 257 + signalLoading = true 258 + try { 259 + const result = await api.linkSignalDevice(session.accessJwt) 260 + signalQr = result.qrBase64 261 + signalPollTimer = setInterval(() => loadSignalStatus(), 2000) 262 + signalLinkTimeout = setTimeout(() => { 263 + if (!signalLinked) { 264 + signalQr = null 265 + stopSignalPolling() 266 + toast.error($_('admin.signalLinkTimedOut')) 267 + } 268 + }, 130_000) 269 + } catch (e) { 270 + toast.error(e instanceof ApiError ? e.message : $_('admin.signalLinkFailed')) 271 + } finally { 272 + signalLoading = false 273 + } 274 + } 275 + 276 + async function unlinkSignal() { 277 + if (!confirm($_('admin.signalUnlinkConfirm'))) return 278 + signalLoading = true 279 + try { 280 + await api.unlinkSignalDevice(session.accessJwt) 281 + signalLinked = false 282 + toast.success($_('admin.signalUnlinkSuccess')) 283 + } catch (e) { 284 + toast.error(e instanceof ApiError ? e.message : $_('admin.signalUnlinkFailed')) 285 + } finally { 286 + signalLoading = false 287 + } 288 + } 289 + 213 290 async function showUserDetail(user: User) { 214 291 selectedUser = user 215 292 userDetailLoading = true ··· 323 400 </form> 324 401 </section> 325 402 403 + {#if signalEnabled} 404 + <section class="config-section"> 405 + <div class="section-header-row"> 406 + <h3>{$_('admin.signalIntegration')}</h3> 407 + {#if signalLinked} 408 + <span class="badge verified">{$_('admin.signalLinked')}</span> 409 + {:else if !signalQr} 410 + <span class="badge unverified">{$_('admin.signalNotLinked')}</span> 411 + {/if} 412 + </div> 413 + 414 + {#if signalQr} 415 + <div class="qr-container"> 416 + <p>{$_('admin.signalLinking')}</p> 417 + <img src="data:image/png;base64,{signalQr}" alt="Signal QR" class="qr-code" /> 418 + </div> 419 + {:else if signalLinked} 420 + <button type="button" class="danger sm" onclick={unlinkSignal} disabled={signalLoading}> 421 + {$_('admin.signalUnlinkDevice')} 422 + </button> 423 + {:else} 424 + <button type="button" onclick={linkSignal} disabled={signalLoading}> 425 + {signalLoading ? $_('common.loading') : $_('admin.signalLinkDevice')} 426 + </button> 427 + {/if} 428 + </section> 429 + {/if} 430 + 326 431 <section class="stats-section"> 327 432 <div class="section-header-row"> 328 433 <h3>{$_('admin.serverStats')}</h3>
+14
frontend/src/lib/api.ts
··· 69 69 ServerConfig, 70 70 ServerDescription, 71 71 ServerStats, 72 + SignalLinkResult, 73 + SignalStatus, 72 74 Session, 73 75 SsoLinkedAccount, 74 76 StartPasskeyRegistrationResponse, ··· 709 711 return xrpc("_admin.getServerStats", { token }); 710 712 }, 711 713 714 + getSignalStatus(token: AccessToken): Promise<SignalStatus> { 715 + return xrpc("_admin.getSignalStatus", { token }); 716 + }, 717 + 718 + linkSignalDevice(token: AccessToken): Promise<SignalLinkResult> { 719 + return xrpc("_admin.linkSignalDevice", { method: "POST", token }); 720 + }, 721 + 722 + unlinkSignalDevice(token: AccessToken): Promise<void> { 723 + return xrpc("_admin.unlinkSignalDevice", { method: "POST", token }); 724 + }, 725 + 712 726 getServerConfig(): Promise<ServerConfig> { 713 727 return xrpc("_server.getConfig"); 714 728 },
+9
frontend/src/lib/types/api.ts
··· 248 248 blobStorageBytes: number; 249 249 } 250 250 251 + export interface SignalStatus { 252 + enabled: boolean; 253 + linked: boolean; 254 + } 255 + 256 + export interface SignalLinkResult { 257 + qrBase64: string; 258 + } 259 + 251 260 export interface ServerConfig { 252 261 serverName: string; 253 262 primaryColor: string | null;
+14 -1
frontend/src/locales/en.json
··· 490 490 "invitesEnabled": "User invites enabled", 491 491 "invitesDisabled": "User invites disabled", 492 492 "userDeleted": "User account deleted", 493 - "failedToLoadConfig": "Failed to load server configuration" 493 + "failedToLoadConfig": "Failed to load server configuration", 494 + "signalIntegration": "Signal", 495 + "signalLinked": "Linked", 496 + "signalNotLinked": "Not linked", 497 + "signalLinkDevice": "Link Device", 498 + "signalUnlinkDevice": "Unlink", 499 + "signalLinking": "Scan with Signal", 500 + "signalLinkSuccess": "Signal device linked", 501 + "signalUnlinkSuccess": "Signal device unlinked", 502 + "signalUnlinkConfirm": "Unlink Signal device? Notifications will stop.", 503 + "signalLinkFailed": "Failed to link Signal device", 504 + "signalLinkTimedOut": "Signal linking timed out", 505 + "signalUnlinkFailed": "Failed to unlink Signal device", 506 + "signalFailedToLoad": "Failed to load Signal status" 494 507 }, 495 508 "oauth": { 496 509 "login": {
+2 -1
frontend/src/styles/dashboard.css
··· 644 644 645 645 .qr-container { 646 646 display: flex; 647 - justify-content: center; 647 + flex-direction: column; 648 + align-items: center; 648 649 margin: var(--space-4) 0; 649 650 } 650 651
-8
module.nix
··· 123 123 description = "Path to the sendmail executable to use for sending emails."; 124 124 }; 125 125 }; 126 - 127 - signal = { 128 - cli_path = mkOption { 129 - type = types.path; 130 - default = lib.getExe pkgs.signal-cli; 131 - description = "Path to the signal-cli executable to use for sending Signal notifications."; 132 - }; 133 - }; 134 126 }; 135 127 }; 136 128

History

1 round 0 comments
sign up or login to add to the discussion
oyster.cafe submitted #0
1 commit
expand
feat(signal): add admin UI, frontend, and build changes
expand 0 comments
pull request successfully merged