+173
-36
Diff
round #0
+8
.config/nextest.toml
+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
+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
+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
+1
Cargo.toml
+4
-13
Dockerfile
+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
+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
+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
+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
+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
+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
+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
+2
-1
frontend/src/styles/dashboard.css
-8
module.nix
-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
oyster.cafe
submitted
#0
1 commit
expand
collapse
feat(signal): add admin UI, frontend, and build changes
expand 0 comments
pull request successfully merged