this repo has no description

Misc fixes for blobs and invites

lewis ed387916 114cc8a8

+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
···
··· 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
··· 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
··· 33 server_name _; 34 ssl_certificate /etc/nginx/certs/fullchain.pem; 35 ssl_certificate_key /etc/nginx/certs/privkey.pem; 36 - client_max_body_size 100M; 37 location / { 38 proxy_pass http://127.0.0.1:3000; 39 proxy_http_version 1.1;
··· 33 server_name _; 34 ssl_certificate /etc/nginx/certs/fullchain.pem; 35 ssl_certificate_key /etc/nginx/certs/privkey.pem; 36 + client_max_body_size 10G; 37 location / { 38 proxy_pass http://127.0.0.1:3000; 39 proxy_http_version 1.1;
+1 -1
frontend/src/lib/api.ts
··· 105 forAccount: string; 106 createdBy: string; 107 createdAt: string; 108 - uses: { usedBy: string; usedAt: string }[]; 109 } 110 111 export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
··· 105 forAccount: string; 106 createdBy: string; 107 createdAt: string; 108 + uses: { usedBy: string; usedByHandle?: string; usedAt: string }[]; 109 } 110 111 export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
+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
··· 55 server_name _; 56 ssl_certificate /etc/nginx/certs/live/${PDS_HOSTNAME}/fullchain.pem; 57 ssl_certificate_key /etc/nginx/certs/live/${PDS_HOSTNAME}/privkey.pem; 58 - client_max_body_size 100M; 59 location / { 60 proxy_pass http://tranquil-pds; 61 proxy_http_version 1.1;
··· 55 server_name _; 56 ssl_certificate /etc/nginx/certs/live/${PDS_HOSTNAME}/fullchain.pem; 57 ssl_certificate_key /etc/nginx/certs/live/${PDS_HOSTNAME}/privkey.pem; 58 + client_max_body_size 10G; 59 location / { 60 proxy_pass http://tranquil-pds; 61 proxy_http_version 1.1;
+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
··· 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
··· 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
··· 1 use axum::http::HeaderMap; 2 use rand::Rng; 3 use sqlx::PgPool; 4 use uuid::Uuid; 5 6 const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; 7 8 pub fn generate_token_code() -> String { 9 generate_token_code_parts(2, 5)
··· 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)