Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
tokio = { version = "1.38.0", features = ["full"] }
tower-http = { version = "0.5.2", features = ["trace", "limit", "set-header"] }
tower-http = { version = "0.5.2", features = ["trace", "limit", "set-header", "cors"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
async-once-cell = "0.5.3"
Expand Down
2 changes: 2 additions & 0 deletions backend/migrations/20260224000000_add_tc_columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "account" ADD COLUMN "tc_accepted_at" DATETIME DEFAULT NULL;
ALTER TABLE "account" ADD COLUMN "tc_version" TEXT DEFAULT NULL;
13 changes: 13 additions & 0 deletions backend/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ impl<S> FromRequestParts<S> for AccessClaims {
(StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response()
})?;
let token = bearer.token();

#[cfg(feature = "dev-mode")]
if token.starts_with("test::") {
let client = validate_test_token(token).map_err(|e| {
tracing::error!("Test token validation failed: {:?}", e);
(StatusCode::UNAUTHORIZED, "Bad test token").into_response()
})?;
return Ok(AccessClaims {
sub: client.id,
roles: client.roles,
});
}

let claims = validate_jwt(token).await.map_err(|e| {
tracing::error!("JWT validation failed: {:?}", e);
(StatusCode::UNAUTHORIZED, "Bad JWT").into_response()
Expand Down
40 changes: 40 additions & 0 deletions backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3470,6 +3470,46 @@ impl DB {

Ok((transaction, info))
}

/// # Errors
/// Returns an error if the database query fails.
pub async fn get_tc_status_by_kinde_id(
&self,
kinde_id: &str,
) -> SqlxResult<Option<(bool, Option<String>)>> {
let row = sqlx::query!(
r#"
SELECT tc_accepted_at IS NOT NULL AS "accepted!: bool", tc_version
FROM account
WHERE kinde_id = ?
"#,
kinde_id
)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.accepted, r.tc_version)))
}

/// # Errors
/// Returns an error if the database query fails.
pub async fn accept_tc_by_kinde_id(
&self,
kinde_id: &str,
tc_version: &str,
) -> SqlxResult<bool> {
let result = sqlx::query!(
r#"
UPDATE account
SET tc_accepted_at = CURRENT_TIMESTAMP, tc_version = ?
WHERE kinde_id = ?
"#,
tc_version,
kinde_id
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}

async fn get_portfolio_with_credits(
Expand Down
1 change: 1 addition & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub mod convert;
pub mod db;
pub mod handle_socket;
pub mod subscriptions;
pub mod tc;

#[cfg(feature = "dev-mode")]
pub mod seed;
Expand Down
3 changes: 3 additions & 0 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use backend::{airtable_users, AppState};
use std::{env, path::Path, str::FromStr};
use tokio::{fs::create_dir_all, net::TcpListener};
use tower_http::{
cors::CorsLayer,
limit::RequestBodyLimitLayer, set_header::response::SetResponseHeaderLayer, trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
Expand Down Expand Up @@ -42,7 +43,9 @@ async fn main() -> anyhow::Result<()> {
.route("/sync-airtable-users", get(sync_airtable_users))
.route("/api/upload-image", post(upload_image))
.route("/api/images/:filename", get(serve_image))
.merge(backend::tc::router())
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
// Limit file uploads to 10MB
.layer(RequestBodyLimitLayer::new(50 * 1024 * 1024))
.layer(SetResponseHeaderLayer::if_not_present(
Expand Down
85 changes: 85 additions & 0 deletions backend/src/tc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use axum::{
extract::State,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};

use crate::{auth::AccessClaims, AppState};

#[derive(Serialize)]
struct TcStatusResponse {
accepted: bool,
tc_version: Option<String>,
}

#[derive(Deserialize)]
struct AcceptTcRequest {
tc_version: String,
}

#[derive(Serialize)]
struct AcceptTcResponse {
success: bool,
}

async fn tc_status(
claims: AccessClaims,
State(state): State<AppState>,
) -> impl IntoResponse {
let result = state.db.get_tc_status_by_kinde_id(&claims.sub).await;
match result {
Ok(Some((accepted, tc_version))) => Json(TcStatusResponse {
accepted,
tc_version,
})
.into_response(),
Ok(None) => Json(TcStatusResponse {
accepted: false,
tc_version: None,
})
.into_response(),
Err(e) => {
tracing::error!("Failed to get T&C status: {e}");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)
.into_response()
}
}
}

async fn accept_tc(
claims: AccessClaims,
State(state): State<AppState>,
Json(body): Json<AcceptTcRequest>,
) -> impl IntoResponse {
let result = state
.db
.accept_tc_by_kinde_id(&claims.sub, &body.tc_version)
.await;
match result {
Ok(true) => Json(AcceptTcResponse { success: true }).into_response(),
Ok(false) => (
axum::http::StatusCode::NOT_FOUND,
"Account not found",
)
.into_response(),
Err(e) => {
tracing::error!("Failed to accept T&C: {e}");
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)
.into_response()
}
}
}

pub fn router() -> Router<AppState> {
Router::new()
.route("/api/tc/status", get(tc_status))
.route("/api/tc/accept", post(accept_tc))
}
79 changes: 66 additions & 13 deletions frontend/src/lib/api.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,26 @@ import { notifyUser } from './notifications';
// // Using new Error().stack is generally more reliable for the original call site.
// };

const socket = new ReconnectingWebSocket(PUBLIC_SERVER_URL);
socket.binaryType = 'arraybuffer';
let socket: ReconnectingWebSocket | null = null;

function ensureSocket(): ReconnectingWebSocket {
if (!socket) {
socket = new ReconnectingWebSocket(PUBLIC_SERVER_URL);
socket.binaryType = 'arraybuffer';
console.log('Connecting to', PUBLIC_SERVER_URL);
socket.onopen = authenticate;
socket.onclose = () => {
serverState.stale = true;
};
socket.onmessage = handleMessage;
}
return socket;
}

console.log('Connecting to', PUBLIC_SERVER_URL);
/** Call from layout after auth check to establish WebSocket connection */
export const connect = () => {
ensureSocket();
};

export class MarketData {
definition: websocket_api.IMarket = $state({});
Expand Down Expand Up @@ -63,7 +79,8 @@ export const serverState = $state({
auctions: new SvelteMap<number, websocket_api.IAuction>(),
universes: new SvelteMap<number, websocket_api.IUniverse>(),
lastKnownTransactionId: 0,
arborPixieAccountId: undefined as number | undefined
arborPixieAccountId: undefined as number | undefined,
tcAccepted: undefined as boolean | undefined
});

export const hasArborPixieTransfer = () => {
Expand Down Expand Up @@ -99,11 +116,12 @@ let messageQueue: websocket_api.IClientMessage[] = [];
let hasAuthenticated = false;

export const sendClientMessage = (msg: websocket_api.IClientMessage) => {
const s = ensureSocket();
if (hasAuthenticated || 'authenticate' in msg) {
const msgType = Object.keys(msg).find((key) => msg[key as keyof typeof msg]);
console.log(`sending ${msgType} message`, msg[msgType as keyof typeof msg]);
const data = websocket_api.ClientMessage.encode(msg).finish();
socket.send(data);
s.send(data);
hasAuthenticated = true;
for (const m of messageQueue) {
sendClientMessage(m);
Expand Down Expand Up @@ -157,13 +175,7 @@ const authenticate = async () => {
sendClientMessage({ authenticate });
};

socket.onopen = authenticate;

socket.onclose = () => {
serverState.stale = true;
};

socket.onmessage = (event: MessageEvent) => {
const handleMessage = (event: MessageEvent) => {
const data = event.data;
const msg = websocket_api.ServerMessage.decode(new Uint8Array(data));

Expand Down Expand Up @@ -527,5 +539,46 @@ if (browser) {

/** Force WebSocket to reconnect and re-authenticate (useful after login state changes) */
export const reconnect = () => {
socket.reconnect();
ensureSocket().reconnect();
};

export async function checkTcStatus(): Promise<boolean> {
const token = await kinde.getToken();
if (!token) return false;
try {
const res = await fetch('/api/tc/status', {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) return false;
const data = await res.json();
serverState.tcAccepted = data.accepted;
return data.accepted;
} catch (e) {
console.error('Failed to check T&C status', e);
return false;
}
}

export async function acceptTc(tcVersion: string): Promise<boolean> {
const token = await kinde.getToken();
if (!token) return false;
try {
const res = await fetch('/api/tc/accept', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ tc_version: tcVersion })
});
if (!res.ok) return false;
const data = await res.json();
if (data.success) {
serverState.tcAccepted = true;
}
return data.success;
} catch (e) {
console.error('Failed to accept T&C', e);
return false;
}
}
Loading
Loading