Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Misc fixes for blobs and invites

+98 -24
+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
··· 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 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
··· 33 33 server_name _; 34 34 ssl_certificate /etc/nginx/certs/fullchain.pem; 35 35 ssl_certificate_key /etc/nginx/certs/privkey.pem; 36 - client_max_body_size 100M; 36 + client_max_body_size 10G; 37 37 location / { 38 38 proxy_pass http://127.0.0.1:3000; 39 39 proxy_http_version 1.1;
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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)