+9
-2
.env.example
+9
-2
.env.example
···
82
82
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
83
83
# SIGNAL_SENDER_NUMBER=+1234567890
84
84
# =============================================================================
85
+
# Upload Limits
86
+
# =============================================================================
87
+
# Maximum blob/body size in bytes (default: 10GB)
88
+
# This controls both the Axum body limit and blob upload limits.
89
+
# Make sure your nginx client_max_body_size matches or exceeds this value.
90
+
# MAX_BLOB_SIZE=10737418240
91
+
# =============================================================================
85
92
# Repository Import
86
93
# =============================================================================
87
94
# Set to "true" to accept repository imports
88
95
# ACCEPTING_REPO_IMPORTS=false
89
-
# Maximum import size in bytes (default: 50MB)
90
-
# MAX_IMPORT_SIZE=52428800
96
+
# Maximum import size in bytes (default: 100MB)
97
+
# MAX_IMPORT_SIZE=104857600
91
98
# Maximum blocks per import (default: 100000)
92
99
# MAX_IMPORT_BLOCKS=100000
93
100
# Skip verification during import (testing only)
+34
.sqlx/query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json
+34
.sqlx/query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "handle",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "used_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
false
31
+
]
32
+
},
33
+
"hash": "6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47"
34
+
}
+1
-1
README.md
+1
-1
README.md
···
8
8
9
9
It has full compatibility with Bluesky's reference PDS: same endpoints, same behavior, same client compatibility. Everything works: repo operations, blob storage, firehose, OAuth, handle resolution, account migration, the lot.
10
10
11
-
Another excellent PDS is [Cocoon](https://github.com/haileyok/cocoon), written in go.
11
+
Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written in go.
12
12
13
13
## What's different about Tranquil PDS
14
14
+1
-1
deploy/nginx/nginx-quadlet.conf
+1
-1
deploy/nginx/nginx-quadlet.conf
+1
-1
frontend/src/lib/api.ts
+1
-1
frontend/src/lib/api.ts
···
105
105
forAccount: string;
106
106
createdBy: string;
107
107
createdAt: string;
108
-
uses: { usedBy: string; usedAt: string }[];
108
+
uses: { usedBy: string; usedByHandle?: string; usedAt: string }[];
109
109
}
110
110
111
111
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
+25
-4
frontend/src/routes/InviteCodes.svelte
+25
-4
frontend/src/routes/InviteCodes.svelte
···
12
12
let error = $state<string | null>(null)
13
13
let creating = $state(false)
14
14
let createdCode = $state<string | null>(null)
15
+
let createdCodeCopied = $state(false)
16
+
let copiedCode = $state<string | null>(null)
15
17
let inviteCodesEnabled = $state<boolean | null>(null)
16
18
17
19
onMount(async () => {
···
65
67
}
66
68
function dismissCreated() {
67
69
createdCode = null
70
+
createdCodeCopied = false
71
+
}
72
+
function copyCreatedCode() {
73
+
if (createdCode) {
74
+
navigator.clipboard.writeText(createdCode)
75
+
createdCodeCopied = true
76
+
}
68
77
}
69
78
function copyCode(code: string) {
70
79
navigator.clipboard.writeText(code)
80
+
copiedCode = code
81
+
setTimeout(() => {
82
+
if (copiedCode === code) {
83
+
copiedCode = null
84
+
}
85
+
}, 2000)
71
86
}
72
87
</script>
73
88
<div class="page">
···
86
101
<h3>{$_('inviteCodes.created')}</h3>
87
102
<div class="code-display">
88
103
<code>{createdCode}</code>
89
-
<button class="copy" onclick={() => copyCode(createdCode!)}>{$_('inviteCodes.copy')}</button>
104
+
<button class="copy" onclick={copyCreatedCode}>
105
+
{createdCodeCopied ? $_('common.copied') : $_('common.copyToClipboard')}
106
+
</button>
90
107
</div>
91
108
<button onclick={dismissCreated}>{$_('common.done')}</button>
92
109
</div>
···
110
127
<li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}>
111
128
<div class="code-main">
112
129
<code>{code.code}</code>
113
-
<button class="copy-small" onclick={() => copyCode(code.code)} title={$_('inviteCodes.copy')}>
114
-
{$_('inviteCodes.copy')}
130
+
<button
131
+
class="copy-small"
132
+
onclick={() => copyCode(code.code)}
133
+
title={copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')}
134
+
>
135
+
{copiedCode === code.code ? $_('common.copied') : $_('inviteCodes.copy')}
115
136
</button>
116
137
</div>
117
138
<div class="code-meta">
···
119
140
{#if code.disabled}
120
141
<span class="status disabled">{$_('inviteCodes.disabled')}</span>
121
142
{:else if code.uses.length > 0}
122
-
<span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedBy.split(':').pop() } })}</span>
143
+
<span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span>
123
144
{:else}
124
145
<span class="status available">{$_('inviteCodes.available')}</span>
125
146
{/if}
+1
-1
nginx.prod.conf
+1
-1
nginx.prod.conf
···
55
55
server_name _;
56
56
ssl_certificate /etc/nginx/certs/live/${PDS_HOSTNAME}/fullchain.pem;
57
57
ssl_certificate_key /etc/nginx/certs/live/${PDS_HOSTNAME}/privkey.pem;
58
-
client_max_body_size 100M;
58
+
client_max_body_size 10G;
59
59
location / {
60
60
proxy_pass http://tranquil-pds;
61
61
proxy_http_version 1.1;
+3
-9
src/api/repo/blob.rs
+3
-9
src/api/repo/blob.rs
···
1
1
use crate::auth::{ServiceTokenVerifier, is_service_token};
2
2
use crate::delegation::{self, DelegationActionType};
3
3
use crate::state::AppState;
4
+
use crate::util::get_max_blob_size;
4
5
use axum::body::Bytes;
5
6
use axum::{
6
7
Json,
···
14
15
use serde_json::json;
15
16
use sha2::{Digest, Sha256};
16
17
use tracing::{debug, error};
17
-
18
-
const MAX_BLOB_SIZE: usize = 10_000_000_000;
19
-
const MAX_VIDEO_BLOB_SIZE: usize = 10_000_000_000;
20
18
21
19
pub async fn upload_blob(
22
20
State(state): State<AppState>,
···
38
36
39
37
let is_service_auth = is_service_token(&token);
40
38
41
-
let (did, is_migration, controller_did) = if is_service_auth {
39
+
let (did, _is_migration, controller_did) = if is_service_auth {
42
40
debug!("Verifying service token for blob upload");
43
41
let verifier = ServiceTokenVerifier::new();
44
42
match verifier
···
94
92
}
95
93
};
96
94
97
-
let max_size = if is_service_auth || is_migration {
98
-
MAX_VIDEO_BLOB_SIZE
99
-
} else {
100
-
MAX_BLOB_SIZE
101
-
};
95
+
let max_size = get_max_blob_size();
102
96
103
97
if body.len() > max_size {
104
98
return (
+8
-5
src/api/server/invite.rs
+8
-5
src/api/server/invite.rs
···
46
46
47
47
pub async fn create_invite_code(
48
48
State(state): State<AppState>,
49
-
BearerAuthAdmin(_auth_user): BearerAuthAdmin,
49
+
BearerAuthAdmin(auth_user): BearerAuthAdmin,
50
50
Json(input): Json<CreateInviteCodeInput>,
51
51
) -> Response {
52
52
if input.use_count < 1 {
53
53
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
54
54
}
55
55
56
-
let for_account = input.for_account.unwrap_or_else(|| "admin".to_string());
56
+
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.clone());
57
57
let code = gen_invite_code();
58
58
59
59
match sqlx::query!(
···
101
101
102
102
pub async fn create_invite_codes(
103
103
State(state): State<AppState>,
104
-
BearerAuthAdmin(_auth_user): BearerAuthAdmin,
104
+
BearerAuthAdmin(auth_user): BearerAuthAdmin,
105
105
Json(input): Json<CreateInviteCodesInput>,
106
106
) -> Response {
107
107
if input.use_count < 1 {
···
112
112
let for_accounts = input
113
113
.for_accounts
114
114
.filter(|v| !v.is_empty())
115
-
.unwrap_or_else(|| vec!["admin".to_string()]);
115
+
.unwrap_or_else(|| vec![auth_user.did.clone()]);
116
116
117
117
let admin_user_id = match sqlx::query_scalar!(
118
118
"SELECT id FROM users WHERE is_admin = true LIMIT 1"
···
184
184
#[serde(rename_all = "camelCase")]
185
185
pub struct InviteCodeUse {
186
186
pub used_by: String,
187
+
#[serde(skip_serializing_if = "Option::is_none")]
188
+
pub used_by_handle: Option<String>,
187
189
pub used_at: String,
188
190
}
189
191
···
238
240
239
241
let uses = sqlx::query!(
240
242
r#"
241
-
SELECT u.did, icu.used_at
243
+
SELECT u.did, u.handle, icu.used_at
242
244
FROM invite_code_uses icu
243
245
JOIN users u ON icu.used_by_user = u.id
244
246
WHERE icu.code = $1
···
253
255
.iter()
254
256
.map(|u| InviteCodeUse {
255
257
used_by: u.did.clone(),
258
+
used_by_handle: Some(u.handle.clone()),
256
259
used_at: u.used_at.to_rfc3339(),
257
260
})
258
261
.collect()
+2
src/lib.rs
+2
src/lib.rs
···
24
24
25
25
use axum::{
26
26
Router,
27
+
extract::DefaultBodyLimit,
27
28
http::Method,
28
29
middleware,
29
30
routing::{any, get, post},
···
618
619
post(api::delegation::create_delegated_account),
619
620
)
620
621
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
622
+
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
621
623
.layer(middleware::from_fn(metrics::metrics_middleware))
622
624
.layer(
623
625
CorsLayer::new()
+13
src/util.rs
+13
src/util.rs
···
1
1
use axum::http::HeaderMap;
2
2
use rand::Rng;
3
3
use sqlx::PgPool;
4
+
use std::sync::OnceLock;
4
5
use uuid::Uuid;
5
6
6
7
const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567";
8
+
const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024;
9
+
10
+
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
11
+
12
+
pub fn get_max_blob_size() -> usize {
13
+
*MAX_BLOB_SIZE.get_or_init(|| {
14
+
std::env::var("MAX_BLOB_SIZE")
15
+
.ok()
16
+
.and_then(|s| s.parse().ok())
17
+
.unwrap_or(DEFAULT_MAX_BLOB_SIZE)
18
+
})
19
+
}
7
20
8
21
pub fn generate_token_code() -> String {
9
22
generate_token_code_parts(2, 5)