mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 09:16:54 +00:00
Add back some whitespaces
This commit is contained in:
@@ -9,10 +9,13 @@
|
||||
import Settings from './routes/Settings.svelte'
|
||||
import Notifications from './routes/Notifications.svelte'
|
||||
import RepoExplorer from './routes/RepoExplorer.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
initAuth()
|
||||
})
|
||||
|
||||
function getComponent(path: string) {
|
||||
switch (path) {
|
||||
case '/login':
|
||||
@@ -35,9 +38,11 @@
|
||||
return auth.session ? Dashboard : Login
|
||||
}
|
||||
}
|
||||
|
||||
let currentPath = $derived(getCurrentPath())
|
||||
let CurrentComponent = $derived(getComponent(currentPath))
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if auth.loading}
|
||||
<div class="loading">
|
||||
@@ -47,6 +52,7 @@
|
||||
<CurrentComponent />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
--bg-primary: #fafafa;
|
||||
@@ -70,6 +76,7 @@
|
||||
--warning-bg: #ffd;
|
||||
--warning-text: #660;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root) {
|
||||
--bg-primary: #1a1a1a;
|
||||
@@ -94,6 +101,7 @@
|
||||
--warning-text: #c6c67b;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
@@ -101,13 +109,16 @@
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const API_BASE = '/xrpc'
|
||||
|
||||
export class ApiError extends Error {
|
||||
public did?: string
|
||||
constructor(public status: number, public error: string, message: string, did?: string) {
|
||||
@@ -7,6 +8,7 @@ export class ApiError extends Error {
|
||||
this.did = did
|
||||
}
|
||||
}
|
||||
|
||||
async function xrpc<T>(method: string, options?: {
|
||||
method?: 'GET' | 'POST'
|
||||
params?: Record<string, string>
|
||||
@@ -37,6 +39,7 @@ async function xrpc<T>(method: string, options?: {
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
did: string
|
||||
handle: string
|
||||
@@ -47,10 +50,12 @@ export interface Session {
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}
|
||||
|
||||
export interface AppPassword {
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface InviteCode {
|
||||
code: string
|
||||
available: number
|
||||
@@ -60,7 +65,9 @@ export interface InviteCode {
|
||||
createdAt: string
|
||||
uses: { usedBy: string; usedAt: string }[]
|
||||
}
|
||||
|
||||
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
|
||||
|
||||
export interface CreateAccountParams {
|
||||
handle: string
|
||||
email: string
|
||||
@@ -71,12 +78,14 @@ export interface CreateAccountParams {
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
}
|
||||
|
||||
export interface CreateAccountResult {
|
||||
handle: string
|
||||
did: string
|
||||
verificationRequired: boolean
|
||||
verificationChannel: string
|
||||
}
|
||||
|
||||
export interface ConfirmSignupResult {
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
@@ -87,6 +96,7 @@ export interface ConfirmSignupResult {
|
||||
preferredChannel?: string
|
||||
preferredChannelVerified?: boolean
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
return xrpc('com.atproto.server.createAccount', {
|
||||
@@ -103,42 +113,50 @@ export const api = {
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
|
||||
return xrpc('com.atproto.server.confirmSignup', {
|
||||
method: 'POST',
|
||||
body: { did, verificationCode },
|
||||
})
|
||||
},
|
||||
|
||||
async resendVerification(did: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.atproto.server.resendVerification', {
|
||||
method: 'POST',
|
||||
body: { did },
|
||||
})
|
||||
},
|
||||
|
||||
async createSession(identifier: string, password: string): Promise<Session> {
|
||||
return xrpc('com.atproto.server.createSession', {
|
||||
method: 'POST',
|
||||
body: { identifier, password },
|
||||
})
|
||||
},
|
||||
|
||||
async getSession(token: string): Promise<Session> {
|
||||
return xrpc('com.atproto.server.getSession', { token })
|
||||
},
|
||||
|
||||
async refreshSession(refreshJwt: string): Promise<Session> {
|
||||
return xrpc('com.atproto.server.refreshSession', {
|
||||
method: 'POST',
|
||||
token: refreshJwt,
|
||||
})
|
||||
},
|
||||
|
||||
async deleteSession(token: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.deleteSession', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
|
||||
return xrpc('com.atproto.server.listAppPasswords', { token })
|
||||
},
|
||||
|
||||
async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
|
||||
return xrpc('com.atproto.server.createAppPassword', {
|
||||
method: 'POST',
|
||||
@@ -146,6 +164,7 @@ export const api = {
|
||||
body: { name },
|
||||
})
|
||||
},
|
||||
|
||||
async revokeAppPassword(token: string, name: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.revokeAppPassword', {
|
||||
method: 'POST',
|
||||
@@ -153,9 +172,11 @@ export const api = {
|
||||
body: { name },
|
||||
})
|
||||
},
|
||||
|
||||
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
|
||||
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
|
||||
},
|
||||
|
||||
async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
|
||||
return xrpc('com.atproto.server.createInviteCode', {
|
||||
method: 'POST',
|
||||
@@ -163,24 +184,28 @@ export const api = {
|
||||
body: { useCount },
|
||||
})
|
||||
},
|
||||
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.requestPasswordReset', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
})
|
||||
},
|
||||
|
||||
async resetPassword(token: string, password: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.resetPassword', {
|
||||
method: 'POST',
|
||||
body: { token, password },
|
||||
})
|
||||
},
|
||||
|
||||
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
|
||||
return xrpc('com.atproto.server.requestEmailUpdate', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async updateEmail(token: string, email: string, emailToken?: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.updateEmail', {
|
||||
method: 'POST',
|
||||
@@ -188,6 +213,7 @@ export const api = {
|
||||
body: { email, token: emailToken },
|
||||
})
|
||||
},
|
||||
|
||||
async updateHandle(token: string, handle: string): Promise<void> {
|
||||
await xrpc('com.atproto.identity.updateHandle', {
|
||||
method: 'POST',
|
||||
@@ -195,18 +221,21 @@ export const api = {
|
||||
body: { handle },
|
||||
})
|
||||
},
|
||||
|
||||
async requestAccountDelete(token: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.requestAccountDelete', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.deleteAccount', {
|
||||
method: 'POST',
|
||||
body: { did, password, token: deleteToken },
|
||||
})
|
||||
},
|
||||
|
||||
async describeServer(): Promise<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
@@ -214,6 +243,7 @@ export const api = {
|
||||
}> {
|
||||
return xrpc('com.atproto.server.describeServer')
|
||||
},
|
||||
|
||||
async getNotificationPrefs(token: string): Promise<{
|
||||
preferredChannel: string
|
||||
email: string
|
||||
@@ -226,6 +256,7 @@ export const api = {
|
||||
}> {
|
||||
return xrpc('com.bspds.account.getNotificationPrefs', { token })
|
||||
},
|
||||
|
||||
async updateNotificationPrefs(token: string, prefs: {
|
||||
preferredChannel?: string
|
||||
discordId?: string
|
||||
@@ -238,6 +269,7 @@ export const api = {
|
||||
body: prefs,
|
||||
})
|
||||
},
|
||||
|
||||
async describeRepo(token: string, repo: string): Promise<{
|
||||
handle: string
|
||||
did: string
|
||||
@@ -250,6 +282,7 @@ export const api = {
|
||||
params: { repo },
|
||||
})
|
||||
},
|
||||
|
||||
async listRecords(token: string, repo: string, collection: string, options?: {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
@@ -264,6 +297,7 @@ export const api = {
|
||||
if (options?.reverse) params.reverse = 'true'
|
||||
return xrpc('com.atproto.repo.listRecords', { token, params })
|
||||
},
|
||||
|
||||
async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
|
||||
uri: string
|
||||
cid: string
|
||||
@@ -274,6 +308,7 @@ export const api = {
|
||||
params: { repo, collection, rkey },
|
||||
})
|
||||
},
|
||||
|
||||
async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
|
||||
uri: string
|
||||
cid: string
|
||||
@@ -284,6 +319,7 @@ export const api = {
|
||||
body: { repo, collection, record, rkey },
|
||||
})
|
||||
},
|
||||
|
||||
async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
|
||||
uri: string
|
||||
cid: string
|
||||
@@ -294,6 +330,7 @@ export const api = {
|
||||
body: { repo, collection, rkey, record },
|
||||
})
|
||||
},
|
||||
|
||||
async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> {
|
||||
await xrpc('com.atproto.repo.deleteRecord', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
|
||||
|
||||
const STORAGE_KEY = 'bspds_session'
|
||||
|
||||
interface AuthState {
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
let state = $state<AuthState>({
|
||||
session: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
function saveSession(session: Session | null) {
|
||||
if (session) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
@@ -17,6 +21,7 @@ function saveSession(session: Session | null) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
@@ -28,6 +33,7 @@ function loadSession(): Session | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function initAuth() {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
@@ -54,6 +60,7 @@ export async function initAuth() {
|
||||
}
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
export async function login(identifier: string, password: string): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
@@ -72,6 +79,7 @@ export async function login(identifier: string, password: string): Promise<void>
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
try {
|
||||
const result = await api.createAccount(params)
|
||||
@@ -85,6 +93,7 @@ export async function register(params: CreateAccountParams): Promise<CreateAccou
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
@@ -113,6 +122,7 @@ export async function confirmSignup(did: string, verificationCode: string): Prom
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function resendVerification(did: string): Promise<void> {
|
||||
try {
|
||||
await api.resendVerification(did)
|
||||
@@ -123,6 +133,7 @@ export async function resendVerification(did: string): Promise<void> {
|
||||
throw new Error('Failed to resend verification code')
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (state.session) {
|
||||
try {
|
||||
@@ -134,20 +145,25 @@ export async function logout(): Promise<void> {
|
||||
state.session = null
|
||||
saveSession(null)
|
||||
}
|
||||
|
||||
export function getAuthState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return state.session?.accessJwt ?? null
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return state.session !== null
|
||||
}
|
||||
|
||||
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
|
||||
state.session = newState.session
|
||||
state.loading = newState.loading
|
||||
state.error = newState.error
|
||||
}
|
||||
|
||||
export function _testReset() {
|
||||
state.session = null
|
||||
state.loading = true
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
let currentPath = $state(window.location.hash.slice(1) || '/')
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
currentPath = window.location.hash.slice(1) || '/'
|
||||
})
|
||||
|
||||
export function navigate(path: string) {
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
export function getCurrentPath() {
|
||||
return currentPath
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import App from './App.svelte'
|
||||
import { mount } from 'svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
import { _testReset } from '../lib/auth.svelte'
|
||||
|
||||
let locationHash = ''
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
get hash() { return locationHash },
|
||||
@@ -19,6 +21,7 @@ Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
@@ -26,6 +29,7 @@ beforeEach(() => {
|
||||
locationHash = ''
|
||||
_testReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, type RenderResult } from '@testing-library/svelte'
|
||||
import { tick } from 'svelte'
|
||||
import type { ComponentType } from 'svelte'
|
||||
|
||||
export async function renderAndWait<T extends ComponentType>(
|
||||
component: T,
|
||||
options?: Parameters<typeof render>[1]
|
||||
@@ -10,6 +11,7 @@ export async function renderAndWait<T extends ComponentType>(
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return result
|
||||
}
|
||||
|
||||
export async function waitForElement(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000
|
||||
@@ -22,6 +24,7 @@ export async function waitForElement(
|
||||
}
|
||||
throw new Error('Element not found within timeout')
|
||||
}
|
||||
|
||||
export async function waitForElementToDisappear(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000
|
||||
@@ -34,6 +37,7 @@ export async function waitForElementToDisappear(
|
||||
}
|
||||
throw new Error('Element still present after timeout')
|
||||
}
|
||||
|
||||
export async function waitForText(
|
||||
container: HTMLElement,
|
||||
text: string | RegExp,
|
||||
@@ -49,6 +53,7 @@ export async function waitForText(
|
||||
}
|
||||
throw new Error(`Text "${text}" not found within timeout`)
|
||||
}
|
||||
|
||||
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
|
||||
const store: Record<string, string> = { ...initialData }
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
@@ -63,6 +68,7 @@ export function mockLocalStorage(initialData: Record<string, string> = {}): void
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function setAuthState(session: {
|
||||
did: string
|
||||
handle: string
|
||||
@@ -73,6 +79,7 @@ export function setAuthState(session: {
|
||||
}): void {
|
||||
localStorage.setItem('session', JSON.stringify(session))
|
||||
}
|
||||
|
||||
export function clearAuthState(): void {
|
||||
localStorage.removeItem('session')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod preferences;
|
||||
mod profile;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
pub use profile::{get_profile, get_profiles};
|
||||
|
||||
@@ -7,9 +7,11 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
||||
const MAX_PREFERENCES_COUNT: usize = 100;
|
||||
const MAX_PREFERENCE_SIZE: usize = 10_000;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetPreferencesOutput {
|
||||
pub preferences: Vec<Value>,
|
||||
@@ -84,6 +86,7 @@ pub async fn get_preferences(
|
||||
.collect();
|
||||
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PutPreferencesInput {
|
||||
pub preferences: Vec<Value>,
|
||||
|
||||
@@ -11,14 +11,17 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetProfileParams {
|
||||
pub actor: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetProfilesParams {
|
||||
pub actors: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileViewDetailed {
|
||||
@@ -35,10 +38,12 @@ pub struct ProfileViewDetailed {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GetProfilesOutput {
|
||||
pub profiles: Vec<ProfileViewDetailed>,
|
||||
}
|
||||
|
||||
async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value> {
|
||||
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -55,6 +60,7 @@ async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value>
|
||||
let block_bytes = state.block_store.get(&cid).await.ok()??;
|
||||
serde_ipld_dagcbor::from_slice(&block_bytes).ok()
|
||||
}
|
||||
|
||||
fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Value) {
|
||||
if let Some(display_name) = local_record.get("displayName").and_then(|v| v.as_str()) {
|
||||
profile.display_name = Some(display_name.to_string());
|
||||
@@ -63,6 +69,7 @@ fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Va
|
||||
profile.description = Some(description.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_to_appview(
|
||||
method: &str,
|
||||
params: &HashMap<String, String>,
|
||||
@@ -104,6 +111,7 @@ async fn proxy_to_appview(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -146,6 +154,7 @@ pub async fn get_profile(
|
||||
}
|
||||
(StatusCode::OK, Json(profile)).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_profiles(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -8,10 +8,12 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountInput {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -8,6 +8,7 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendEmailInput {
|
||||
@@ -17,10 +18,12 @@ pub struct SendEmailInput {
|
||||
pub subject: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SendEmailOutput {
|
||||
pub sent: bool,
|
||||
}
|
||||
|
||||
pub async fn send_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -8,10 +8,12 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfoParams {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountInfo {
|
||||
@@ -24,11 +26,13 @@ pub struct AccountInfo {
|
||||
pub email_confirmed_at: Option<String>,
|
||||
pub deactivated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetAccountInfosOutput {
|
||||
pub infos: Vec<AccountInfo>,
|
||||
}
|
||||
|
||||
pub async fn get_account_info(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -92,10 +96,12 @@ pub async fn get_account_info(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfosParams {
|
||||
pub dids: String,
|
||||
}
|
||||
|
||||
pub async fn get_account_infos(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -3,6 +3,7 @@ mod email;
|
||||
mod info;
|
||||
mod profile;
|
||||
mod update;
|
||||
|
||||
pub use delete::{delete_account, DeleteAccountInput};
|
||||
pub use email::{send_email, SendEmailInput, SendEmailOutput};
|
||||
pub use info::{
|
||||
|
||||
@@ -8,11 +8,13 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAccountEmailInput {
|
||||
pub account: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn update_account_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -59,11 +61,13 @@ pub async fn update_account_email(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAccountHandleInput {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn update_account_handle(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -139,11 +143,13 @@ pub async fn update_account_handle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAccountPasswordInput {
|
||||
pub did: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn update_account_password(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -8,12 +8,14 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisableInviteCodesInput {
|
||||
pub codes: Option<Vec<String>>,
|
||||
pub accounts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn disable_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -51,12 +53,14 @@ pub async fn disable_invite_codes(
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetInviteCodesParams {
|
||||
pub sort: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeInfo {
|
||||
@@ -68,17 +72,20 @@ pub struct InviteCodeInfo {
|
||||
pub created_at: String,
|
||||
pub uses: Vec<InviteCodeUseInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeUseInfo {
|
||||
pub used_by: String,
|
||||
pub used_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetInviteCodesOutput {
|
||||
pub cursor: Option<String>,
|
||||
pub codes: Vec<InviteCodeInfo>,
|
||||
}
|
||||
|
||||
pub async fn get_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -192,10 +199,12 @@ pub async fn get_invite_codes(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DisableAccountInvitesInput {
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
pub async fn disable_account_invites(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -241,10 +250,12 @@ pub async fn disable_account_invites(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EnableAccountInvitesInput {
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
pub async fn enable_account_invites(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod account;
|
||||
pub mod invite;
|
||||
pub mod status;
|
||||
|
||||
pub use account::{
|
||||
create_profile, create_record_admin, delete_account, get_account_info, get_account_infos,
|
||||
send_email, update_account_email, update_account_handle, update_account_password,
|
||||
|
||||
@@ -8,24 +8,28 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetSubjectStatusParams {
|
||||
pub did: Option<String>,
|
||||
pub uri: Option<String>,
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SubjectStatus {
|
||||
pub subject: serde_json::Value,
|
||||
pub takedown: Option<StatusAttr>,
|
||||
pub deactivated: Option<StatusAttr>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusAttr {
|
||||
pub applied: bool,
|
||||
pub r#ref: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_subject_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -184,6 +188,7 @@ pub async fn get_subject_status(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateSubjectStatusInput {
|
||||
@@ -191,11 +196,13 @@ pub struct UpdateSubjectStatusInput {
|
||||
pub takedown: Option<StatusAttrInput>,
|
||||
pub deactivated: Option<StatusAttrInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatusAttrInput {
|
||||
pub apply: bool,
|
||||
pub r#ref: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_subject_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -4,12 +4,14 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
InternalError,
|
||||
@@ -46,6 +48,7 @@ pub enum ApiError {
|
||||
UpstreamUnavailable(String),
|
||||
UpstreamError { status: u16, error: Option<String>, message: Option<String> },
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
@@ -144,6 +147,7 @@ impl ApiError {
|
||||
Self::UpstreamError { status, error: None, message: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let body = ErrorBody {
|
||||
@@ -153,12 +157,14 @@ impl IntoResponse for ApiError {
|
||||
(self.status_code(), Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
Self::DatabaseError
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::auth::TokenValidationError> for ApiError {
|
||||
fn from(e: crate::auth::TokenValidationError) -> Self {
|
||||
match e {
|
||||
@@ -169,6 +175,7 @@ impl From<crate::auth::TokenValidationError> for ApiError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::util::DbLookupError> for ApiError {
|
||||
fn from(e: crate::util::DbLookupError) -> Self {
|
||||
match e {
|
||||
|
||||
@@ -13,12 +13,14 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetActorLikesParams {
|
||||
pub actor: String,
|
||||
pub limit: Option<u32>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<LikeRecord>]) {
|
||||
for like in likes {
|
||||
let like_time = &like.indexed_at.to_rfc3339();
|
||||
@@ -57,6 +59,7 @@ fn insert_likes_into_feed(feed: &mut Vec<FeedViewPost>, likes: &[RecordDescript<
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_actor_likes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -13,6 +13,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAuthorFeedParams {
|
||||
pub actor: String,
|
||||
@@ -22,6 +23,7 @@ pub struct GetAuthorFeedParams {
|
||||
#[serde(rename = "includePins")]
|
||||
pub include_pins: Option<bool>,
|
||||
}
|
||||
|
||||
fn update_author_profile_in_feed(
|
||||
feed: &mut [FeedViewPost],
|
||||
author_did: &str,
|
||||
@@ -35,6 +37,7 @@ fn update_author_profile_in_feed(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_author_feed(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -11,12 +11,14 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetFeedParams {
|
||||
pub feed: String,
|
||||
pub limit: Option<u32>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_feed(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -3,6 +3,7 @@ mod author_feed;
|
||||
mod custom_feed;
|
||||
mod post_thread;
|
||||
mod timeline;
|
||||
|
||||
pub use actor_likes::get_actor_likes;
|
||||
pub use author_feed::get_author_feed;
|
||||
pub use custom_feed::get_feed;
|
||||
|
||||
@@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetPostThreadParams {
|
||||
pub uri: String,
|
||||
@@ -20,6 +21,7 @@ pub struct GetPostThreadParams {
|
||||
#[serde(rename = "parentHeight")]
|
||||
pub parent_height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadViewPost {
|
||||
@@ -33,6 +35,7 @@ pub struct ThreadViewPost {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ThreadNode {
|
||||
@@ -40,6 +43,7 @@ pub enum ThreadNode {
|
||||
NotFound(ThreadNotFound),
|
||||
Blocked(ThreadBlocked),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadNotFound {
|
||||
@@ -48,6 +52,7 @@ pub struct ThreadNotFound {
|
||||
pub uri: String,
|
||||
pub not_found: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadBlocked {
|
||||
@@ -57,13 +62,16 @@ pub struct ThreadBlocked {
|
||||
pub blocked: bool,
|
||||
pub author: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostThreadOutput {
|
||||
pub thread: ThreadNode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub threadgate: Option<Value>,
|
||||
}
|
||||
|
||||
const MAX_THREAD_DEPTH: usize = 10;
|
||||
|
||||
fn add_replies_to_thread(
|
||||
thread: &mut ThreadViewPost,
|
||||
local_posts: &[RecordDescript<PostRecord>],
|
||||
@@ -111,6 +119,7 @@ fn add_replies_to_thread(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_post_thread(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -190,6 +199,7 @@ pub async fn get_post_thread(
|
||||
let lag = get_local_lag(&local_records);
|
||||
format_munged_response(thread_output, lag)
|
||||
}
|
||||
|
||||
async fn handle_not_found(
|
||||
state: &AppState,
|
||||
uri: &str,
|
||||
|
||||
@@ -15,12 +15,14 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetTimelineParams {
|
||||
pub algorithm: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_timeline(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -56,6 +58,7 @@ pub async fn get_timeline(
|
||||
}
|
||||
get_timeline_local_only(&state, &auth_user.did).await
|
||||
}
|
||||
|
||||
async fn get_timeline_with_appview(
|
||||
state: &AppState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
@@ -123,6 +126,7 @@ async fn get_timeline_with_appview(
|
||||
let lag = get_local_lag(&local_records);
|
||||
format_munged_response(feed_output, lag)
|
||||
}
|
||||
|
||||
async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
|
||||
let user_id: uuid::Uuid = match sqlx::query_scalar!(
|
||||
"SELECT id FROM users WHERE did = $1",
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
@@ -31,6 +32,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountInput {
|
||||
@@ -45,6 +47,7 @@ pub struct CreateAccountInput {
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountOutput {
|
||||
@@ -53,6 +56,7 @@ pub struct CreateAccountOutput {
|
||||
pub verification_required: bool,
|
||||
pub verification_channel: String,
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -13,10 +13,12 @@ use reqwest;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResolveHandleParams {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn resolve_handle(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ResolveHandleParams>,
|
||||
@@ -63,6 +65,7 @@ pub async fn resolve_handle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
|
||||
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
|
||||
let public_key = secret_key.public_key();
|
||||
@@ -78,6 +81,7 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
|
||||
"y": y_b64
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
// Kinda for local dev, encode hostname if it contains port
|
||||
@@ -96,6 +100,7 @@ pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
|
||||
@@ -174,6 +179,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
}]
|
||||
})).into_response()
|
||||
}
|
||||
|
||||
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
|
||||
let expected_prefix = if hostname.contains(':') {
|
||||
format!("did:web:{}", hostname.replace(':', "%3A"))
|
||||
@@ -242,6 +248,7 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetRecommendedDidCredentialsOutput {
|
||||
@@ -250,16 +257,19 @@ pub struct GetRecommendedDidCredentialsOutput {
|
||||
pub verification_methods: VerificationMethods,
|
||||
pub services: Services,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerificationMethods {
|
||||
pub atproto: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Services {
|
||||
pub atproto_pds: AtprotoPds,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AtprotoPds {
|
||||
@@ -267,6 +277,7 @@ pub struct AtprotoPds {
|
||||
pub service_type: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
pub async fn get_recommended_did_credentials(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -329,10 +340,12 @@ pub async fn get_recommended_did_credentials(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateHandleInput {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn update_handle(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -410,6 +423,7 @@ pub async fn update_handle(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn well_known_atproto_did(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod account;
|
||||
pub mod did;
|
||||
pub mod plc;
|
||||
|
||||
pub use account::create_account;
|
||||
pub use did::{
|
||||
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod request;
|
||||
mod sign;
|
||||
mod submit;
|
||||
|
||||
pub use request::request_plc_operation_signature;
|
||||
pub use sign::{sign_plc_operation, ServiceInput, SignPlcOperationInput, SignPlcOperationOutput};
|
||||
pub use submit::{submit_plc_operation, SubmitPlcOperationInput};
|
||||
|
||||
@@ -9,9 +9,11 @@ use axum::{
|
||||
use chrono::{Duration, Utc};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn generate_plc_token() -> String {
|
||||
crate::util::generate_token_code()
|
||||
}
|
||||
|
||||
pub async fn request_plc_operation_signature(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SignPlcOperationInput {
|
||||
@@ -25,16 +26,19 @@ pub struct SignPlcOperationInput {
|
||||
pub verification_methods: Option<HashMap<String, String>>,
|
||||
pub services: Option<HashMap<String, ServiceInput>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServiceInput {
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SignPlcOperationOutput {
|
||||
pub operation: Value,
|
||||
}
|
||||
|
||||
pub async fn sign_plc_operation(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -12,10 +12,12 @@ use k256::ecdsa::SigningKey;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitPlcOperationInput {
|
||||
pub operation: Value,
|
||||
}
|
||||
|
||||
pub async fn submit_plc_operation(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -13,5 +13,6 @@ pub mod repo;
|
||||
pub mod server;
|
||||
pub mod temp;
|
||||
pub mod validation;
|
||||
|
||||
pub use error::ApiError;
|
||||
pub use proxy_client::{proxy_client, validate_at_uri, validate_did, validate_limit, AtUriParts};
|
||||
|
||||
@@ -9,6 +9,7 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateReportInput {
|
||||
@@ -16,6 +17,7 @@ pub struct CreateReportInput {
|
||||
pub reason: Option<String>,
|
||||
pub subject: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateReportOutput {
|
||||
@@ -26,6 +28,7 @@ pub struct CreateReportOutput {
|
||||
pub reported_by: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub async fn create_report(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
mod register_push;
|
||||
|
||||
pub use register_push::register_push;
|
||||
|
||||
@@ -10,6 +10,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterPushInput {
|
||||
@@ -18,7 +19,9 @@ pub struct RegisterPushInput {
|
||||
pub platform: String,
|
||||
pub app_id: String,
|
||||
}
|
||||
|
||||
const VALID_PLATFORMS: &[&str] = &["ios", "android", "web"];
|
||||
|
||||
pub async fn register_push(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -10,6 +10,7 @@ use sqlx::Row;
|
||||
use tracing::info;
|
||||
use crate::auth::validate_bearer_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPrefsResponse {
|
||||
@@ -22,6 +23,7 @@ pub struct NotificationPrefsResponse {
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_verified: bool,
|
||||
}
|
||||
|
||||
pub async fn get_notification_prefs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -96,6 +98,7 @@ pub async fn get_notification_prefs(
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateNotificationPrefsInput {
|
||||
@@ -104,6 +107,7 @@ pub struct UpdateNotificationPrefsInput {
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_notification_prefs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -8,6 +8,7 @@ use axum::{
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn proxy_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(method): Path<String>,
|
||||
|
||||
@@ -3,11 +3,14 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
pub const DEFAULT_HEADERS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub const DEFAULT_BODY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub const MAX_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static PROXY_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
pub fn proxy_client() -> &'static Client {
|
||||
PROXY_CLIENT.get_or_init(|| {
|
||||
ClientBuilder::new()
|
||||
@@ -20,6 +23,7 @@ pub fn proxy_client() -> &'static Client {
|
||||
.expect("Failed to build HTTP client - this indicates a TLS or system configuration issue")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
|
||||
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
|
||||
let scheme = parsed.scheme();
|
||||
@@ -61,6 +65,7 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unicast_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
@@ -74,6 +79,7 @@ fn is_unicast_ip(ip: &IpAddr) -> bool {
|
||||
IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_multicast() && !v6.is_unspecified(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
|
||||
let octets = ip.octets();
|
||||
octets[0] == 10
|
||||
@@ -81,6 +87,7 @@ fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
|
||||
|| (octets[0] == 192 && octets[1] == 168)
|
||||
|| (octets[0] == 169 && octets[1] == 254)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SsrfError {
|
||||
InvalidUrl,
|
||||
@@ -89,6 +96,7 @@ pub enum SsrfError {
|
||||
NonUnicastIp(String),
|
||||
DnsResolutionFailed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SsrfError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -100,7 +108,9 @@ impl std::fmt::Display for SsrfError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SsrfError {}
|
||||
|
||||
pub const HEADERS_TO_FORWARD: &[&str] = &[
|
||||
"accept-language",
|
||||
"atproto-accept-labelers",
|
||||
@@ -112,6 +122,7 @@ pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[
|
||||
"retry-after",
|
||||
"content-type",
|
||||
];
|
||||
|
||||
pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
|
||||
if !uri.starts_with("at://") {
|
||||
return Err("URI must start with at://");
|
||||
@@ -137,12 +148,14 @@ pub fn validate_at_uri(uri: &str) -> Result<AtUriParts, &'static str> {
|
||||
rkey: parts.get(2).map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AtUriParts {
|
||||
pub did: String,
|
||||
pub collection: Option<String>,
|
||||
pub rkey: Option<String>,
|
||||
}
|
||||
|
||||
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
|
||||
match limit {
|
||||
Some(l) if l == 0 => default,
|
||||
@@ -151,6 +164,7 @@ pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
|
||||
None => default,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_did(did: &str) -> Result<(), &'static str> {
|
||||
if !did.starts_with("did:") {
|
||||
return Err("Invalid DID format");
|
||||
@@ -165,6 +179,7 @@ pub fn validate_did(did: &str) -> Result<(), &'static str> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -17,8 +17,10 @@ use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const REPO_REV_HEADER: &str = "atproto-repo-rev";
|
||||
pub const UPSTREAM_LAG_HEADER: &str = "atproto-upstream-lag";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PostRecord {
|
||||
@@ -39,6 +41,7 @@ pub struct PostRecord {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileRecord {
|
||||
@@ -55,6 +58,7 @@ pub struct ProfileRecord {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecordDescript<T> {
|
||||
pub uri: String,
|
||||
@@ -62,6 +66,7 @@ pub struct RecordDescript<T> {
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
pub record: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LikeRecord {
|
||||
@@ -72,12 +77,14 @@ pub struct LikeRecord {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LikeSubject {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LocalRecords {
|
||||
pub count: usize,
|
||||
@@ -85,6 +92,7 @@ pub struct LocalRecords {
|
||||
pub posts: Vec<RecordDescript<PostRecord>>,
|
||||
pub likes: Vec<RecordDescript<LikeRecord>>,
|
||||
}
|
||||
|
||||
pub async fn get_records_since_rev(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
@@ -187,6 +195,7 @@ pub async fn get_records_since_rev(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
|
||||
let mut oldest: Option<DateTime<Utc>> = local.profile.as_ref().map(|p| p.indexed_at);
|
||||
for post in &local.posts {
|
||||
@@ -205,18 +214,21 @@ pub fn get_local_lag(local: &LocalRecords) -> Option<i64> {
|
||||
}
|
||||
oldest.map(|o| (Utc::now() - o).num_milliseconds())
|
||||
}
|
||||
|
||||
pub fn extract_repo_rev(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get(REPO_REV_HEADER)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProxyResponse {
|
||||
pub status: StatusCode,
|
||||
pub headers: HeaderMap,
|
||||
pub body: bytes::Bytes,
|
||||
}
|
||||
|
||||
pub async fn proxy_to_appview(
|
||||
method: &str,
|
||||
params: &HashMap<String, String>,
|
||||
@@ -297,6 +309,7 @@ pub async fn proxy_to_appview(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Response {
|
||||
let mut response = (StatusCode::OK, Json(data)).into_response();
|
||||
if let Some(lag_ms) = lag {
|
||||
@@ -308,6 +321,7 @@ pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Respon
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthorView {
|
||||
@@ -320,6 +334,7 @@ pub struct AuthorView {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PostView {
|
||||
@@ -341,6 +356,7 @@ pub struct PostView {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FeedViewPost {
|
||||
@@ -354,12 +370,14 @@ pub struct FeedViewPost {
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeedOutput {
|
||||
pub feed: Vec<FeedViewPost>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
pub fn format_local_post(
|
||||
descript: &RecordDescript<PostRecord>,
|
||||
author_did: &str,
|
||||
@@ -387,6 +405,7 @@ pub fn format_local_post(
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_posts_into_feed(feed: &mut Vec<FeedViewPost>, posts: Vec<PostView>) {
|
||||
if posts.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -14,7 +14,9 @@ use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
|
||||
const MAX_BLOB_SIZE: usize = 1_000_000;
|
||||
|
||||
pub async fn upload_blob(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -154,22 +156,26 @@ pub async fn upload_blob(
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListMissingBlobsParams {
|
||||
pub limit: Option<i64>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordBlob {
|
||||
pub cid: String,
|
||||
pub record_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListMissingBlobsOutput {
|
||||
pub cursor: Option<String>,
|
||||
pub blobs: Vec<RecordBlob>,
|
||||
}
|
||||
|
||||
fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
|
||||
if let Some(obj) = val.as_object() {
|
||||
if let Some(type_val) = obj.get("$type") {
|
||||
@@ -192,6 +198,7 @@ fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_missing_blobs(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -11,8 +11,10 @@ use axum::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const DEFAULT_MAX_IMPORT_SIZE: usize = 100 * 1024 * 1024;
|
||||
const DEFAULT_MAX_BLOCKS: usize = 50000;
|
||||
|
||||
pub async fn import_repo(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -355,6 +357,7 @@ pub async fn import_repo(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sequence_import_event(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
|
||||
@@ -7,10 +7,12 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DescribeRepoInput {
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
pub async fn describe_repo(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<DescribeRepoInput>,
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod blob;
|
||||
pub mod import;
|
||||
pub mod meta;
|
||||
pub mod record;
|
||||
|
||||
pub use blob::{list_missing_blobs, upload_blob};
|
||||
pub use import::import_repo;
|
||||
pub use meta::describe_repo;
|
||||
|
||||
@@ -17,7 +17,9 @@ use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
|
||||
const MAX_BATCH_WRITES: usize = 200;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "$type")]
|
||||
pub enum WriteOp {
|
||||
@@ -36,6 +38,7 @@ pub enum WriteOp {
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#delete")]
|
||||
Delete { collection: String, rkey: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyWritesInput {
|
||||
@@ -44,6 +47,7 @@ pub struct ApplyWritesInput {
|
||||
pub writes: Vec<WriteOp>,
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "$type")]
|
||||
pub enum WriteResult {
|
||||
@@ -54,16 +58,19 @@ pub enum WriteResult {
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
|
||||
DeleteResult {},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApplyWritesOutput {
|
||||
pub commit: CommitInfo,
|
||||
pub results: Vec<WriteResult>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CommitInfo {
|
||||
pub cid: String,
|
||||
pub rev: String,
|
||||
}
|
||||
|
||||
pub async fn apply_writes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordInput {
|
||||
pub repo: String,
|
||||
@@ -26,6 +27,7 @@ pub struct DeleteRecordInput {
|
||||
#[serde(rename = "swapCommit")]
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn delete_record(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod read;
|
||||
pub mod utils;
|
||||
pub mod validation;
|
||||
pub mod write;
|
||||
|
||||
pub use batch::apply_writes;
|
||||
pub use delete::{DeleteRecordInput, delete_record};
|
||||
pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetRecordInput {
|
||||
pub repo: String,
|
||||
@@ -19,6 +20,7 @@ pub struct GetRecordInput {
|
||||
pub rkey: String,
|
||||
pub cid: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_record(
|
||||
State(state): State<AppState>,
|
||||
Query(input): Query<GetRecordInput>,
|
||||
|
||||
@@ -28,6 +28,7 @@ struct UnsignedCommit<'a> {
|
||||
rev: &'a str,
|
||||
version: i64,
|
||||
}
|
||||
|
||||
fn create_signed_commit(
|
||||
did: &str,
|
||||
data: Cid,
|
||||
@@ -68,15 +69,18 @@ fn create_signed_commit(
|
||||
.map_err(|e| format!("Failed to serialize signed commit: {:?}", e))?;
|
||||
Ok((signed_bytes, sig_bytes))
|
||||
}
|
||||
|
||||
pub enum RecordOp {
|
||||
Create { collection: String, rkey: String, cid: Cid },
|
||||
Update { collection: String, rkey: String, cid: Cid, prev: Option<Cid> },
|
||||
Delete { collection: String, rkey: String, prev: Option<Cid> },
|
||||
}
|
||||
|
||||
pub struct CommitResult {
|
||||
pub commit_cid: Cid,
|
||||
pub rev: String,
|
||||
}
|
||||
|
||||
pub async fn commit_and_log(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Response> {
|
||||
let validator = RecordValidator::new();
|
||||
match validator.validate(record, collection) {
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
@@ -44,6 +45,7 @@ pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_repo_write(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckAccountStatusOutput {
|
||||
@@ -25,6 +26,7 @@ pub struct CheckAccountStatusOutput {
|
||||
pub expected_blobs: i64,
|
||||
pub imported_blobs: i64,
|
||||
}
|
||||
|
||||
pub async fn check_account_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -94,6 +96,7 @@ pub async fn check_account_status(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn activate_account(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -133,11 +136,13 @@ pub async fn activate_account(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeactivateAccountInput {
|
||||
pub delete_after: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn deactivate_account(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -178,6 +183,7 @@ pub async fn deactivate_account(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_account_delete(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -232,12 +238,14 @@ pub async fn request_account_delete(
|
||||
info!("Account deletion requested for user {}", did);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountInput {
|
||||
pub did: String,
|
||||
pub password: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<DeleteAccountInput>,
|
||||
|
||||
@@ -11,6 +11,7 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppPassword {
|
||||
@@ -18,10 +19,12 @@ pub struct AppPassword {
|
||||
pub created_at: String,
|
||||
pub privileged: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListAppPasswordsOutput {
|
||||
pub passwords: Vec<AppPassword>,
|
||||
}
|
||||
|
||||
pub async fn list_app_passwords(
|
||||
State(state): State<AppState>,
|
||||
BearerAuth(auth_user): BearerAuth,
|
||||
@@ -54,11 +57,13 @@ pub async fn list_app_passwords(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAppPasswordInput {
|
||||
pub name: String,
|
||||
pub privileged: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAppPasswordOutput {
|
||||
@@ -67,6 +72,7 @@ pub struct CreateAppPasswordOutput {
|
||||
pub created_at: String,
|
||||
pub privileged: bool,
|
||||
}
|
||||
|
||||
pub async fn create_app_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -146,10 +152,12 @@ pub async fn create_app_password(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RevokeAppPasswordInput {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn revoke_app_password(
|
||||
State(state): State<AppState>,
|
||||
BearerAuth(auth_user): BearerAuth,
|
||||
|
||||
@@ -10,14 +10,17 @@ use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn generate_confirmation_code() -> String {
|
||||
crate::util::generate_token_code()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestEmailUpdateInput {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn request_email_update(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -119,12 +122,14 @@ pub async fn request_email_update(
|
||||
info!("Email update requested for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmEmailInput {
|
||||
pub email: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn confirm_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -236,6 +241,7 @@ pub async fn confirm_email(
|
||||
info!("Email updated for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateEmailInput {
|
||||
@@ -244,6 +250,7 @@ pub struct UpdateEmailInput {
|
||||
pub email_auth_factor: Option<bool>,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_email(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -10,16 +10,19 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateInviteCodeInput {
|
||||
pub use_count: i32,
|
||||
pub for_account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateInviteCodeOutput {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn create_invite_code(
|
||||
State(state): State<AppState>,
|
||||
BearerAuth(auth_user): BearerAuth,
|
||||
@@ -81,6 +84,7 @@ pub async fn create_invite_code(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateInviteCodesInput {
|
||||
@@ -88,15 +92,18 @@ pub struct CreateInviteCodesInput {
|
||||
pub use_count: i32,
|
||||
pub for_accounts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateInviteCodesOutput {
|
||||
pub codes: Vec<AccountCodes>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountCodes {
|
||||
pub account: String,
|
||||
pub codes: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn create_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
BearerAuth(auth_user): BearerAuth,
|
||||
@@ -172,12 +179,14 @@ pub async fn create_invite_codes(
|
||||
}
|
||||
Json(CreateInviteCodesOutput { codes: result_codes }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetAccountInviteCodesParams {
|
||||
pub include_used: Option<bool>,
|
||||
pub create_available: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCode {
|
||||
@@ -189,16 +198,19 @@ pub struct InviteCode {
|
||||
pub created_at: String,
|
||||
pub uses: Vec<InviteCodeUse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeUse {
|
||||
pub used_by: String,
|
||||
pub used_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetAccountInviteCodesOutput {
|
||||
pub codes: Vec<InviteCode>,
|
||||
}
|
||||
|
||||
pub async fn get_account_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
BearerAuth(auth_user): BearerAuth,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod password;
|
||||
pub mod service_auth;
|
||||
pub mod session;
|
||||
pub mod signing_key;
|
||||
|
||||
pub use account_status::{
|
||||
activate_account, check_account_status, deactivate_account, delete_account,
|
||||
request_account_delete,
|
||||
|
||||
@@ -10,6 +10,7 @@ use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn generate_reset_code() -> String {
|
||||
crate::util::generate_token_code()
|
||||
}
|
||||
@@ -28,10 +29,12 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn request_password_reset(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -102,11 +105,13 @@ pub async fn request_password_reset(
|
||||
info!("Password reset requested for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordInput {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -9,16 +9,19 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetServiceAuthParams {
|
||||
pub aud: String,
|
||||
pub lxm: Option<String>,
|
||||
pub exp: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetServiceAuthOutput {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn get_service_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
|
||||
@@ -12,6 +12,7 @@ use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
@@ -27,11 +28,13 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSessionInput {
|
||||
pub identifier: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionOutput {
|
||||
@@ -40,6 +43,7 @@ pub struct CreateSessionOutput {
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -155,6 +159,7 @@ pub async fn create_session(
|
||||
did: row.did,
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
BearerAuth(auth_user): BearerAuth,
|
||||
@@ -194,6 +199,7 @@ pub async fn get_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_session(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -227,6 +233,7 @@ pub async fn delete_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -395,12 +402,14 @@ pub async fn refresh_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmSignupInput {
|
||||
pub did: String,
|
||||
pub verification_code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmSignupOutput {
|
||||
@@ -413,6 +422,7 @@ pub struct ConfirmSignupOutput {
|
||||
pub preferred_channel: String,
|
||||
pub preferred_channel_verified: bool,
|
||||
}
|
||||
|
||||
pub async fn confirm_signup(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ConfirmSignupInput>,
|
||||
@@ -535,11 +545,13 @@ pub async fn confirm_signup(
|
||||
preferred_channel_verified: true,
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendVerificationInput {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
pub async fn resend_verification(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ResendVerificationInput>,
|
||||
|
||||
@@ -10,7 +10,9 @@ use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
|
||||
|
||||
fn public_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let compressed_pubkey = verifying_key.to_sec1_bytes();
|
||||
@@ -20,15 +22,18 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> String {
|
||||
let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key);
|
||||
format!("did:key:{}", encoded)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReserveSigningKeyInput {
|
||||
pub did: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReserveSigningKeyOutput {
|
||||
pub signing_key: String,
|
||||
}
|
||||
|
||||
pub async fn reserve_signing_key(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ReserveSigningKeyInput>,
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckSignupQueueOutput {
|
||||
@@ -17,6 +18,7 @@ pub struct CheckSignupQueueOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub estimated_time_ms: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn check_signup_queue(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub const MAX_LOCAL_PART_LENGTH: usize = 64;
|
||||
pub const MAX_DOMAIN_LENGTH: usize = 253;
|
||||
pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
|
||||
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
|
||||
|
||||
pub fn is_valid_email(email: &str) -> bool {
|
||||
let email = email.trim();
|
||||
if email.is_empty() || email.len() > MAX_EMAIL_LENGTH {
|
||||
@@ -49,6 +50,7 @@ pub fn is_valid_email(email: &str) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,9 +5,12 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::AppState;
|
||||
use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated};
|
||||
|
||||
pub struct BearerAuth(pub AuthenticatedUser);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthError {
|
||||
MissingToken,
|
||||
@@ -16,6 +19,7 @@ pub enum AuthError {
|
||||
AccountDeactivated,
|
||||
AccountTakedown,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error, message) = match self {
|
||||
@@ -45,41 +49,54 @@ impl IntoResponse for AuthError {
|
||||
"Account has been taken down",
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(json!({ "error": error, "message": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> {
|
||||
let auth_header = auth_header.trim();
|
||||
|
||||
if auth_header.len() < 8 {
|
||||
return Err(AuthError::InvalidFormat);
|
||||
}
|
||||
|
||||
let prefix = &auth_header[..7];
|
||||
if !prefix.eq_ignore_ascii_case("bearer ") {
|
||||
return Err(AuthError::InvalidFormat);
|
||||
}
|
||||
|
||||
let token = auth_header[7..].trim();
|
||||
if token.is_empty() {
|
||||
return Err(AuthError::InvalidFormat);
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option<String> {
|
||||
let header = auth_header?;
|
||||
let header = header.trim();
|
||||
|
||||
if header.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !header[..7].eq_ignore_ascii_case("bearer ") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let token = header[7..].trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for BearerAuth {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
@@ -90,7 +107,9 @@ impl FromRequestParts<AppState> for BearerAuth {
|
||||
.ok_or(AuthError::MissingToken)?
|
||||
.to_str()
|
||||
.map_err(|_| AuthError::InvalidFormat)?;
|
||||
|
||||
let token = extract_bearer_token(auth_header)?;
|
||||
|
||||
match validate_bearer_token_cached(&state.db, &state.cache, token).await {
|
||||
Ok(user) => Ok(BearerAuth(user)),
|
||||
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
|
||||
@@ -99,9 +118,12 @@ impl FromRequestParts<AppState> for BearerAuth {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BearerAuthAllowDeactivated(pub AuthenticatedUser);
|
||||
|
||||
impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
@@ -112,7 +134,9 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
|
||||
.ok_or(AuthError::MissingToken)?
|
||||
.to_str()
|
||||
.map_err(|_| AuthError::InvalidFormat)?;
|
||||
|
||||
let token = extract_bearer_token(auth_header)?;
|
||||
|
||||
match validate_bearer_token_cached_allow_deactivated(&state.db, &state.cache, token).await {
|
||||
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
|
||||
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
|
||||
@@ -120,9 +144,11 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_bearer_token() {
|
||||
assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
|
||||
@@ -130,6 +156,7 @@ mod tests {
|
||||
assert_eq!(extract_bearer_token("BEARER abc123").unwrap(), "abc123");
|
||||
assert_eq!(extract_bearer_token("Bearer abc123").unwrap(), "abc123");
|
||||
assert_eq!(extract_bearer_token(" Bearer abc123 ").unwrap(), "abc123");
|
||||
|
||||
assert!(extract_bearer_token("Basic abc123").is_err());
|
||||
assert!(extract_bearer_token("Bearer").is_err());
|
||||
assert!(extract_bearer_token("Bearer ").is_err());
|
||||
|
||||
+35
-1
@@ -3,10 +3,13 @@ use sqlx::PgPool;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::Cache;
|
||||
|
||||
pub mod extractor;
|
||||
pub mod token;
|
||||
pub mod verify;
|
||||
|
||||
pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header};
|
||||
pub use token::{
|
||||
create_access_token, create_refresh_token, create_service_token,
|
||||
@@ -16,8 +19,10 @@ pub use token::{
|
||||
SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
|
||||
};
|
||||
pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token};
|
||||
|
||||
const KEY_CACHE_TTL_SECS: u64 = 300;
|
||||
const SESSION_CACHE_TTL_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenValidationError {
|
||||
AccountDeactivated,
|
||||
@@ -25,6 +30,7 @@ pub enum TokenValidationError {
|
||||
KeyDecryptionFailed,
|
||||
AuthenticationFailed,
|
||||
}
|
||||
|
||||
impl fmt::Display for TokenValidationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -35,23 +41,27 @@ impl fmt::Display for TokenValidationError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthenticatedUser {
|
||||
pub did: String,
|
||||
pub key_bytes: Option<Vec<u8>>,
|
||||
pub is_oauth: bool,
|
||||
}
|
||||
|
||||
pub async fn validate_bearer_token(
|
||||
db: &PgPool,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
validate_bearer_token_with_options_internal(db, None, token, false).await
|
||||
}
|
||||
|
||||
pub async fn validate_bearer_token_allow_deactivated(
|
||||
db: &PgPool,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
validate_bearer_token_with_options_internal(db, None, token, true).await
|
||||
}
|
||||
|
||||
pub async fn validate_bearer_token_cached(
|
||||
db: &PgPool,
|
||||
cache: &Arc<dyn Cache>,
|
||||
@@ -59,6 +69,7 @@ pub async fn validate_bearer_token_cached(
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
validate_bearer_token_with_options_internal(db, Some(cache), token, false).await
|
||||
}
|
||||
|
||||
pub async fn validate_bearer_token_cached_allow_deactivated(
|
||||
db: &PgPool,
|
||||
cache: &Arc<dyn Cache>,
|
||||
@@ -66,6 +77,7 @@ pub async fn validate_bearer_token_cached_allow_deactivated(
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
validate_bearer_token_with_options_internal(db, Some(cache), token, true).await
|
||||
}
|
||||
|
||||
async fn validate_bearer_token_with_options_internal(
|
||||
db: &PgPool,
|
||||
cache: Option<&Arc<dyn Cache>>,
|
||||
@@ -73,9 +85,11 @@ async fn validate_bearer_token_with_options_internal(
|
||||
allow_deactivated: bool,
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
let did_from_token = get_did_from_token(token).ok();
|
||||
|
||||
if let Some(ref did) = did_from_token {
|
||||
let key_cache_key = format!("auth:key:{}", did);
|
||||
let mut cached_key: Option<Vec<u8>> = None;
|
||||
|
||||
if let Some(c) = cache {
|
||||
cached_key = c.get_bytes(&key_cache_key).await;
|
||||
if cached_key.is_some() {
|
||||
@@ -84,6 +98,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
crate::metrics::record_auth_cache_miss("key");
|
||||
}
|
||||
}
|
||||
|
||||
let (decrypted_key, deactivated_at, takedown_ref) = if let Some(key) = cached_key {
|
||||
let user_status = sqlx::query!(
|
||||
"SELECT deactivated_at, takedown_ref FROM users WHERE did = $1",
|
||||
@@ -93,6 +108,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match user_status {
|
||||
Some(status) => (Some(key), status.deactivated_at, status.takedown_ref),
|
||||
None => (None, None, None),
|
||||
@@ -112,25 +128,31 @@ async fn validate_bearer_token_with_options_internal(
|
||||
{
|
||||
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
|
||||
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
|
||||
|
||||
if let Some(c) = cache {
|
||||
let _ = c.set_bytes(&key_cache_key, &key, Duration::from_secs(KEY_CACHE_TTL_SECS)).await;
|
||||
}
|
||||
|
||||
(Some(key), user.deactivated_at, user.takedown_ref)
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(decrypted_key) = decrypted_key {
|
||||
if !allow_deactivated && deactivated_at.is_some() {
|
||||
return Err(TokenValidationError::AccountDeactivated);
|
||||
}
|
||||
|
||||
if takedown_ref.is_some() {
|
||||
return Err(TokenValidationError::AccountTakedown);
|
||||
}
|
||||
|
||||
if let Ok(token_data) = verify_access_token(token, &decrypted_key) {
|
||||
let jti = &token_data.claims.jti;
|
||||
let session_cache_key = format!("auth:session:{}:{}", did, jti);
|
||||
let mut session_valid = false;
|
||||
|
||||
if let Some(c) = cache {
|
||||
if let Some(cached_value) = c.get(&session_cache_key).await {
|
||||
session_valid = cached_value == "1";
|
||||
@@ -139,6 +161,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
crate::metrics::record_auth_cache_miss("session");
|
||||
}
|
||||
}
|
||||
|
||||
if !session_valid {
|
||||
let session_exists = sqlx::query_scalar!(
|
||||
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
|
||||
@@ -149,13 +172,16 @@ async fn validate_bearer_token_with_options_internal(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
session_valid = session_exists.is_some();
|
||||
|
||||
if session_valid {
|
||||
if let Some(c) = cache {
|
||||
let _ = c.set(&session_cache_key, "1", Duration::from_secs(SESSION_CACHE_TTL_SECS)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if session_valid {
|
||||
return Ok(AuthenticatedUser {
|
||||
did: did.clone(),
|
||||
@@ -166,6 +192,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) {
|
||||
if let Some(oauth_token) = sqlx::query!(
|
||||
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref
|
||||
@@ -182,9 +209,11 @@ async fn validate_bearer_token_with_options_internal(
|
||||
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
|
||||
return Err(TokenValidationError::AccountDeactivated);
|
||||
}
|
||||
|
||||
if oauth_token.takedown_ref.is_some() {
|
||||
return Err(TokenValidationError::AccountTakedown);
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
if oauth_token.expires_at > now {
|
||||
return Ok(AuthenticatedUser {
|
||||
@@ -195,12 +224,15 @@ async fn validate_bearer_token_with_options_internal(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(TokenValidationError::AuthenticationFailed)
|
||||
}
|
||||
|
||||
pub async fn invalidate_auth_cache(cache: &Arc<dyn Cache>, did: &str) {
|
||||
let key_cache_key = format!("auth:key:{}", did);
|
||||
let _ = cache.delete(&key_cache_key).await;
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub iss: String,
|
||||
@@ -214,17 +246,19 @@ pub struct Claims {
|
||||
pub lxm: Option<String>,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub alg: String,
|
||||
pub typ: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UnsafeClaims {
|
||||
pub iss: String,
|
||||
pub sub: Option<String>,
|
||||
}
|
||||
// fancy boy TokenData equivalent for compatibility/structure
|
||||
|
||||
pub struct TokenData<T> {
|
||||
pub claims: T,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use sha2::Sha256;
|
||||
use uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const TOKEN_TYPE_ACCESS: &str = "at+jwt";
|
||||
pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt";
|
||||
pub const TOKEN_TYPE_SERVICE: &str = "jwt";
|
||||
@@ -15,29 +17,37 @@ pub const SCOPE_ACCESS: &str = "com.atproto.access";
|
||||
pub const SCOPE_REFRESH: &str = "com.atproto.refresh";
|
||||
pub const SCOPE_APP_PASS: &str = "com.atproto.appPass";
|
||||
pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged";
|
||||
|
||||
pub struct TokenWithMetadata {
|
||||
pub token: String,
|
||||
pub jti: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_with_metadata(did, key_bytes)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120))
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90))
|
||||
}
|
||||
|
||||
pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::seconds(60))
|
||||
.expect("valid timestamp")
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
@@ -48,8 +58,10 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
|
||||
sign_claims(claims, &signing_key)
|
||||
}
|
||||
|
||||
fn create_signed_token_with_metadata(
|
||||
did: &str,
|
||||
scope: &str,
|
||||
@@ -58,11 +70,14 @@ fn create_signed_token_with_metadata(
|
||||
duration: Duration,
|
||||
) -> Result<TokenWithMetadata> {
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
|
||||
let expires_at = Utc::now()
|
||||
.checked_add_signed(duration)
|
||||
.expect("valid timestamp");
|
||||
|
||||
let expiration = expires_at.timestamp();
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
@@ -76,47 +91,61 @@ fn create_signed_token_with_metadata(
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
};
|
||||
|
||||
let token = sign_claims_with_type(claims, &signing_key, typ)?;
|
||||
|
||||
Ok(TokenWithMetadata {
|
||||
token,
|
||||
jti,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_claims(claims: Claims, key: &SigningKey) -> Result<String> {
|
||||
sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE)
|
||||
}
|
||||
|
||||
fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result<String> {
|
||||
let header = Header {
|
||||
alg: "ES256K".to_string(),
|
||||
typ: typ.to_string(),
|
||||
};
|
||||
|
||||
let header_json = serde_json::to_string(&header)?;
|
||||
let claims_json = serde_json::to_string(&claims)?;
|
||||
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
let signature: Signature = key.sign(message.as_bytes());
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
Ok(format!("{}.{}", message, signature_b64))
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_access_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, secret, Duration::minutes(120))
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, secret, Duration::days(90))
|
||||
}
|
||||
|
||||
pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::seconds(60))
|
||||
.expect("valid timestamp")
|
||||
.timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
@@ -127,8 +156,10 @@ pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]
|
||||
lxm: Some(lxm.to_string()),
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
|
||||
sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret)
|
||||
}
|
||||
|
||||
fn create_hs256_token_with_metadata(
|
||||
did: &str,
|
||||
scope: &str,
|
||||
@@ -139,8 +170,10 @@ fn create_hs256_token_with_metadata(
|
||||
let expires_at = Utc::now()
|
||||
.checked_add_signed(duration)
|
||||
.expect("valid timestamp");
|
||||
|
||||
let expiration = expires_at.timestamp();
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let claims = Claims {
|
||||
iss: did.to_owned(),
|
||||
sub: did.to_owned(),
|
||||
@@ -154,27 +187,36 @@ fn create_hs256_token_with_metadata(
|
||||
lxm: None,
|
||||
jti: jti.clone(),
|
||||
};
|
||||
|
||||
let token = sign_claims_hs256(claims, typ, secret)?;
|
||||
|
||||
Ok(TokenWithMetadata {
|
||||
token,
|
||||
jti,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_claims_hs256(claims: Claims, typ: &str, secret: &[u8]) -> Result<String> {
|
||||
let header = Header {
|
||||
alg: "HS256".to_string(),
|
||||
typ: typ.to_string(),
|
||||
};
|
||||
|
||||
let header_json = serde_json::to_string(&header)?;
|
||||
let claims_json = serde_json::to_string(&claims)?;
|
||||
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid secret length: {}", e))?;
|
||||
mac.update(message.as_bytes());
|
||||
|
||||
let signature = mac.finalize().into_bytes();
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
|
||||
|
||||
Ok(format!("{}.{}", message, signature_b64))
|
||||
}
|
||||
|
||||
@@ -8,37 +8,48 @@ use hmac::{Hmac, Mac};
|
||||
use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Verifier};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn get_did_from_token(token: &str) -> Result<String, String> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid token format".to_string());
|
||||
}
|
||||
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| format!("Base64 decode failed: {}", e))?;
|
||||
|
||||
let claims: UnsafeClaims =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
|
||||
Ok(claims.sub.unwrap_or(claims.iss))
|
||||
}
|
||||
|
||||
pub fn get_jti_from_token(token: &str) -> Result<String, String> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid token format".to_string());
|
||||
}
|
||||
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| format!("Base64 decode failed: {}", e))?;
|
||||
|
||||
let claims: serde_json::Value =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
|
||||
claims.get("jti")
|
||||
.and_then(|j| j.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No jti claim in token".to_string())
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(token, key_bytes, None, None)
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(
|
||||
token,
|
||||
@@ -47,6 +58,7 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Cl
|
||||
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_internal(
|
||||
token,
|
||||
@@ -55,6 +67,7 @@ pub fn verify_refresh_token(token: &str, key_bytes: &[u8]) -> Result<TokenData<C
|
||||
Some(&[SCOPE_REFRESH]),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_hs256_internal(
|
||||
token,
|
||||
@@ -63,6 +76,7 @@ pub fn verify_access_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData
|
||||
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenData<Claims>> {
|
||||
verify_token_hs256_internal(
|
||||
token,
|
||||
@@ -71,6 +85,7 @@ pub fn verify_refresh_token_hs256(token: &str, secret: &[u8]) -> Result<TokenDat
|
||||
Some(&[SCOPE_REFRESH]),
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_token_internal(
|
||||
token: &str,
|
||||
key_bytes: &[u8],
|
||||
@@ -81,47 +96,61 @@ fn verify_token_internal(
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!("Invalid token format"));
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
let claims_b64 = parts[1];
|
||||
let signature_b64 = parts[2];
|
||||
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(header_b64)
|
||||
.context("Base64 decode of header failed")?;
|
||||
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
|
||||
|
||||
if let Some(expected) = expected_typ {
|
||||
if header.typ != expected {
|
||||
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
|
||||
}
|
||||
}
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.context("Base64 decode of signature failed")?;
|
||||
|
||||
let signature = Signature::from_slice(&signature_bytes)
|
||||
.map_err(|e| anyhow!("Invalid signature format: {}", e))?;
|
||||
|
||||
let signing_key = SigningKey::from_slice(key_bytes)?;
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
verifying_key
|
||||
.verify(message.as_bytes(), &signature)
|
||||
.map_err(|e| anyhow!("Signature verification failed: {}", e))?;
|
||||
|
||||
let claims_bytes = URL_SAFE_NO_PAD
|
||||
.decode(claims_b64)
|
||||
.context("Base64 decode of claims failed")?;
|
||||
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope = claims.scope.as_deref().unwrap_or("");
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(anyhow!("Invalid token scope: {}", token_scope));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TokenData { claims })
|
||||
}
|
||||
|
||||
fn verify_token_hs256_internal(
|
||||
token: &str,
|
||||
secret: &[u8],
|
||||
@@ -132,60 +161,79 @@ fn verify_token_hs256_internal(
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!("Invalid token format"));
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
let claims_b64 = parts[1];
|
||||
let signature_b64 = parts[2];
|
||||
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(header_b64)
|
||||
.context("Base64 decode of header failed")?;
|
||||
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
|
||||
|
||||
if header.alg != "HS256" {
|
||||
return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg));
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_typ {
|
||||
if header.typ != expected {
|
||||
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
|
||||
}
|
||||
}
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.context("Base64 decode of signature failed")?;
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret)
|
||||
.map_err(|e| anyhow!("Invalid secret: {}", e))?;
|
||||
mac.update(message.as_bytes());
|
||||
|
||||
let expected_signature = mac.finalize().into_bytes();
|
||||
let is_valid: bool = signature_bytes.ct_eq(&expected_signature).into();
|
||||
|
||||
if !is_valid {
|
||||
return Err(anyhow!("Signature verification failed"));
|
||||
}
|
||||
|
||||
let claims_bytes = URL_SAFE_NO_PAD
|
||||
.decode(claims_b64)
|
||||
.context("Base64 decode of claims failed")?;
|
||||
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
|
||||
|
||||
let now = Utc::now().timestamp() as usize;
|
||||
if claims.exp < now {
|
||||
return Err(anyhow!("Token expired"));
|
||||
}
|
||||
|
||||
if let Some(scopes) = allowed_scopes {
|
||||
let token_scope = claims.scope.as_deref().unwrap_or("");
|
||||
if !scopes.contains(&token_scope) {
|
||||
return Err(anyhow!("Invalid token scope: {}", token_scope));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TokenData { claims })
|
||||
}
|
||||
|
||||
pub fn get_algorithm_from_token(token: &str) -> Result<String, String> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err("Invalid token format".to_string());
|
||||
}
|
||||
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[0])
|
||||
.map_err(|e| format!("Base64 decode failed: {}", e))?;
|
||||
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
|
||||
Ok(header.alg)
|
||||
}
|
||||
|
||||
Vendored
+24
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CacheError {
|
||||
#[error("Cache connection error: {0}")]
|
||||
@@ -9,6 +10,7 @@ pub enum CacheError {
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Cache: Send + Sync {
|
||||
async fn get(&self, key: &str) -> Option<String>;
|
||||
@@ -22,10 +24,12 @@ pub trait Cache: Send + Sync {
|
||||
self.set(key, &encoded, ttl).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ValkeyCache {
|
||||
conn: redis::aio::ConnectionManager,
|
||||
}
|
||||
|
||||
impl ValkeyCache {
|
||||
pub async fn new(url: &str) -> Result<Self, CacheError> {
|
||||
let client = redis::Client::open(url)
|
||||
@@ -36,10 +40,12 @@ impl ValkeyCache {
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
Ok(Self { conn: manager })
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> redis::aio::ConnectionManager {
|
||||
self.conn.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for ValkeyCache {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
@@ -51,6 +57,7 @@ impl Cache for ValkeyCache {
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("SET")
|
||||
@@ -62,6 +69,7 @@ impl Cache for ValkeyCache {
|
||||
.await
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
let mut conn = self.conn.clone();
|
||||
redis::cmd("DEL")
|
||||
@@ -71,32 +79,40 @@ impl Cache for ValkeyCache {
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOpCache;
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for NoOpCache {
|
||||
async fn get(&self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn set(&self, _key: &str, _value: &str, _ttl: Duration) -> Result<(), CacheError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, _key: &str) -> Result<(), CacheError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DistributedRateLimiter: Send + Sync {
|
||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisRateLimiter {
|
||||
conn: redis::aio::ConnectionManager,
|
||||
}
|
||||
|
||||
impl RedisRateLimiter {
|
||||
pub fn new(conn: redis::aio::ConnectionManager) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DistributedRateLimiter for RedisRateLimiter {
|
||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
|
||||
@@ -124,17 +140,21 @@ impl DistributedRateLimiter for RedisRateLimiter {
|
||||
count <= limit as i64
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOpRateLimiter;
|
||||
|
||||
#[async_trait]
|
||||
impl DistributedRateLimiter for NoOpRateLimiter {
|
||||
async fn check_rate_limit(&self, _key: &str, _limit: u32, _window_ms: u64) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CacheBackend {
|
||||
Valkey(ValkeyCache),
|
||||
NoOp,
|
||||
}
|
||||
|
||||
impl CacheBackend {
|
||||
pub fn rate_limiter(&self) -> Arc<dyn DistributedRateLimiter> {
|
||||
match self {
|
||||
@@ -145,6 +165,7 @@ impl CacheBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for CacheBackend {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
@@ -153,12 +174,14 @@ impl Cache for CacheBackend {
|
||||
CacheBackend::NoOp => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
match self {
|
||||
CacheBackend::Valkey(c) => c.set(key, value, ttl).await,
|
||||
CacheBackend::NoOp => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
match self {
|
||||
CacheBackend::Valkey(c) => c.delete(key).await,
|
||||
@@ -166,6 +189,7 @@ impl Cache for CacheBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_cache() -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
|
||||
match std::env::var("VALKEY_URL") {
|
||||
Ok(url) => match ValkeyCache::new(&url).await {
|
||||
|
||||
@@ -2,12 +2,14 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CircuitState {
|
||||
Closed,
|
||||
Open,
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
pub struct CircuitBreaker {
|
||||
name: String,
|
||||
failure_threshold: u32,
|
||||
@@ -18,6 +20,7 @@ pub struct CircuitBreaker {
|
||||
success_count: AtomicU32,
|
||||
last_failure_time: AtomicU64,
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
pub fn new(name: &str, failure_threshold: u32, success_threshold: u32, timeout_secs: u64) -> Self {
|
||||
Self {
|
||||
@@ -31,8 +34,10 @@ impl CircuitBreaker {
|
||||
last_failure_time: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn can_execute(&self) -> bool {
|
||||
let state = self.state.read().await;
|
||||
|
||||
match *state {
|
||||
CircuitState::Closed => true,
|
||||
CircuitState::Open => {
|
||||
@@ -41,6 +46,7 @@ impl CircuitBreaker {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if now - last_failure >= self.timeout.as_secs() {
|
||||
drop(state);
|
||||
let mut state = self.state.write().await;
|
||||
@@ -56,8 +62,10 @@ impl CircuitBreaker {
|
||||
CircuitState::HalfOpen => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_success(&self) {
|
||||
let state = *self.state.read().await;
|
||||
|
||||
match state {
|
||||
CircuitState::Closed => {
|
||||
self.failure_count.store(0, Ordering::SeqCst);
|
||||
@@ -75,8 +83,10 @@ impl CircuitBreaker {
|
||||
CircuitState::Open => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_failure(&self) {
|
||||
let state = *self.state.read().await;
|
||||
|
||||
match state {
|
||||
CircuitState::Closed => {
|
||||
let count = self.failure_count.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
@@ -110,23 +120,28 @@ impl CircuitBreaker {
|
||||
CircuitState::Open => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn state(&self) -> CircuitState {
|
||||
*self.state.read().await
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CircuitBreakers {
|
||||
pub plc_directory: Arc<CircuitBreaker>,
|
||||
pub relay_notification: Arc<CircuitBreaker>,
|
||||
}
|
||||
|
||||
impl Default for CircuitBreakers {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CircuitBreakers {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -135,16 +150,20 @@ impl CircuitBreakers {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CircuitOpenError {
|
||||
pub circuit_name: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CircuitOpenError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Circuit breaker '{}' is open", self.circuit_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CircuitOpenError {}
|
||||
|
||||
pub async fn with_circuit_breaker<T, E, F, Fut>(
|
||||
circuit: &CircuitBreaker,
|
||||
operation: F,
|
||||
@@ -158,6 +177,7 @@ where
|
||||
circuit_name: circuit.name().to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
match operation().await {
|
||||
Ok(result) => {
|
||||
circuit.record_success().await;
|
||||
@@ -169,11 +189,13 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CircuitBreakerError<E> {
|
||||
CircuitOpen(CircuitOpenError),
|
||||
OperationFailed(E),
|
||||
}
|
||||
|
||||
impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -182,6 +204,7 @@ impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: std::error::Error + 'static> std::error::Error for CircuitBreakerError<E> {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
@@ -190,71 +213,93 @@ impl<E: std::error::Error + 'static> std::error::Error for CircuitBreakerError<E
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_starts_closed() {
|
||||
let cb = CircuitBreaker::new("test", 3, 2, 10);
|
||||
assert_eq!(cb.state().await, CircuitState::Closed);
|
||||
assert!(cb.can_execute().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_opens_after_failures() {
|
||||
let cb = CircuitBreaker::new("test", 3, 2, 10);
|
||||
|
||||
cb.record_failure().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Closed);
|
||||
|
||||
cb.record_failure().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Closed);
|
||||
|
||||
cb.record_failure().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Open);
|
||||
assert!(!cb.can_execute().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_success_resets_failures() {
|
||||
let cb = CircuitBreaker::new("test", 3, 2, 10);
|
||||
|
||||
cb.record_failure().await;
|
||||
cb.record_failure().await;
|
||||
cb.record_success().await;
|
||||
|
||||
cb.record_failure().await;
|
||||
cb.record_failure().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Closed);
|
||||
|
||||
cb.record_failure().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Open);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_half_open_closes_after_successes() {
|
||||
let cb = CircuitBreaker::new("test", 3, 2, 0);
|
||||
|
||||
for _ in 0..3 {
|
||||
cb.record_failure().await;
|
||||
}
|
||||
assert_eq!(cb.state().await, CircuitState::Open);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(cb.can_execute().await);
|
||||
assert_eq!(cb.state().await, CircuitState::HalfOpen);
|
||||
|
||||
cb.record_success().await;
|
||||
assert_eq!(cb.state().await, CircuitState::HalfOpen);
|
||||
|
||||
cb.record_success().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Closed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_half_open_reopens_on_failure() {
|
||||
let cb = CircuitBreaker::new("test", 3, 2, 0);
|
||||
|
||||
for _ in 0..3 {
|
||||
cb.record_failure().await;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
cb.can_execute().await;
|
||||
|
||||
cb.record_failure().await;
|
||||
assert_eq!(cb.state().await, CircuitState::Open);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_circuit_breaker_helper() {
|
||||
let cb = CircuitBreaker::new("test", 3, 2, 10);
|
||||
|
||||
let result: Result<i32, CircuitBreakerError<std::io::Error>> =
|
||||
with_circuit_breaker(&cb, || async { Ok(42) }).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 42);
|
||||
|
||||
let result: Result<i32, CircuitBreakerError<&str>> =
|
||||
with_circuit_breaker(&cb, || async { Err("error") }).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
@@ -8,8 +8,11 @@ use hkdf::Hkdf;
|
||||
use p256::ecdsa::SigningKey;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static CONFIG: OnceLock<AuthConfig> = OnceLock::new();
|
||||
|
||||
pub const ENCRYPTION_VERSION: i32 = 1;
|
||||
|
||||
pub struct AuthConfig {
|
||||
jwt_secret: String,
|
||||
dpop_secret: String,
|
||||
@@ -20,6 +23,7 @@ pub struct AuthConfig {
|
||||
pub signing_key_y: String,
|
||||
key_encryption_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn init() -> &'static Self {
|
||||
CONFIG.get_or_init(|| {
|
||||
@@ -33,6 +37,7 @@ impl AuthConfig {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let dpop_secret = std::env::var("DPOP_SECRET").unwrap_or_else(|_| {
|
||||
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
|
||||
"test-dpop-secret-not-for-production".to_string()
|
||||
@@ -43,31 +48,39 @@ impl AuthConfig {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if jwt_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
|
||||
panic!("JWT_SECRET must be at least 32 characters");
|
||||
}
|
||||
|
||||
if dpop_secret.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
|
||||
panic!("DPOP_SECRET must be at least 32 characters");
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"oauth-signing-key-derivation:");
|
||||
hasher.update(jwt_secret.as_bytes());
|
||||
let seed = hasher.finalize();
|
||||
|
||||
let signing_key = SigningKey::from_slice(&seed)
|
||||
.unwrap_or_else(|e| panic!("Failed to create signing key from seed: {}. This is a bug.", e));
|
||||
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let point = verifying_key.to_encoded_point(false);
|
||||
|
||||
let signing_key_x = URL_SAFE_NO_PAD.encode(
|
||||
point.x().expect("EC point missing X coordinate - this should never happen")
|
||||
);
|
||||
let signing_key_y = URL_SAFE_NO_PAD.encode(
|
||||
point.y().expect("EC point missing Y coordinate - this should never happen")
|
||||
);
|
||||
|
||||
let mut kid_hasher = Sha256::new();
|
||||
kid_hasher.update(signing_key_x.as_bytes());
|
||||
kid_hasher.update(signing_key_y.as_bytes());
|
||||
let kid_hash = kid_hasher.finalize();
|
||||
let signing_key_id = URL_SAFE_NO_PAD.encode(&kid_hash[..8]);
|
||||
|
||||
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
|
||||
if cfg!(test) || std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_ok() {
|
||||
"test-master-key-not-for-production".to_string()
|
||||
@@ -78,13 +91,16 @@ impl AuthConfig {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if master_key.len() < 32 && std::env::var("BSPDS_ALLOW_INSECURE_SECRETS").is_err() {
|
||||
panic!("MASTER_KEY must be at least 32 characters");
|
||||
}
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
|
||||
let mut key_encryption_key = [0u8; 32];
|
||||
hk.expand(b"bspds-user-key-encryption", &mut key_encryption_key)
|
||||
.expect("HKDF expansion failed");
|
||||
|
||||
AuthConfig {
|
||||
jwt_secret,
|
||||
dpop_secret,
|
||||
@@ -96,48 +112,64 @@ impl AuthConfig {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get() -> &'static Self {
|
||||
CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first")
|
||||
}
|
||||
|
||||
pub fn jwt_secret(&self) -> &str {
|
||||
&self.jwt_secret
|
||||
}
|
||||
|
||||
pub fn dpop_secret(&self) -> &str {
|
||||
&self.dpop_secret
|
||||
}
|
||||
|
||||
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use rand::RngCore;
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
|
||||
.map_err(|e| format!("Failed to create cipher: {}", e))?;
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
|
||||
#[allow(deprecated)]
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce_bytes);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if encrypted.len() < 12 {
|
||||
return Err("Encrypted data too short".to_string());
|
||||
}
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
|
||||
.map_err(|e| format!("Failed to create cipher: {}", e))?;
|
||||
|
||||
#[allow(deprecated)]
|
||||
let nonce = Nonce::from_slice(&encrypted[..12]);
|
||||
let ciphertext = &encrypted[12..];
|
||||
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt_key(plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
AuthConfig::get().encrypt_user_key(plaintext)
|
||||
}
|
||||
|
||||
pub fn decrypt_key(encrypted: &[u8], version: Option<i32>) -> Result<Vec<u8>, String> {
|
||||
match version.unwrap_or(0) {
|
||||
0 => Ok(encrypted.to_vec()),
|
||||
|
||||
@@ -6,7 +6,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60;
|
||||
|
||||
pub struct Crawlers {
|
||||
hostname: String,
|
||||
crawler_urls: Vec<String>,
|
||||
@@ -14,6 +16,7 @@ pub struct Crawlers {
|
||||
last_notified: AtomicU64,
|
||||
circuit_breaker: Option<Arc<CircuitBreaker>>,
|
||||
}
|
||||
|
||||
impl Crawlers {
|
||||
pub fn new(hostname: String, crawler_urls: Vec<String>) -> Self {
|
||||
Self {
|
||||
@@ -27,56 +30,70 @@ impl Crawlers {
|
||||
circuit_breaker: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_circuit_breaker(mut self, circuit_breaker: Arc<CircuitBreaker>) -> Self {
|
||||
self.circuit_breaker = Some(circuit_breaker);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").ok()?;
|
||||
|
||||
let crawler_urls: Vec<String> = std::env::var("CRAWLERS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect();
|
||||
|
||||
if crawler_urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self::new(hostname, crawler_urls))
|
||||
}
|
||||
|
||||
fn should_notify(&self) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let last = self.last_notified.load(Ordering::Relaxed);
|
||||
now - last >= NOTIFY_THRESHOLD_SECS
|
||||
}
|
||||
|
||||
fn mark_notified(&self) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
self.last_notified.store(now, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn notify_of_update(&self) {
|
||||
if !self.should_notify() {
|
||||
debug!("Skipping crawler notification due to debounce");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cb) = &self.circuit_breaker {
|
||||
if !cb.can_execute().await {
|
||||
debug!("Skipping crawler notification due to circuit breaker open");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.mark_notified();
|
||||
let circuit_breaker = self.circuit_breaker.clone();
|
||||
|
||||
for crawler_url in &self.crawler_urls {
|
||||
let url = format!("{}/xrpc/com.atproto.sync.requestCrawl", crawler_url.trim_end_matches('/'));
|
||||
let hostname = self.hostname.clone();
|
||||
let client = self.http_client.clone();
|
||||
let cb = circuit_breaker.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match client
|
||||
.post(&url)
|
||||
@@ -116,6 +133,7 @@ impl Crawlers {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_crawlers_service(
|
||||
crawlers: Arc<Crawlers>,
|
||||
mut firehose_rx: broadcast::Receiver<SequencedEvent>,
|
||||
@@ -127,6 +145,7 @@ pub async fn start_crawlers_service(
|
||||
crawlers = ?crawlers.crawler_urls,
|
||||
"Starting crawlers notification service"
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = firehose_rx.recv() => {
|
||||
|
||||
+28
-1
@@ -1,7 +1,9 @@
|
||||
use image::{DynamicImage, ImageFormat, ImageReader, imageops::FilterType};
|
||||
use std::io::Cursor;
|
||||
|
||||
pub const THUMB_SIZE_FEED: u32 = 200;
|
||||
pub const THUMB_SIZE_FULL: u32 = 1000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessedImage {
|
||||
pub data: Vec<u8>,
|
||||
@@ -9,12 +11,14 @@ pub struct ProcessedImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageProcessingResult {
|
||||
pub original: ProcessedImage,
|
||||
pub thumbnail_feed: Option<ProcessedImage>,
|
||||
pub thumbnail_full: Option<ProcessedImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ImageError {
|
||||
#[error("Failed to decode image: {0}")]
|
||||
@@ -32,13 +36,16 @@ pub enum ImageError {
|
||||
#[error("File too large: {size} bytes exceeds maximum {max_size} bytes")]
|
||||
FileTooLarge { size: usize, max_size: usize },
|
||||
}
|
||||
pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
pub struct ImageProcessor {
|
||||
max_dimension: u32,
|
||||
max_file_size: usize,
|
||||
output_format: OutputFormat,
|
||||
generate_thumbnails: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum OutputFormat {
|
||||
WebP,
|
||||
@@ -46,6 +53,7 @@ pub enum OutputFormat {
|
||||
Png,
|
||||
Original,
|
||||
}
|
||||
|
||||
impl Default for ImageProcessor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -56,26 +64,32 @@ impl Default for ImageProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProcessor {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_max_dimension(mut self, max: u32) -> Self {
|
||||
self.max_dimension = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_file_size(mut self, max: usize) -> Self {
|
||||
self.max_file_size = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_output_format(mut self, format: OutputFormat) -> Self {
|
||||
self.output_format = format;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_thumbnails(mut self, generate: bool) -> Self {
|
||||
self.generate_thumbnails = generate;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn process(&self, data: &[u8], mime_type: &str) -> Result<ImageProcessingResult, ImageError> {
|
||||
if data.len() > self.max_file_size {
|
||||
return Err(ImageError::FileTooLarge {
|
||||
@@ -109,6 +123,7 @@ impl ImageProcessor {
|
||||
thumbnail_full,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_format(&self, mime_type: &str, data: &[u8]) -> Result<ImageFormat, ImageError> {
|
||||
match mime_type.to_lowercase().as_str() {
|
||||
"image/jpeg" | "image/jpg" => Ok(ImageFormat::Jpeg),
|
||||
@@ -124,6 +139,7 @@ impl ImageProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_image(&self, data: &[u8], format: ImageFormat) -> Result<DynamicImage, ImageError> {
|
||||
let cursor = Cursor::new(data);
|
||||
let reader = ImageReader::with_format(cursor, format);
|
||||
@@ -131,6 +147,7 @@ impl ImageProcessor {
|
||||
.decode()
|
||||
.map_err(|e| ImageError::DecodeError(e.to_string()))
|
||||
}
|
||||
|
||||
fn encode_image(&self, img: &DynamicImage) -> Result<ProcessedImage, ImageError> {
|
||||
let (data, mime_type) = match self.output_format {
|
||||
OutputFormat::WebP => {
|
||||
@@ -165,6 +182,7 @@ impl ImageProcessor {
|
||||
height: img.height(),
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result<ProcessedImage, ImageError> {
|
||||
let (orig_width, orig_height) = (img.width(), img.height());
|
||||
let (new_width, new_height) = if orig_width > orig_height {
|
||||
@@ -177,12 +195,14 @@ impl ImageProcessor {
|
||||
let thumb = img.resize(new_width, new_height, FilterType::Lanczos3);
|
||||
self.encode_image(&thumb)
|
||||
}
|
||||
|
||||
pub fn is_supported_mime_type(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type.to_lowercase().as_str(),
|
||||
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||
let format = image::guess_format(data)
|
||||
.map_err(|e| ImageError::DecodeError(e.to_string()))?;
|
||||
@@ -196,15 +216,18 @@ impl ImageProcessor {
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_image(width: u32, height: u32) -> Vec<u8> {
|
||||
let img = DynamicImage::new_rgb8(width, height);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_small_image() {
|
||||
let processor = ImageProcessor::new();
|
||||
@@ -213,6 +236,7 @@ mod tests {
|
||||
assert!(result.thumbnail_feed.is_none());
|
||||
assert!(result.thumbnail_full.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_large_image_generates_thumbnails() {
|
||||
let processor = ImageProcessor::new();
|
||||
@@ -227,6 +251,7 @@ mod tests {
|
||||
assert!(full_thumb.width <= THUMB_SIZE_FULL);
|
||||
assert!(full_thumb.height <= THUMB_SIZE_FULL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_webp_conversion() {
|
||||
let processor = ImageProcessor::new().with_output_format(OutputFormat::WebP);
|
||||
@@ -234,6 +259,7 @@ mod tests {
|
||||
let result = processor.process(&data, "image/png").unwrap();
|
||||
assert_eq!(result.original.mime_type, "image/webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_too_large() {
|
||||
let processor = ImageProcessor::new().with_max_dimension(1000);
|
||||
@@ -241,6 +267,7 @@ mod tests {
|
||||
let result = processor.process(&data, "image/png");
|
||||
assert!(matches!(result, Err(ImageError::TooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_mime_type() {
|
||||
assert!(ImageProcessor::is_supported_mime_type("image/jpeg"));
|
||||
|
||||
+4
-1
@@ -16,6 +16,7 @@ pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod util;
|
||||
pub mod validation;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::Method,
|
||||
@@ -25,6 +26,7 @@ use axum::{
|
||||
use state::AppState;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub fn app(state: AppState) -> Router {
|
||||
let router = Router::new()
|
||||
.route("/metrics", get(metrics::metrics_handler))
|
||||
@@ -358,7 +360,6 @@ pub fn app(state: AppState) -> Router {
|
||||
.route("/.well-known/did.json", get(api::identity::well_known_did))
|
||||
.route("/.well-known/atproto-did", get(api::identity::well_known_atproto_did))
|
||||
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
|
||||
// OAuth 2.1 endpoints
|
||||
.route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
get(oauth::endpoints::oauth_protected_resource),
|
||||
@@ -402,8 +403,10 @@ pub fn app(state: AppState) -> Router {
|
||||
.allow_headers(Any),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let frontend_dir = std::env::var("FRONTEND_DIR")
|
||||
.unwrap_or_else(|_| "./frontend/dist".to_string());
|
||||
|
||||
if std::path::Path::new(&frontend_dir).join("index.html").exists() {
|
||||
let index_path = format!("{}/index.html", frontend_dir);
|
||||
let serve_dir = ServeDir::new(&frontend_dir)
|
||||
|
||||
+30
@@ -6,11 +6,13 @@ use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
bspds::metrics::init_metrics();
|
||||
|
||||
match run().await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
@@ -19,25 +21,31 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| "DATABASE_URL environment variable must be set")?;
|
||||
|
||||
let max_connections: u32 = std::env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(100);
|
||||
|
||||
let min_connections: u32 = std::env::var("DATABASE_MIN_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10);
|
||||
|
||||
let acquire_timeout_secs: u64 = std::env::var("DATABASE_ACQUIRE_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10);
|
||||
|
||||
info!(
|
||||
"Configuring database pool: max={}, min={}, acquire_timeout={}s",
|
||||
max_connections, min_connections, acquire_timeout_secs
|
||||
);
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.min_connections(min_connections)
|
||||
@@ -47,33 +55,43 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to Postgres: {}", e))?;
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run migrations: {}", e))?;
|
||||
|
||||
let state = AppState::new(pool.clone()).await;
|
||||
bspds::sync::listener::start_sequencer_listener(state.clone()).await;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
let mut notification_service = NotificationService::new(pool);
|
||||
|
||||
if let Some(email_sender) = EmailSender::from_env() {
|
||||
info!("Email notifications enabled");
|
||||
notification_service = notification_service.register_sender(email_sender);
|
||||
} else {
|
||||
warn!("Email notifications disabled (MAIL_FROM_ADDRESS not set)");
|
||||
}
|
||||
|
||||
if let Some(discord_sender) = DiscordSender::from_env() {
|
||||
info!("Discord notifications enabled");
|
||||
notification_service = notification_service.register_sender(discord_sender);
|
||||
}
|
||||
|
||||
if let Some(telegram_sender) = TelegramSender::from_env() {
|
||||
info!("Telegram notifications enabled");
|
||||
notification_service = notification_service.register_sender(telegram_sender);
|
||||
}
|
||||
|
||||
if let Some(signal_sender) = SignalSender::from_env() {
|
||||
info!("Signal notifications enabled");
|
||||
notification_service = notification_service.register_sender(signal_sender);
|
||||
}
|
||||
|
||||
let notification_handle = tokio::spawn(notification_service.run(shutdown_rx.clone()));
|
||||
|
||||
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
|
||||
let crawlers = Arc::new(
|
||||
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone())
|
||||
@@ -85,24 +103,32 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)");
|
||||
None
|
||||
};
|
||||
|
||||
let app = bspds::app(state);
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
info!("listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
|
||||
|
||||
let server_result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_tx))
|
||||
.await;
|
||||
|
||||
notification_handle.await.ok();
|
||||
|
||||
if let Some(handle) = crawlers_handle {
|
||||
handle.await.ok();
|
||||
}
|
||||
|
||||
if let Err(e) = server_result {
|
||||
return Err(format!("Server error: {}", e).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
|
||||
let ctrl_c = async {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
@@ -112,6 +138,7 @@ async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
@@ -124,12 +151,15 @@ async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
info!("Shutdown signal received, stopping services...");
|
||||
shutdown_tx.send(true).ok();
|
||||
}
|
||||
|
||||
@@ -8,16 +8,21 @@ use metrics::{counter, gauge, histogram};
|
||||
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
|
||||
static PROMETHEUS_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
|
||||
|
||||
pub fn init_metrics() -> PrometheusHandle {
|
||||
let builder = PrometheusBuilder::new();
|
||||
let handle = builder
|
||||
.install_recorder()
|
||||
.expect("failed to install Prometheus recorder");
|
||||
|
||||
PROMETHEUS_HANDLE.set(handle.clone()).ok();
|
||||
describe_metrics();
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
fn describe_metrics() {
|
||||
metrics::describe_counter!(
|
||||
"bspds_http_requests_total",
|
||||
@@ -68,6 +73,7 @@ fn describe_metrics() {
|
||||
"Database query duration in seconds"
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn metrics_handler() -> impl IntoResponse {
|
||||
match PROMETHEUS_HANDLE.get() {
|
||||
Some(handle) => {
|
||||
@@ -81,13 +87,17 @@ pub async fn metrics_handler() -> impl IntoResponse {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response {
|
||||
let start = Instant::now();
|
||||
let method = request.method().to_string();
|
||||
let path = normalize_path(request.uri().path());
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
let duration = start.elapsed().as_secs_f64();
|
||||
let status = response.status().as_u16().to_string();
|
||||
|
||||
counter!(
|
||||
"bspds_http_requests_total",
|
||||
"method" => method.clone(),
|
||||
@@ -95,14 +105,17 @@ pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response
|
||||
"status" => status.clone()
|
||||
)
|
||||
.increment(1);
|
||||
|
||||
histogram!(
|
||||
"bspds_http_request_duration_seconds",
|
||||
"method" => method,
|
||||
"path" => path
|
||||
)
|
||||
.record(duration);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
if path.starts_with("/xrpc/") {
|
||||
if let Some(method) = path.strip_prefix("/xrpc/") {
|
||||
@@ -112,32 +125,42 @@ fn normalize_path(path: &str) -> String {
|
||||
return path.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if path.starts_with("/u/") && path.ends_with("/did.json") {
|
||||
return "/u/{handle}/did.json".to_string();
|
||||
}
|
||||
|
||||
if path.starts_with("/oauth/") {
|
||||
return path.to_string();
|
||||
}
|
||||
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
pub fn record_auth_cache_hit(cache_type: &str) {
|
||||
counter!("bspds_auth_cache_hits_total", "cache_type" => cache_type.to_string()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_auth_cache_miss(cache_type: &str) {
|
||||
counter!("bspds_auth_cache_misses_total", "cache_type" => cache_type.to_string()).increment(1);
|
||||
}
|
||||
|
||||
pub fn set_firehose_subscribers(count: usize) {
|
||||
gauge!("bspds_firehose_subscribers").set(count as f64);
|
||||
}
|
||||
|
||||
pub fn increment_firehose_subscribers() {
|
||||
counter!("bspds_firehose_events_total").increment(1);
|
||||
}
|
||||
|
||||
pub fn record_firehose_event() {
|
||||
counter!("bspds_firehose_events_total").increment(1);
|
||||
}
|
||||
|
||||
pub fn record_block_operation(op_type: &str) {
|
||||
counter!("bspds_block_operations_total", "op_type" => op_type.to_string()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_s3_operation(op_type: &str, status: &str) {
|
||||
counter!(
|
||||
"bspds_s3_operations_total",
|
||||
@@ -146,12 +169,15 @@ pub fn record_s3_operation(op_type: &str, status: &str) {
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn set_notification_queue_size(size: usize) {
|
||||
gauge!("bspds_notification_queue_size").set(size as f64);
|
||||
}
|
||||
|
||||
pub fn record_rate_limit_rejection(limiter: &str) {
|
||||
counter!("bspds_rate_limit_rejections_total", "limiter" => limiter.to_string()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_db_query(query_type: &str, duration_seconds: f64) {
|
||||
counter!("bspds_db_queries_total", "query_type" => query_type.to_string()).increment(1);
|
||||
histogram!(
|
||||
@@ -160,9 +186,11 @@ pub fn record_db_query(query_type: &str, duration_seconds: f64) {
|
||||
)
|
||||
.record(duration_seconds);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_path() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
mod sender;
|
||||
mod service;
|
||||
mod types;
|
||||
|
||||
pub use sender::{
|
||||
DiscordSender, EmailSender, NotificationSender, SendError, SignalSender, TelegramSender,
|
||||
is_valid_phone_number, sanitize_header_value,
|
||||
};
|
||||
|
||||
pub use service::{
|
||||
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
|
||||
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
|
||||
};
|
||||
|
||||
pub use types::{
|
||||
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
|
||||
};
|
||||
|
||||
@@ -5,15 +5,19 @@ use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::types::{NotificationChannel, QueuedNotification};
|
||||
|
||||
const HTTP_TIMEOUT_SECS: u64 = 30;
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
const INITIAL_RETRY_DELAY_MS: u64 = 500;
|
||||
|
||||
#[async_trait]
|
||||
pub trait NotificationSender: Send + Sync {
|
||||
fn channel(&self) -> NotificationChannel;
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SendError {
|
||||
#[error("Failed to spawn sendmail process: {0}")]
|
||||
@@ -31,6 +35,7 @@ pub enum SendError {
|
||||
#[error("Max retries exceeded: {0}")]
|
||||
MaxRetriesExceeded(String),
|
||||
}
|
||||
|
||||
fn create_http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
@@ -38,16 +43,20 @@ fn create_http_client() -> Client {
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new())
|
||||
}
|
||||
|
||||
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
}
|
||||
|
||||
async fn retry_delay(attempt: u32) {
|
||||
let delay_ms = INITIAL_RETRY_DELAY_MS * 2u64.pow(attempt);
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
pub fn sanitize_header_value(value: &str) -> String {
|
||||
value.replace(['\r', '\n'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
pub fn is_valid_phone_number(number: &str) -> bool {
|
||||
if number.len() < 2 || number.len() > 20 {
|
||||
return false;
|
||||
@@ -59,11 +68,13 @@ pub fn is_valid_phone_number(number: &str) -> bool {
|
||||
let remaining: String = chars.collect();
|
||||
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub struct EmailSender {
|
||||
from_address: String,
|
||||
from_name: String,
|
||||
sendmail_path: String,
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub fn new(from_address: String, from_name: String) -> Self {
|
||||
Self {
|
||||
@@ -72,11 +83,13 @@ impl EmailSender {
|
||||
sendmail_path: std::env::var("SENDMAIL_PATH").unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?;
|
||||
let from_name = std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "BSPDS".to_string());
|
||||
Some(Self::new(from_address, from_name))
|
||||
}
|
||||
|
||||
pub fn format_email(&self, notification: &QueuedNotification) -> String {
|
||||
let subject = sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
|
||||
let recipient = sanitize_header_value(¬ification.recipient);
|
||||
@@ -94,11 +107,13 @@ impl EmailSender {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for EmailSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Email
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
let email_content = self.format_email(notification);
|
||||
let mut child = Command::new(&self.sendmail_path)
|
||||
@@ -119,10 +134,12 @@ impl NotificationSender for EmailSender {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiscordSender {
|
||||
webhook_url: String,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl DiscordSender {
|
||||
pub fn new(webhook_url: String) -> Self {
|
||||
Self {
|
||||
@@ -130,16 +147,19 @@ impl DiscordSender {
|
||||
http_client: create_http_client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?;
|
||||
Some(Self::new(webhook_url))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for DiscordSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Discord
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let content = format!("**{}**\n\n{}", subject, notification.body);
|
||||
@@ -193,10 +213,12 @@ impl NotificationSender for DiscordSender {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TelegramSender {
|
||||
bot_token: String,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl TelegramSender {
|
||||
pub fn new(bot_token: String) -> Self {
|
||||
Self {
|
||||
@@ -204,16 +226,19 @@ impl TelegramSender {
|
||||
http_client: create_http_client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
|
||||
Some(Self::new(bot_token))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for TelegramSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Telegram
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
let chat_id = ¬ification.recipient;
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
@@ -273,10 +298,12 @@ impl NotificationSender for TelegramSender {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SignalSender {
|
||||
signal_cli_path: String,
|
||||
sender_number: String,
|
||||
}
|
||||
|
||||
impl SignalSender {
|
||||
pub fn new(signal_cli_path: String, sender_number: String) -> Self {
|
||||
Self {
|
||||
@@ -284,6 +311,7 @@ impl SignalSender {
|
||||
sender_number,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let signal_cli_path = std::env::var("SIGNAL_CLI_PATH")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/signal-cli".to_string());
|
||||
@@ -291,11 +319,13 @@ impl SignalSender {
|
||||
Some(Self::new(signal_cli_path, sender_number))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for SignalSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Signal
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
let recipient = ¬ification.recipient;
|
||||
if !is_valid_phone_number(recipient) {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::interval;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::sender::{NotificationSender, SendError};
|
||||
use super::types::{NewNotification, NotificationChannel, NotificationStatus, QueuedNotification};
|
||||
|
||||
pub struct NotificationService {
|
||||
db: PgPool,
|
||||
senders: HashMap<NotificationChannel, Arc<dyn NotificationSender>>,
|
||||
poll_interval: Duration,
|
||||
batch_size: i64,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(db: PgPool) -> Self {
|
||||
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
|
||||
@@ -32,18 +36,22 @@ impl NotificationService {
|
||||
batch_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_poll_interval(mut self, interval: Duration) -> Self {
|
||||
self.poll_interval = interval;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_batch_size(mut self, size: i64) -> Self {
|
||||
self.batch_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_sender<S: NotificationSender + 'static>(mut self, sender: S) -> Self {
|
||||
self.senders.insert(sender.channel(), Arc::new(sender));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn enqueue(&self, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
|
||||
let id = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -65,9 +73,11 @@ impl NotificationService {
|
||||
debug!(notification_id = %id, "Notification enqueued");
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn has_senders(&self) -> bool {
|
||||
!self.senders.is_empty()
|
||||
}
|
||||
|
||||
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
|
||||
if self.senders.is_empty() {
|
||||
warn!("Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured.");
|
||||
@@ -95,6 +105,7 @@ impl NotificationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_batch(&self) -> Result<(), sqlx::Error> {
|
||||
let notifications = self.fetch_pending_notifications().await?;
|
||||
if notifications.is_empty() {
|
||||
@@ -106,6 +117,7 @@ impl NotificationService {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_pending_notifications(&self) -> Result<Vec<QueuedNotification>, sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
sqlx::query_as!(
|
||||
@@ -137,6 +149,7 @@ impl NotificationService {
|
||||
.fetch_all(&self.db)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_notification(&self, notification: QueuedNotification) {
|
||||
let notification_id = notification.id;
|
||||
let channel = notification.channel;
|
||||
@@ -179,6 +192,7 @@ impl NotificationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_sent(&self, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -192,6 +206,7 @@ impl NotificationService {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -215,6 +230,7 @@ impl NotificationService {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -234,11 +250,13 @@ pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) ->
|
||||
.fetch_one(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct UserNotificationPrefs {
|
||||
pub channel: NotificationChannel,
|
||||
pub email: Option<String>,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_notification_prefs(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -262,6 +280,7 @@ pub async fn get_user_notification_prefs(
|
||||
handle: row.handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn enqueue_welcome(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -285,6 +304,7 @@ pub async fn enqueue_welcome(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_email_verification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -309,6 +329,7 @@ pub async fn enqueue_email_verification(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_password_reset(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -333,6 +354,7 @@ pub async fn enqueue_password_reset(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_email_update(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -357,6 +379,7 @@ pub async fn enqueue_email_update(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_account_deletion(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -381,6 +404,7 @@ pub async fn enqueue_account_deletion(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_plc_operation(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -405,6 +429,7 @@ pub async fn enqueue_plc_operation(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_2fa_code(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
@@ -429,6 +454,7 @@ pub async fn enqueue_2fa_code(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
|
||||
match channel {
|
||||
NotificationChannel::Email => "email",
|
||||
@@ -437,6 +463,7 @@ pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
|
||||
NotificationChannel::Signal => "Signal",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_signup_verification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "notification_channel", rename_all = "lowercase")]
|
||||
pub enum NotificationChannel {
|
||||
@@ -10,6 +11,7 @@ pub enum NotificationChannel {
|
||||
Telegram,
|
||||
Signal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "notification_status", rename_all = "lowercase")]
|
||||
pub enum NotificationStatus {
|
||||
@@ -18,6 +20,7 @@ pub enum NotificationStatus {
|
||||
Sent,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "notification_type", rename_all = "snake_case")]
|
||||
pub enum NotificationType {
|
||||
@@ -30,6 +33,7 @@ pub enum NotificationType {
|
||||
PlcOperation,
|
||||
TwoFactorCode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct QueuedNotification {
|
||||
pub id: Uuid,
|
||||
@@ -49,6 +53,7 @@ pub struct QueuedNotification {
|
||||
pub scheduled_for: DateTime<Utc>,
|
||||
pub processed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct NewNotification {
|
||||
pub user_id: Uuid,
|
||||
pub channel: NotificationChannel,
|
||||
@@ -58,6 +63,7 @@ pub struct NewNotification {
|
||||
pub body: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl NewNotification {
|
||||
pub fn new(
|
||||
user_id: Uuid,
|
||||
@@ -77,6 +83,7 @@ impl NewNotification {
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email(
|
||||
user_id: Uuid,
|
||||
notification_type: NotificationType,
|
||||
|
||||
@@ -3,7 +3,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::OAuthError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientMetadata {
|
||||
pub client_id: String,
|
||||
@@ -31,6 +33,7 @@ pub struct ClientMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub application_type: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ClientMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -50,6 +53,7 @@ impl Default for ClientMetadata {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientMetadataCache {
|
||||
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
|
||||
@@ -57,14 +61,17 @@ pub struct ClientMetadataCache {
|
||||
http_client: Client,
|
||||
cache_ttl_secs: u64,
|
||||
}
|
||||
|
||||
struct CachedMetadata {
|
||||
metadata: ClientMetadata,
|
||||
cached_at: std::time::Instant,
|
||||
}
|
||||
|
||||
struct CachedJwks {
|
||||
jwks: serde_json::Value,
|
||||
cached_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl ClientMetadataCache {
|
||||
pub fn new(cache_ttl_secs: u64) -> Self {
|
||||
Self {
|
||||
@@ -78,6 +85,7 @@ impl ClientMetadataCache {
|
||||
cache_ttl_secs,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
@@ -100,6 +108,7 @@ impl ClientMetadataCache {
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
pub async fn get_jwks(&self, metadata: &ClientMetadata) -> Result<serde_json::Value, OAuthError> {
|
||||
if let Some(jwks) = &metadata.jwks {
|
||||
return Ok(jwks.clone());
|
||||
@@ -130,6 +139,7 @@ impl ClientMetadataCache {
|
||||
}
|
||||
Ok(jwks)
|
||||
}
|
||||
|
||||
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
|
||||
if !jwks_uri.starts_with("https://") {
|
||||
if !jwks_uri.starts_with("http://")
|
||||
@@ -166,6 +176,7 @@ impl ClientMetadataCache {
|
||||
}
|
||||
Ok(jwks)
|
||||
}
|
||||
|
||||
async fn fetch_metadata(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
|
||||
if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
@@ -207,6 +218,7 @@ impl ClientMetadataCache {
|
||||
self.validate_metadata(&metadata)?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> {
|
||||
if metadata.redirect_uris.is_empty() {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
@@ -232,6 +244,7 @@ impl ClientMetadataCache {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_redirect_uri(
|
||||
&self,
|
||||
metadata: &ClientMetadata,
|
||||
@@ -244,6 +257,7 @@ impl ClientMetadataCache {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> {
|
||||
if uri.contains('#') {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
@@ -278,16 +292,19 @@ impl ClientMetadataCache {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientMetadata {
|
||||
pub fn requires_dpop(&self) -> bool {
|
||||
self.dpop_bound_access_tokens.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn auth_method(&self) -> &str {
|
||||
self.token_endpoint_auth_method
|
||||
.as_deref()
|
||||
.unwrap_or("none")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_client_auth(
|
||||
cache: &ClientMetadataCache,
|
||||
metadata: &ClientMetadata,
|
||||
@@ -321,6 +338,7 @@ pub async fn verify_client_auth(
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_private_key_jwt_async(
|
||||
cache: &ClientMetadataCache,
|
||||
metadata: &ClientMetadata,
|
||||
@@ -425,6 +443,7 @@ async fn verify_private_key_jwt_async(
|
||||
"client_assertion signature verification failed".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_es256(
|
||||
key: &serde_json::Value,
|
||||
signing_input: &str,
|
||||
@@ -456,6 +475,7 @@ fn verify_es256(
|
||||
.verify(signing_input.as_bytes(), &sig)
|
||||
.map_err(|_| OAuthError::InvalidClient("ES256 signature verification failed".to_string()))
|
||||
}
|
||||
|
||||
fn verify_es384(
|
||||
key: &serde_json::Value,
|
||||
signing_input: &str,
|
||||
@@ -487,6 +507,7 @@ fn verify_es384(
|
||||
.verify(signing_input.as_bytes(), &sig)
|
||||
.map_err(|_| OAuthError::InvalidClient("ES384 signature verification failed".to_string()))
|
||||
}
|
||||
|
||||
fn verify_rsa(
|
||||
_alg: &str,
|
||||
_key: &serde_json::Value,
|
||||
@@ -497,6 +518,7 @@ fn verify_rsa(
|
||||
"RSA signature verification not yet supported - use EC keys".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_eddsa(
|
||||
key: &serde_json::Value,
|
||||
signing_input: &str,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use sqlx::PgPool;
|
||||
use super::super::{AuthorizedClientData, OAuthError};
|
||||
use super::helpers::{from_json, to_json};
|
||||
|
||||
pub async fn upsert_authorized_client(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
@@ -22,6 +23,7 @@ pub async fn upsert_authorized_client(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_authorized_client(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use super::super::{DeviceData, OAuthError};
|
||||
|
||||
pub struct DeviceAccountRow {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn create_device(
|
||||
pool: &PgPool,
|
||||
device_id: &str,
|
||||
@@ -27,6 +29,7 @@ pub async fn create_device(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceData>, OAuthError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
@@ -45,6 +48,7 @@ pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceD
|
||||
last_seen_at: r.last_seen_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_device_last_seen(
|
||||
pool: &PgPool,
|
||||
device_id: &str,
|
||||
@@ -61,6 +65,7 @@ pub async fn update_device_last_seen(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -72,6 +77,7 @@ pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthEr
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert_account_device(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
@@ -90,6 +96,7 @@ pub async fn upsert_account_device(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_device_accounts(
|
||||
pool: &PgPool,
|
||||
device_id: &str,
|
||||
@@ -118,6 +125,7 @@ pub async fn get_device_accounts(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn verify_account_on_device(
|
||||
pool: &PgPool,
|
||||
device_id: &str,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use sqlx::PgPool;
|
||||
use super::super::OAuthError;
|
||||
|
||||
pub async fn check_and_record_dpop_jti(
|
||||
pool: &PgPool,
|
||||
jti: &str,
|
||||
@@ -16,6 +17,7 @@ pub async fn check_and_record_dpop_jti(
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn cleanup_expired_dpop_jtis(
|
||||
pool: &PgPool,
|
||||
max_age_secs: i64,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use super::super::OAuthError;
|
||||
|
||||
pub fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, OAuthError> {
|
||||
serde_json::to_value(value).map_err(|e| {
|
||||
tracing::error!("JSON serialization error: {}", e);
|
||||
OAuthError::ServerError("Internal serialization error".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_json<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, OAuthError> {
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
tracing::error!("JSON deserialization error: {}", e);
|
||||
|
||||
@@ -5,6 +5,7 @@ mod helpers;
|
||||
mod request;
|
||||
mod token;
|
||||
mod two_factor;
|
||||
|
||||
pub use client::{get_authorized_client, upsert_authorized_client};
|
||||
pub use device::{
|
||||
create_device, delete_device, get_device, get_device_accounts, update_device_last_seen,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use sqlx::PgPool;
|
||||
use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
|
||||
use super::helpers::{from_json, to_json};
|
||||
|
||||
pub async fn create_authorization_request(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
@@ -30,6 +31,7 @@ pub async fn create_authorization_request(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_authorization_request(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
@@ -64,6 +66,7 @@ pub async fn get_authorization_request(
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_authorization_request(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
@@ -86,6 +89,7 @@ pub async fn update_authorization_request(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn consume_authorization_request_by_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
@@ -120,6 +124,7 @@ pub async fn consume_authorization_request_by_code(
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_authorization_request(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
@@ -134,6 +139,7 @@ pub async fn delete_authorization_request(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result<u64, OAuthError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use super::super::{OAuthError, TokenData};
|
||||
use super::helpers::{from_json, to_json};
|
||||
|
||||
pub async fn create_token(
|
||||
pool: &PgPool,
|
||||
data: &TokenData,
|
||||
@@ -34,6 +35,7 @@ pub async fn create_token(
|
||||
.await?;
|
||||
Ok(row.id)
|
||||
}
|
||||
|
||||
pub async fn get_token_by_id(
|
||||
pool: &PgPool,
|
||||
token_id: &str,
|
||||
@@ -68,6 +70,7 @@ pub async fn get_token_by_id(
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_token_by_refresh_token(
|
||||
pool: &PgPool,
|
||||
refresh_token: &str,
|
||||
@@ -105,6 +108,7 @@ pub async fn get_token_by_refresh_token(
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rotate_token(
|
||||
pool: &PgPool,
|
||||
old_db_id: i32,
|
||||
@@ -149,6 +153,7 @@ pub async fn rotate_token(
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_refresh_token_used(
|
||||
pool: &PgPool,
|
||||
refresh_token: &str,
|
||||
@@ -163,6 +168,7 @@ pub async fn check_refresh_token_used(
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -174,6 +180,7 @@ pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthErro
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -185,6 +192,7 @@ pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthE
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_tokens_for_user(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
@@ -220,6 +228,7 @@ pub async fn list_tokens_for_user(
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAuthError> {
|
||||
let count = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -231,6 +240,7 @@ pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAut
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn delete_oldest_tokens_for_user(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
@@ -253,7 +263,9 @@ pub async fn delete_oldest_tokens_for_user(
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
const MAX_TOKENS_PER_USER: i64 = 100;
|
||||
|
||||
pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> {
|
||||
let count = count_tokens_for_user(pool, did).await?;
|
||||
if count > MAX_TOKENS_PER_USER {
|
||||
|
||||
@@ -3,6 +3,7 @@ use rand::Rng;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use super::super::OAuthError;
|
||||
|
||||
pub struct TwoFactorChallenge {
|
||||
pub id: Uuid,
|
||||
pub did: String,
|
||||
@@ -12,11 +13,13 @@ pub struct TwoFactorChallenge {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub fn generate_2fa_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let code: u32 = rng.gen_range(0..1_000_000);
|
||||
format!("{:06}", code)
|
||||
}
|
||||
|
||||
pub async fn create_2fa_challenge(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
@@ -47,6 +50,7 @@ pub async fn create_2fa_challenge(
|
||||
expires_at: row.expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_2fa_challenge(
|
||||
pool: &PgPool,
|
||||
request_uri: &str,
|
||||
@@ -71,6 +75,7 @@ pub async fn get_2fa_challenge(
|
||||
expires_at: r.expires_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn increment_2fa_attempts(pool: &PgPool, id: Uuid) -> Result<i32, OAuthError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
@@ -85,6 +90,7 @@ pub async fn increment_2fa_attempts(pool: &PgPool, id: Uuid) -> Result<i32, OAut
|
||||
.await?;
|
||||
Ok(row.attempts)
|
||||
}
|
||||
|
||||
pub async fn delete_2fa_challenge(pool: &PgPool, id: Uuid) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -96,6 +102,7 @@ pub async fn delete_2fa_challenge(pool: &PgPool, id: Uuid) -> Result<(), OAuthEr
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_2fa_challenge_by_request_uri(
|
||||
pool: &PgPool,
|
||||
request_uri: &str,
|
||||
@@ -110,6 +117,7 @@ pub async fn delete_2fa_challenge_by_request_uri(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cleanup_expired_2fa_challenges(pool: &PgPool) -> Result<u64, OAuthError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
@@ -120,6 +128,7 @@ pub async fn cleanup_expired_2fa_challenges(pool: &PgPool) -> Result<u64, OAuthE
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn check_user_2fa_enabled(pool: &PgPool, did: &str) -> Result<bool, OAuthError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
|
||||
@@ -3,20 +3,25 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::OAuthError;
|
||||
|
||||
const DPOP_NONCE_VALIDITY_SECS: i64 = 300;
|
||||
const DPOP_MAX_AGE_SECS: i64 = 300;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DPoPVerifyResult {
|
||||
pub jkt: String,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DPoPProofHeader {
|
||||
pub typ: String,
|
||||
pub alg: String,
|
||||
pub jwk: DPoPJwk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DPoPJwk {
|
||||
pub kty: String,
|
||||
@@ -27,6 +32,7 @@ pub struct DPoPJwk {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub y: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DPoPProofPayload {
|
||||
pub jti: String,
|
||||
@@ -38,15 +44,18 @@ pub struct DPoPProofPayload {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nonce: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DPoPVerifier {
|
||||
secret: Vec<u8>,
|
||||
}
|
||||
|
||||
impl DPoPVerifier {
|
||||
pub fn new(secret: &[u8]) -> Self {
|
||||
Self {
|
||||
secret: secret.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_nonce(&self) -> String {
|
||||
let timestamp = Utc::now().timestamp();
|
||||
let timestamp_bytes = timestamp.to_be_bytes();
|
||||
@@ -59,6 +68,7 @@ impl DPoPVerifier {
|
||||
nonce_data.extend_from_slice(&hash[..16]);
|
||||
URL_SAFE_NO_PAD.encode(&nonce_data)
|
||||
}
|
||||
|
||||
pub fn validate_nonce(&self, nonce: &str) -> Result<(), OAuthError> {
|
||||
let nonce_bytes = URL_SAFE_NO_PAD
|
||||
.decode(nonce)
|
||||
@@ -83,6 +93,7 @@ impl DPoPVerifier {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_proof(
|
||||
&self,
|
||||
dpop_header: &str,
|
||||
@@ -152,6 +163,7 @@ impl DPoPVerifier {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_dpop_signature(
|
||||
alg: &str,
|
||||
jwk: &DPoPJwk,
|
||||
@@ -168,6 +180,7 @@ fn verify_dpop_signature(
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
@@ -208,6 +221,7 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
.verify(message, &sig)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
|
||||
}
|
||||
|
||||
fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
|
||||
use p384::ecdsa::signature::Verifier;
|
||||
use p384::ecdsa::{Signature, VerifyingKey};
|
||||
@@ -248,6 +262,7 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
.verify(message, &sig)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
|
||||
}
|
||||
|
||||
fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
let crv = jwk.crv.as_ref().ok_or_else(|| {
|
||||
@@ -277,6 +292,7 @@ fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
.verify_strict(message, &sig)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Signature verification failed".to_string()))
|
||||
}
|
||||
|
||||
pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
|
||||
let canonical = match jwk.kty.as_str() {
|
||||
"EC" => {
|
||||
@@ -319,15 +335,18 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
|
||||
let hash = hasher.finalize();
|
||||
Ok(URL_SAFE_NO_PAD.encode(&hash))
|
||||
}
|
||||
|
||||
pub fn compute_access_token_hash(access_token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(access_token.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
URL_SAFE_NO_PAD.encode(&hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_nonce_generation_and_validation() {
|
||||
let secret = b"test-secret-key-32-bytes-long!!!";
|
||||
@@ -335,6 +354,7 @@ mod tests {
|
||||
let nonce = verifier.generate_nonce();
|
||||
assert!(verifier.validate_nonce(&nonce).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwk_thumbprint_ec() {
|
||||
let jwk = DPoPJwk {
|
||||
|
||||
@@ -11,7 +11,9 @@ use urlencoding::encode as url_encode;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::{Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates};
|
||||
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
|
||||
|
||||
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
|
||||
|
||||
fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("cookie")
|
||||
@@ -26,6 +28,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
@@ -41,12 +44,14 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
}
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn make_device_cookie(device_id: &str) -> String {
|
||||
format!(
|
||||
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
|
||||
@@ -54,12 +59,14 @@ fn make_device_cookie(device_id: &str) -> String {
|
||||
device_id
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeQuery {
|
||||
pub request_uri: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub new_account: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthorizeResponse {
|
||||
pub client_id: String,
|
||||
@@ -69,6 +76,7 @@ pub struct AuthorizeResponse {
|
||||
pub state: Option<String>,
|
||||
pub login_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeSubmit {
|
||||
pub request_uri: String,
|
||||
@@ -77,11 +85,13 @@ pub struct AuthorizeSubmit {
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeSelectSubmit {
|
||||
pub request_uri: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
fn wants_json(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("accept")
|
||||
@@ -89,6 +99,7 @@ fn wants_json(headers: &HeaderMap) -> bool {
|
||||
.map(|accept| accept.contains("application/json"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn authorize_get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -216,6 +227,7 @@ pub async fn authorize_get(
|
||||
request_data.parameters.login_hint.as_deref(),
|
||||
)).into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_get_json(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
@@ -239,6 +251,7 @@ pub async fn authorize_get_json(
|
||||
login_hint: request_data.parameters.login_hint.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn authorize_post(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -441,6 +454,7 @@ pub async fn authorize_post(
|
||||
redirect.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_select(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -574,6 +588,7 @@ pub async fn authorize_select(
|
||||
);
|
||||
Redirect::temporary(&redirect_url).into_response()
|
||||
}
|
||||
|
||||
fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -> String {
|
||||
let mut redirect_url = redirect_uri.to_string();
|
||||
let separator = if redirect_url.contains('?') { '&' } else { '?' };
|
||||
@@ -586,11 +601,13 @@ fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -
|
||||
redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname))));
|
||||
redirect_url
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthorizeDenyResponse {
|
||||
pub error: String,
|
||||
pub error_description: String,
|
||||
}
|
||||
|
||||
pub async fn authorize_deny(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<AuthorizeDenyForm>,
|
||||
@@ -610,21 +627,26 @@ pub async fn authorize_deny(
|
||||
}
|
||||
Ok(Redirect::temporary(&redirect_url).into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeDenyForm {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Authorize2faQuery {
|
||||
pub request_uri: String,
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Authorize2faSubmit {
|
||||
pub request_uri: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
const MAX_2FA_ATTEMPTS: i32 = 5;
|
||||
|
||||
pub async fn authorize_2fa_get(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<Authorize2faQuery>,
|
||||
@@ -673,6 +695,7 @@ pub async fn authorize_2fa_get(
|
||||
None,
|
||||
)).into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_2fa_post(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -2,6 +2,7 @@ use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::state::AppState;
|
||||
use crate::oauth::jwks::{JwkSet, create_jwk_set};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProtectedResourceMetadata {
|
||||
pub resource: String,
|
||||
@@ -11,6 +12,7 @@ pub struct ProtectedResourceMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_documentation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthorizationServerMetadata {
|
||||
pub issuer: String,
|
||||
@@ -43,6 +45,7 @@ pub struct AuthorizationServerMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub introspection_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn oauth_protected_resource(
|
||||
State(_state): State<AppState>,
|
||||
) -> Json<ProtectedResourceMetadata> {
|
||||
@@ -56,6 +59,7 @@ pub async fn oauth_protected_resource(
|
||||
resource_documentation: Some("https://atproto.com".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn oauth_authorization_server(
|
||||
State(_state): State<AppState>,
|
||||
) -> Json<AuthorizationServerMetadata> {
|
||||
@@ -96,6 +100,7 @@ pub async fn oauth_authorization_server(
|
||||
introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn oauth_jwks(State(_state): State<AppState>) -> Json<JwkSet> {
|
||||
use crate::config::AuthConfig;
|
||||
use crate::oauth::jwks::Jwk;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod metadata;
|
||||
pub mod par;
|
||||
pub mod authorize;
|
||||
pub mod token;
|
||||
|
||||
pub use metadata::*;
|
||||
pub use par::*;
|
||||
pub use authorize::*;
|
||||
|
||||
@@ -11,8 +11,10 @@ use crate::oauth::{
|
||||
client::ClientMetadataCache,
|
||||
db,
|
||||
};
|
||||
|
||||
const PAR_EXPIRY_SECONDS: i64 = 600;
|
||||
const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ParRequest {
|
||||
pub response_type: String,
|
||||
@@ -37,11 +39,13 @@ pub struct ParRequest {
|
||||
#[serde(default)]
|
||||
pub client_assertion_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ParResponse {
|
||||
pub request_uri: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
pub async fn pushed_authorization_request(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -115,6 +119,7 @@ pub async fn pushed_authorization_request(
|
||||
expires_in: PAR_EXPIRY_SECONDS as u64,
|
||||
}))
|
||||
}
|
||||
|
||||
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
|
||||
if let (Some(assertion), Some(assertion_type)) =
|
||||
(&request.client_assertion, &request.client_assertion_type)
|
||||
@@ -135,6 +140,7 @@ fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError>
|
||||
}
|
||||
Ok(ClientAuth::None)
|
||||
}
|
||||
|
||||
fn validate_scope(
|
||||
requested_scope: &Option<String>,
|
||||
client_metadata: &crate::oauth::client::ClientMetadata,
|
||||
|
||||
@@ -11,8 +11,10 @@ use crate::oauth::{
|
||||
};
|
||||
use super::types::{TokenRequest, TokenResponse};
|
||||
use super::helpers::{create_access_token, verify_pkce};
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
|
||||
const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60;
|
||||
|
||||
pub async fn handle_authorization_code_grant(
|
||||
state: AppState,
|
||||
_headers: HeaderMap,
|
||||
@@ -125,6 +127,7 @@ pub async fn handle_authorization_code_grant(
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn handle_refresh_token_grant(
|
||||
state: AppState,
|
||||
_headers: HeaderMap,
|
||||
|
||||
@@ -6,12 +6,15 @@ use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::oauth::OAuthError;
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
|
||||
|
||||
pub struct TokenClaims {
|
||||
pub jti: String,
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
}
|
||||
|
||||
pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
@@ -22,6 +25,7 @@ pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAut
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_access_token(
|
||||
token_id: &str,
|
||||
sub: &str,
|
||||
@@ -60,6 +64,7 @@ pub fn create_access_token(
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
|
||||
Ok(format!("{}.{}", signing_input, signature_b64))
|
||||
}
|
||||
|
||||
pub fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
|
||||
@@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::{OAuthError, db};
|
||||
use super::helpers::extract_token_claims;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RevokeRequest {
|
||||
pub token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub token_type_hint: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -31,12 +33,14 @@ pub async fn revoke_token(
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IntrospectRequest {
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub token_type_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IntrospectResponse {
|
||||
pub active: bool,
|
||||
@@ -63,6 +67,7 @@ pub struct IntrospectResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jti: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn introspect_token(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -2,6 +2,7 @@ mod grants;
|
||||
mod helpers;
|
||||
mod introspect;
|
||||
mod types;
|
||||
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
@@ -9,12 +10,14 @@ use axum::{
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::OAuthError;
|
||||
|
||||
pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant};
|
||||
pub use helpers::{create_access_token, extract_token_claims, verify_pkce, TokenClaims};
|
||||
pub use introspect::{
|
||||
introspect_token, revoke_token, IntrospectRequest, IntrospectResponse, RevokeRequest,
|
||||
};
|
||||
pub use types::{TokenRequest, TokenResponse};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
@@ -30,6 +33,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
pub async fn token_endpoint(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub grant_type: String,
|
||||
@@ -19,6 +20,7 @@ pub struct TokenRequest {
|
||||
#[serde(default)]
|
||||
pub client_assertion_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
|
||||
@@ -4,6 +4,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OAuthError {
|
||||
InvalidRequest(String),
|
||||
@@ -20,11 +21,13 @@ pub enum OAuthError {
|
||||
InvalidToken(String),
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OAuthErrorResponse {
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for OAuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error, description) = match self {
|
||||
@@ -86,12 +89,14 @@ impl IntoResponse for OAuthError {
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for OAuthError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
tracing::error!("Database error in OAuth flow: {}", err);
|
||||
OAuthError::ServerError("An internal error occurred".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for OAuthError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
tracing::error!("Internal error in OAuth flow: {}", err);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JwkSet {
|
||||
pub keys: Vec<Jwk>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Jwk {
|
||||
pub kty: String,
|
||||
@@ -19,6 +21,7 @@ pub struct Jwk {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub y: Option<String>,
|
||||
}
|
||||
|
||||
pub fn create_jwk_set(keys: Vec<Jwk>) -> JwkSet {
|
||||
JwkSet { keys }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod endpoints;
|
||||
pub mod error;
|
||||
pub mod templates;
|
||||
pub mod verify;
|
||||
|
||||
pub use types::*;
|
||||
pub use error::OAuthError;
|
||||
pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user