+9
-2
.env.example
+9
-2
.env.example
···
82
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
83
# SIGNAL_SENDER_NUMBER=+1234567890
84
# =============================================================================
85
# Repository Import
86
# =============================================================================
87
# Set to "true" to accept repository imports
88
# ACCEPTING_REPO_IMPORTS=false
89
-
# Maximum import size in bytes (default: 50MB)
90
-
# MAX_IMPORT_SIZE=52428800
91
# Maximum blocks per import (default: 100000)
92
# MAX_IMPORT_BLOCKS=100000
93
# Skip verification during import (testing only)
···
82
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
83
# SIGNAL_SENDER_NUMBER=+1234567890
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
+
# =============================================================================
92
# Repository Import
93
# =============================================================================
94
# Set to "true" to accept repository imports
95
# ACCEPTING_REPO_IMPORTS=false
96
+
# Maximum import size in bytes (default: 100MB)
97
+
# MAX_IMPORT_SIZE=104857600
98
# Maximum blocks per import (default: 100000)
99
# MAX_IMPORT_BLOCKS=100000
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
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
11
-
Another excellent PDS is [Cocoon](https://github.com/haileyok/cocoon), written in go.
12
13
## What's different about Tranquil PDS
14
···
8
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
11
+
Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written in go.
12
13
## What's different about Tranquil PDS
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
+25
-4
frontend/src/routes/InviteCodes.svelte
+25
-4
frontend/src/routes/InviteCodes.svelte
···
12
let error = $state<string | null>(null)
13
let creating = $state(false)
14
let createdCode = $state<string | null>(null)
15
let inviteCodesEnabled = $state<boolean | null>(null)
16
17
onMount(async () => {
···
65
}
66
function dismissCreated() {
67
createdCode = null
68
}
69
function copyCode(code: string) {
70
navigator.clipboard.writeText(code)
71
}
72
</script>
73
<div class="page">
···
86
<h3>{$_('inviteCodes.created')}</h3>
87
<div class="code-display">
88
<code>{createdCode}</code>
89
-
<button class="copy" onclick={() => copyCode(createdCode!)}>{$_('inviteCodes.copy')}</button>
90
</div>
91
<button onclick={dismissCreated}>{$_('common.done')}</button>
92
</div>
···
110
<li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}>
111
<div class="code-main">
112
<code>{code.code}</code>
113
-
<button class="copy-small" onclick={() => copyCode(code.code)} title={$_('inviteCodes.copy')}>
114
-
{$_('inviteCodes.copy')}
115
</button>
116
</div>
117
<div class="code-meta">
···
119
{#if code.disabled}
120
<span class="status disabled">{$_('inviteCodes.disabled')}</span>
121
{:else if code.uses.length > 0}
122
-
<span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedBy.split(':').pop() } })}</span>
123
{:else}
124
<span class="status available">{$_('inviteCodes.available')}</span>
125
{/if}
···
12
let error = $state<string | null>(null)
13
let creating = $state(false)
14
let createdCode = $state<string | null>(null)
15
+
let createdCodeCopied = $state(false)
16
+
let copiedCode = $state<string | null>(null)
17
let inviteCodesEnabled = $state<boolean | null>(null)
18
19
onMount(async () => {
···
67
}
68
function dismissCreated() {
69
createdCode = null
70
+
createdCodeCopied = false
71
+
}
72
+
function copyCreatedCode() {
73
+
if (createdCode) {
74
+
navigator.clipboard.writeText(createdCode)
75
+
createdCodeCopied = true
76
+
}
77
}
78
function copyCode(code: string) {
79
navigator.clipboard.writeText(code)
80
+
copiedCode = code
81
+
setTimeout(() => {
82
+
if (copiedCode === code) {
83
+
copiedCode = null
84
+
}
85
+
}, 2000)
86
}
87
</script>
88
<div class="page">
···
101
<h3>{$_('inviteCodes.created')}</h3>
102
<div class="code-display">
103
<code>{createdCode}</code>
104
+
<button class="copy" onclick={copyCreatedCode}>
105
+
{createdCodeCopied ? $_('common.copied') : $_('common.copyToClipboard')}
106
+
</button>
107
</div>
108
<button onclick={dismissCreated}>{$_('common.done')}</button>
109
</div>
···
127
<li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}>
128
<div class="code-main">
129
<code>{code.code}</code>
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')}
136
</button>
137
</div>
138
<div class="code-meta">
···
140
{#if code.disabled}
141
<span class="status disabled">{$_('inviteCodes.disabled')}</span>
142
{:else if code.uses.length > 0}
143
+
<span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span>
144
{:else}
145
<span class="status available">{$_('inviteCodes.available')}</span>
146
{/if}
+1
-1
nginx.prod.conf
+1
-1
nginx.prod.conf
+3
-9
src/api/repo/blob.rs
+3
-9
src/api/repo/blob.rs
···
1
use crate::auth::{ServiceTokenVerifier, is_service_token};
2
use crate::delegation::{self, DelegationActionType};
3
use crate::state::AppState;
4
use axum::body::Bytes;
5
use axum::{
6
Json,
···
14
use serde_json::json;
15
use sha2::{Digest, Sha256};
16
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
21
pub async fn upload_blob(
22
State(state): State<AppState>,
···
38
39
let is_service_auth = is_service_token(&token);
40
41
-
let (did, is_migration, controller_did) = if is_service_auth {
42
debug!("Verifying service token for blob upload");
43
let verifier = ServiceTokenVerifier::new();
44
match verifier
···
94
}
95
};
96
97
-
let max_size = if is_service_auth || is_migration {
98
-
MAX_VIDEO_BLOB_SIZE
99
-
} else {
100
-
MAX_BLOB_SIZE
101
-
};
102
103
if body.len() > max_size {
104
return (
···
1
use crate::auth::{ServiceTokenVerifier, is_service_token};
2
use crate::delegation::{self, DelegationActionType};
3
use crate::state::AppState;
4
+
use crate::util::get_max_blob_size;
5
use axum::body::Bytes;
6
use axum::{
7
Json,
···
15
use serde_json::json;
16
use sha2::{Digest, Sha256};
17
use tracing::{debug, error};
18
19
pub async fn upload_blob(
20
State(state): State<AppState>,
···
36
37
let is_service_auth = is_service_token(&token);
38
39
+
let (did, _is_migration, controller_did) = if is_service_auth {
40
debug!("Verifying service token for blob upload");
41
let verifier = ServiceTokenVerifier::new();
42
match verifier
···
92
}
93
};
94
95
+
let max_size = get_max_blob_size();
96
97
if body.len() > max_size {
98
return (
+8
-5
src/api/server/invite.rs
+8
-5
src/api/server/invite.rs
···
46
47
pub async fn create_invite_code(
48
State(state): State<AppState>,
49
-
BearerAuthAdmin(_auth_user): BearerAuthAdmin,
50
Json(input): Json<CreateInviteCodeInput>,
51
) -> Response {
52
if input.use_count < 1 {
53
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
54
}
55
56
-
let for_account = input.for_account.unwrap_or_else(|| "admin".to_string());
57
let code = gen_invite_code();
58
59
match sqlx::query!(
···
101
102
pub async fn create_invite_codes(
103
State(state): State<AppState>,
104
-
BearerAuthAdmin(_auth_user): BearerAuthAdmin,
105
Json(input): Json<CreateInviteCodesInput>,
106
) -> Response {
107
if input.use_count < 1 {
···
112
let for_accounts = input
113
.for_accounts
114
.filter(|v| !v.is_empty())
115
-
.unwrap_or_else(|| vec!["admin".to_string()]);
116
117
let admin_user_id = match sqlx::query_scalar!(
118
"SELECT id FROM users WHERE is_admin = true LIMIT 1"
···
184
#[serde(rename_all = "camelCase")]
185
pub struct InviteCodeUse {
186
pub used_by: String,
187
pub used_at: String,
188
}
189
···
238
239
let uses = sqlx::query!(
240
r#"
241
-
SELECT u.did, icu.used_at
242
FROM invite_code_uses icu
243
JOIN users u ON icu.used_by_user = u.id
244
WHERE icu.code = $1
···
253
.iter()
254
.map(|u| InviteCodeUse {
255
used_by: u.did.clone(),
256
used_at: u.used_at.to_rfc3339(),
257
})
258
.collect()
···
46
47
pub async fn create_invite_code(
48
State(state): State<AppState>,
49
+
BearerAuthAdmin(auth_user): BearerAuthAdmin,
50
Json(input): Json<CreateInviteCodeInput>,
51
) -> Response {
52
if input.use_count < 1 {
53
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
54
}
55
56
+
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.clone());
57
let code = gen_invite_code();
58
59
match sqlx::query!(
···
101
102
pub async fn create_invite_codes(
103
State(state): State<AppState>,
104
+
BearerAuthAdmin(auth_user): BearerAuthAdmin,
105
Json(input): Json<CreateInviteCodesInput>,
106
) -> Response {
107
if input.use_count < 1 {
···
112
let for_accounts = input
113
.for_accounts
114
.filter(|v| !v.is_empty())
115
+
.unwrap_or_else(|| vec![auth_user.did.clone()]);
116
117
let admin_user_id = match sqlx::query_scalar!(
118
"SELECT id FROM users WHERE is_admin = true LIMIT 1"
···
184
#[serde(rename_all = "camelCase")]
185
pub struct InviteCodeUse {
186
pub used_by: String,
187
+
#[serde(skip_serializing_if = "Option::is_none")]
188
+
pub used_by_handle: Option<String>,
189
pub used_at: String,
190
}
191
···
240
241
let uses = sqlx::query!(
242
r#"
243
+
SELECT u.did, u.handle, icu.used_at
244
FROM invite_code_uses icu
245
JOIN users u ON icu.used_by_user = u.id
246
WHERE icu.code = $1
···
255
.iter()
256
.map(|u| InviteCodeUse {
257
used_by: u.did.clone(),
258
+
used_by_handle: Some(u.handle.clone()),
259
used_at: u.used_at.to_rfc3339(),
260
})
261
.collect()
+2
src/lib.rs
+2
src/lib.rs
···
24
25
use axum::{
26
Router,
27
http::Method,
28
middleware,
29
routing::{any, get, post},
···
618
post(api::delegation::create_delegated_account),
619
)
620
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
621
.layer(middleware::from_fn(metrics::metrics_middleware))
622
.layer(
623
CorsLayer::new()
···
24
25
use axum::{
26
Router,
27
+
extract::DefaultBodyLimit,
28
http::Method,
29
middleware,
30
routing::{any, get, post},
···
619
post(api::delegation::create_delegated_account),
620
)
621
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
622
+
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
623
.layer(middleware::from_fn(metrics::metrics_middleware))
624
.layer(
625
CorsLayer::new()
+13
src/util.rs
+13
src/util.rs
···
1
use axum::http::HeaderMap;
2
use rand::Rng;
3
use sqlx::PgPool;
4
+
use std::sync::OnceLock;
5
use uuid::Uuid;
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
+
}
20
21
pub fn generate_token_code() -> String {
22
generate_token_code_parts(2, 5)