diff --git a/package.json b/package.json index 0a2bd1b..e596ff1 100644 --- a/package.json +++ b/package.json @@ -14,30 +14,32 @@ "devDependencies": { "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.56.1", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.2", - "svelte": "^5.55.1", - "svelte-check": "^4.4.6", - "tailwindcss": "^4.2.2", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.55.9", + "svelte-check": "^4.4.8", + "tailwindcss": "^4.3.0", "typescript": "^5.9.3", - "vite": "^7.3.2", + "vite": "^7.3.3", "vite-plugin-top-level-await": "^1.6.0", "vite-plugin-wasm": "^3.6.0" }, "dependencies": { - "@codemirror/autocomplete": "^6.20.1", + "@codemirror/autocomplete": "^6.20.2", "@codemirror/commands": "^6.10.3", "@codemirror/lang-rust": "^6.0.2", - "@codemirror/lint": "^6.9.5", - "@codemirror/lsp-client": "^6.2.2", + "@codemirror/lint": "^6.9.6", + "@codemirror/lsp-client": "^6.2.4", "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.41.0", + "@codemirror/view": "^6.43.0", "@iconify/svelte": "^5.2.1", + "chart.js": "^4.5.1", "codemirror": "^6.0.2", "codemirror-lang-typst": "^0.4.0", + "highlight.js": "^11.11.1", "y-codemirror.next": "^0.3.5", "y-websocket": "^3.0.0", "yjs": "^13.6.30" diff --git a/server/Cargo.toml b/server/Cargo.toml index 2dbc9be..6a03b68 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -33,3 +33,5 @@ yrs-axum = "0.8" typst-assets = { version = "0.14.2", features = ["fonts"] } tokio-stream = "0.1.18" tempfile = "3.27.0" +sha2 = "0.10" +base64 = "0.22" diff --git a/server/src/api_keys.rs b/server/src/api_keys.rs new file mode 100644 index 0000000..91e94df --- /dev/null +++ b/server/src/api_keys.rs @@ -0,0 +1,236 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use std::collections::HashMap; +use axum_extra::extract::cookie::SignedCookieJar; +use sha2::{Sha256, Digest}; +use uuid::Uuid; + +use crate::{ + models::{ApiKeyView, CreateApiKeyRequest, UsagePoint}, + AppState, +}; + +fn get_user_id(jar: &SignedCookieJar) -> Option { + jar.get("session_user_id").map(|c| c.value().to_string()) +} + +pub fn hash_key(key: &str) -> String { + format!("{:x}", Sha256::digest(key.as_bytes())) +} + +fn generate_api_key() -> String { + format!( + "td_{}{}", + Uuid::new_v4().to_string().replace("-", ""), + Uuid::new_v4().to_string().replace("-", "") + ) +} + +pub async fn list_keys( + State(state): State, + jar: SignedCookieJar, +) -> Result>, (StatusCode, String)> { + let user_id = get_user_id(&jar) + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + let keys = sqlx::query_as::<_, ApiKeyView>( + "SELECT id, name, key_prefix, created_at, last_used_at, rate_limit FROM api_keys WHERE user_id = ? ORDER BY created_at DESC" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(keys)) +} + +pub async fn create_key( + State(state): State, + jar: SignedCookieJar, + Json(payload): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let user_id = get_user_id(&jar) + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + if payload.name.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "Key name cannot be empty".to_string())); + } + + let existing: Option<(i64,)> = sqlx::query_as("SELECT COUNT(*) FROM api_keys WHERE user_id = ?") + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some((count,)) = existing { + if count >= 10 { + return Err((StatusCode::CONFLICT, "Maximum of 10 API keys per account".to_string())); + } + } + + let key = generate_api_key(); + let hash = hash_key(&key); + let prefix = key[..11].to_string(); // "td_" + 8 hex chars + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&id) + .bind(&user_id) + .bind(&payload.name) + .bind(&hash) + .bind(&prefix) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok((StatusCode::CREATED, Json(serde_json::json!({ + "id": id, + "name": payload.name, + "key": key, + "prefix": prefix, + "created_at": now, + "rate_limit": 60, + })))) +} + +pub async fn get_aggregate_usage( + State(state): State, + jar: SignedCookieJar, + Query(params): Query>, +) -> Result>, (StatusCode, String)> { + let user_id = get_user_id(&jar) + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + let period = params.get("period").map(|s| s.as_str()).unwrap_or("1week"); + + let usage = match period { + "1hr" => { + let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(1)) + .format("%Y-%m-%d %H:%M") + .to_string(); + sqlx::query_as::<_, UsagePoint>( + "SELECT minute as date, SUM(count) as count + FROM api_key_usage_detail + WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND minute >= ? + GROUP BY minute + ORDER BY minute ASC" + ) + .bind(&user_id) + .bind(&cutoff) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } + "1day" => { + let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(24)) + .format("%Y-%m-%d %H:%M") + .to_string(); + sqlx::query_as::<_, UsagePoint>( + "SELECT SUBSTR(minute, 1, 13) as date, SUM(count) as count + FROM api_key_usage_detail + WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND minute >= ? + GROUP BY SUBSTR(minute, 1, 13) + ORDER BY date ASC" + ) + .bind(&user_id) + .bind(&cutoff) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } + _ => { + let cutoff = (chrono::Utc::now() - chrono::TimeDelta::days(6)) + .format("%Y-%m-%d") + .to_string(); + sqlx::query_as::<_, UsagePoint>( + "SELECT date, SUM(count) as count + FROM api_key_usage + WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND date >= ? + GROUP BY date + ORDER BY date ASC" + ) + .bind(&user_id) + .bind(&cutoff) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } + }; + + Ok(Json(usage)) +} + +pub async fn regenerate_key( + State(state): State, + jar: SignedCookieJar, + Path(key_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = get_user_id(&jar) + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + let row: Option<(String,)> = sqlx::query_as( + "SELECT name FROM api_keys WHERE id = ? AND user_id = ?" + ) + .bind(&key_id) + .bind(&user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (name,) = row.ok_or((StatusCode::NOT_FOUND, "API key not found".to_string()))?; + + let new_key = generate_api_key(); + let new_hash = hash_key(&new_key); + let new_prefix = new_key[..11].to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + sqlx::query( + "UPDATE api_keys SET key_hash = ?, key_prefix = ?, created_at = ?, last_used_at = NULL WHERE id = ? AND user_id = ?" + ) + .bind(&new_hash) + .bind(&new_prefix) + .bind(&now) + .bind(&key_id) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "id": key_id, + "name": name, + "key": new_key, + "prefix": new_prefix, + "created_at": now, + "rate_limit": 60, + }))) +} + +pub async fn delete_key( + State(state): State, + jar: SignedCookieJar, + Path(key_id): Path, +) -> Result { + let user_id = get_user_id(&jar) + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + let result = sqlx::query("DELETE FROM api_keys WHERE id = ? AND user_id = ?") + .bind(&key_id) + .bind(&user_id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "API key not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/db/postgres.rs b/server/src/db/postgres.rs index 58fd871..3dec58b 100644 --- a/server/src/db/postgres.rs +++ b/server/src/db/postgres.rs @@ -76,6 +76,35 @@ pub async fn init_schema(pool: &AnyPool) { content TEXT NOT NULL, created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS') )", + "CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + rate_limit INTEGER NOT NULL DEFAULT 60 + )", + "CREATE TABLE IF NOT EXISTS api_render_cache ( + id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL UNIQUE, + format TEXT NOT NULL, + data BYTEA NOT NULL, + created_at TEXT NOT NULL + )", + "CREATE TABLE IF NOT EXISTS api_key_usage ( + key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + date TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(key_id, date) + )", + "CREATE TABLE IF NOT EXISTS api_key_usage_detail ( + key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + minute TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(key_id, minute) + )", ]; for stmt in &statements { diff --git a/server/src/db/sqlite.rs b/server/src/db/sqlite.rs index 1240f31..07b19b9 100644 --- a/server/src/db/sqlite.rs +++ b/server/src/db/sqlite.rs @@ -81,6 +81,35 @@ pub async fn init_schema(pool: &AnyPool) { content TEXT NOT NULL, created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')) )", + "CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + rate_limit INTEGER NOT NULL DEFAULT 60 + )", + "CREATE TABLE IF NOT EXISTS api_render_cache ( + id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL UNIQUE, + format TEXT NOT NULL, + data BLOB NOT NULL, + created_at TEXT NOT NULL + )", + "CREATE TABLE IF NOT EXISTS api_key_usage ( + key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + date TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(key_id, date) + )", + "CREATE TABLE IF NOT EXISTS api_key_usage_detail ( + key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + minute TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY(key_id, minute) + )", ]; for stmt in &statements { diff --git a/server/src/main.rs b/server/src/main.rs index 2789072..0e0c7a2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,6 +13,7 @@ use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod admin; +mod api_keys; mod auth; mod compiler; mod db; @@ -21,6 +22,7 @@ mod folders; mod files; mod handlers; mod models; +mod public_api; mod setup; mod world; mod collab; @@ -28,6 +30,8 @@ mod collab; use compiler::TypstCompiler; use handlers::{compile_handler, export_handler, yjs_handler}; +pub type RateLimiterMap = Arc>>; + #[derive(Clone)] pub struct AppState { pub compiler: Arc>, @@ -35,6 +39,7 @@ pub struct AppState { pub db: AnyPool, pub key: Key, pub registration_enabled: bool, + pub rate_limiter: RateLimiterMap, } impl axum::extract::FromRef for Key { @@ -84,6 +89,7 @@ async fn main() { db, key, registration_enabled, + rate_limiter: Arc::new(Mutex::new(HashMap::new())), }; let api_routes = Router::new() @@ -114,7 +120,14 @@ async fn main() { .route("/docs/{id}/invite", post(collab::invite_collaborator)) .route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment)) .route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version)) - .route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment)); + .route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment)) + .route("/keys", get(api_keys::list_keys).post(api_keys::create_key)) + .route("/keys/usage", get(api_keys::get_aggregate_usage)) + .route("/keys/{id}", delete(api_keys::delete_key)) + .route("/keys/{id}/regenerate", post(api_keys::regenerate_key)); + + let v1_routes = Router::new() + .route("/render", post(public_api::render_handler)); let yjs_routes = Router::new() .route("/{id}", get(yjs_handler)); @@ -123,6 +136,7 @@ async fn main() { let app = Router::new() .nest("/api", api_routes.layer(TraceLayer::new_for_http())) + .nest("/v1", v1_routes.layer(TraceLayer::new_for_http())) .nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http())) .fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir)))) .with_state(state); diff --git a/server/src/models.rs b/server/src/models.rs index 037375d..df967cd 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -195,6 +195,27 @@ pub struct AdminCreateUserRequest { pub is_admin: Option, } +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct UsagePoint { + pub date: String, + pub count: i64, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct ApiKeyView { + pub id: String, + pub name: String, + pub key_prefix: String, + pub created_at: String, + pub last_used_at: Option, + pub rate_limit: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateApiKeyRequest { + pub name: String, +} + #[derive(Debug, Serialize, Deserialize)] pub struct SetupRequest { pub username: String, diff --git a/server/src/public_api.rs b/server/src/public_api.rs new file mode 100644 index 0000000..7711260 --- /dev/null +++ b/server/src/public_api.rs @@ -0,0 +1,211 @@ +use axum::{ + extract::State, + http::{header, StatusCode}, + response::IntoResponse, + Json, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use serde::Deserialize; +use sha2::{Sha256, Digest}; +use std::collections::HashMap; +use uuid::Uuid; + +use crate::{api_keys::hash_key, AppState}; + +#[derive(Deserialize)] +pub struct RenderRequest { + pub code: String, + pub format: String, + pub files: Option>, +} + +#[derive(Deserialize)] +pub struct InlineFile { + pub name: String, + pub data: String, // base64-encoded +} + +fn compute_cache_key(format: &str, code: &str, files: &Option>) -> String { + let mut hasher = Sha256::new(); + hasher.update(format.as_bytes()); + hasher.update(b"\x00"); + hasher.update(code.as_bytes()); + if let Some(files) = files { + let mut pairs: Vec<_> = files.iter().map(|f| (f.name.as_str(), f.data.as_str())).collect(); + pairs.sort_by_key(|(n, _)| *n); + for (name, data) in pairs { + hasher.update(b"\x01"); + hasher.update(name.as_bytes()); + hasher.update(data.as_bytes()); + } + } + format!("{:x}", hasher.finalize()) +} + +pub async fn render_handler( + State(state): State, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> impl IntoResponse { + // Extract Bearer token + let api_key = match headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .filter(|v| v.starts_with("Bearer ")) + .map(|v| v[7..].to_string()) + { + Some(k) => k, + None => return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Use: Authorization: Bearer ").into_response(), + }; + + if payload.format != "png" && payload.format != "pdf" { + return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png' or 'pdf'").into_response(); + } + + if payload.code.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "code cannot be empty").into_response(); + } + + let key_hash = hash_key(&api_key); + + let key_row = sqlx::query_as::<_, (String, String, i64)>( + "SELECT id, user_id, rate_limit FROM api_keys WHERE key_hash = ?" + ) + .bind(&key_hash) + .fetch_optional(&state.db) + .await; + + let (key_id, user_id, rate_limit) = match key_row { + Ok(Some(row)) => row, + Ok(None) => return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response(), + Err(e) => { + let msg = format!("Database error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(); + } + }; + + // Rate limiting: fixed window of 60 seconds + { + let mut limiter = state.rate_limiter.lock().await; + let now = std::time::Instant::now(); + let window = std::time::Duration::from_secs(60); + let entry = limiter.entry(key_id.clone()).or_insert((0u32, now)); + if now.duration_since(entry.1) > window { + entry.0 = 1; + entry.1 = now; + } else if entry.0 < rate_limit as u32 { + entry.0 += 1; + } else { + return (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Max requests per minute reached.").into_response(); + } + } + + let now = chrono::Utc::now(); + let now_str = now.to_rfc3339(); + let today = now.format("%Y-%m-%d").to_string(); + + let _ = sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?") + .bind(&now_str) + .bind(&key_id) + .execute(&state.db) + .await; + + let _ = sqlx::query( + "INSERT INTO api_key_usage (key_id, date, count) VALUES (?, ?, 1) \ + ON CONFLICT (key_id, date) DO UPDATE SET count = api_key_usage.count + 1" + ) + .bind(&key_id) + .bind(&today) + .execute(&state.db) + .await; + + let minute_str = now.format("%Y-%m-%d %H:%M").to_string(); + let _ = sqlx::query( + "INSERT INTO api_key_usage_detail (key_id, minute, count) VALUES (?, ?, 1) \ + ON CONFLICT (key_id, minute) DO UPDATE SET count = api_key_usage_detail.count + 1" + ) + .bind(&key_id) + .bind(&minute_str) + .execute(&state.db) + .await; + + let cutoff_minute = (chrono::Utc::now() - chrono::TimeDelta::hours(25)) + .format("%Y-%m-%d %H:%M") + .to_string(); + let _ = sqlx::query("DELETE FROM api_key_usage_detail WHERE minute < ?") + .bind(&cutoff_minute) + .execute(&state.db) + .await; + + // Check cache + let cache_key = compute_cache_key(&payload.format, &payload.code, &payload.files); + let content_type: &'static str = if payload.format == "pdf" { "application/pdf" } else { "image/png" }; + + if let Ok(Some((data, created_at))) = sqlx::query_as::<_, (Vec, String)>( + "SELECT data, created_at FROM api_render_cache WHERE content_hash = ? AND format = ?" + ) + .bind(&cache_key) + .bind(&payload.format) + .fetch_optional(&state.db) + .await + { + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&created_at) { + let age = chrono::Utc::now().signed_duration_since(parsed.with_timezone(&chrono::Utc)); + if age.num_seconds() < 3600 { + return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response(); + } + } + } + + // Load user's account files + let mut files_map: HashMap> = HashMap::new(); + if let Ok(files) = sqlx::query_as::<_, (String, Vec)>( + "SELECT name, data FROM files WHERE owner_id = ?" + ) + .bind(&user_id) + .fetch_all(&state.db) + .await + { + for (name, data) in files { + files_map.insert(name, data); + } + } + + // Inline files override account files + if let Some(inline_files) = &payload.files { + for f in inline_files { + if let Ok(decoded) = BASE64.decode(&f.data) { + files_map.insert(f.name.clone(), decoded); + } + } + } + + // Compile + let compiler = state.compiler.lock().await; + let result = match payload.format.as_str() { + "pdf" => compiler.export_pdf(payload.code.clone(), files_map), + "png" => compiler.export_png(payload.code.clone(), files_map), + _ => unreachable!(), + }; + drop(compiler); + + match result { + Ok(data) => { + // Store in cache (ignore errors — concurrent inserts are fine) + let cache_id = Uuid::new_v4().to_string(); + let _ = sqlx::query( + "INSERT INTO api_render_cache (id, content_hash, format, data, created_at) VALUES (?, ?, ?, ?, ?)" + ) + .bind(&cache_id) + .bind(&cache_key) + .bind(&payload.format) + .bind(&data) + .bind(&now_str) + .execute(&state.db) + .await; + + (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response() + } + Err(_) => (StatusCode::UNPROCESSABLE_ENTITY, "Typst compilation failed. Check your code for errors.").into_response(), + } +} diff --git a/src/lib/components/dashboard/Navbar.svelte b/src/lib/components/dashboard/Navbar.svelte index e27c78f..ce9dd8e 100644 --- a/src/lib/components/dashboard/Navbar.svelte +++ b/src/lib/components/dashboard/Navbar.svelte @@ -24,6 +24,9 @@
+ + + diff --git a/src/routes/api-docs/+page.svelte b/src/routes/api-docs/+page.svelte new file mode 100644 index 0000000..e136514 --- /dev/null +++ b/src/routes/api-docs/+page.svelte @@ -0,0 +1,426 @@ + + + + API Docs - TypstDrive + + + + + +
+ + +
+ + +
+ +
+ {#each navSections as section} + + {/each} +
+ + {#if activeSection === 'overview'} +
+

+ + Overview +

+

+ The TypstDrive Render API lets you compile Typst markup into PNG images or PDF documents programmatically. + Authenticate with an API key and POST Typst code — get back binary output. +

+
+
+ +

PNG output

+

First page rendered at 2× scale

+
+
+ +

PDF output

+

Full multi-page PDF document

+
+
+ +

Cached results

+

Identical inputs skip recompilation

+
+
+
+

Base URL

+ {baseUrl} +
+
+ {/if} + + {#if activeSection === 'auth'} +
+

+ + Authentication +

+

+ All requests must include an API key in the Authorization header. +

+
+
+

Header format

+
{@html hljs.highlight('Authorization: Bearer td_your_api_key_here', { language: 'bash' }).value}
+
+
+ +
+

Keep your keys secret

+

API keys grant access to your account's uploaded files during compilation. Never expose them in client-side code or commit them to version control.

+
+
+
+

Managing keys

+

+ Create, regenerate, and revoke keys in + Settings → API Keys. + The full key is shown only once at creation time. +

+
+
+
+ {/if} + + {#if activeSection === 'endpoint'} +
+

+ + POST /v1/render +

+ +
+
+ POST + /v1/render +
+

+ Compile Typst markup and return rendered binary output as PNG or PDF. + Results are cached for 1 hour — identical inputs return the cached result without recompiling. +

+
+ +
+ +
+

Request headers

+
+ + + + + + + + + + + + + + + + + +
HeaderValue
AuthorizationBearer <api-key> — required
Content-Typeapplication/json — required
+
+
+ +
+

Request body

+
{@html hSchema}
+
+ +
+

Response

+
+ 200 OK + Binary body with Content-Type: image/png or application/pdf +
+
+ +
+

Account files available automatically

+

Files uploaded to your TypstDrive account are available by filename inside your Typst code. Pass additional files inline via the files array to supplement or override them.

+
+
+ {/if} + + {#if activeSection === 'examples'} +
+

+ + Examples +

+ + {#each [ + { id: 'curl-png', label: 'cURL — render PNG', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPng, raw: curlPng }, + { id: 'curl-pdf', label: 'cURL — render PDF', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPdf, raw: curlPdf }, + { id: 'js', label: 'JavaScript / TypeScript', icon: 'mdi:language-javascript', iconColor: 'text-yellow-400', code: hJs, raw: jsExample }, + { id: 'python', label: 'Python (httpx)', icon: 'mdi:language-python', iconColor: 'text-blue-400', code: hPython, raw: pythonExample}, + { id: 'files', label: 'Python — with inline files', icon: 'mdi:file-image-outline', iconColor: 'text-purple-400', code: hFiles, raw: filesExample }, + ] as ex} +
+
+

+ + {ex.label} +

+ +
+
{@html ex.code}
+
+ {/each} +
+ {/if} + + {#if activeSection === 'rate-limits'} +
+

+ + Rate Limits +

+
+
+

60

+

requests / minute per key

+
+
+

10

+

API keys per account

+
+
+
+

Caching saves quota

+

Identical inputs (same code + files) skip recompilation and are served from cache for up to 1 hour. Cached responses return instantly and do not consume your rate limit.

+
+
+

When exceeded

+
+ 429 Too Many Requests + — wait for the current 60-second window to reset. +
+
+
+ {/if} + + {#if activeSection === 'errors'} +
+

+ + Error Reference +

+
+ {#each [ + { code: '400', name: 'Bad Request', desc: 'Invalid format value, empty code, or malformed JSON body.' }, + { code: '401', name: 'Unauthorized', desc: 'Missing or invalid Authorization header, or unknown API key.' }, + { code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. Fix the markup and retry.' }, + { code: '429', name: 'Too Many Requests', desc: 'Rate limit exceeded. Wait for the current 60-second window to reset.' }, + { code: '500', name: 'Internal Server Error', desc: 'Unexpected server error. Try again after a short delay.' }, + ] as err} +
+ {err.code} +
+

{err.name}

+

{err.desc}

+
+
+ {/each} +
+
+

Error body

+

Error responses return plain text describing the issue — no JSON envelope.

+
+
+ {/if} + +
+
+ +
+
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 739a020..0964ba7 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,10 +1,12 @@ @@ -218,7 +433,6 @@
- -
- {#if activeSection === 'account'}
@@ -342,7 +554,6 @@
{/if} - {#if activeSection === 'theme'}
@@ -356,7 +567,6 @@
{/if} - {#if activeSection === 'storage'}
@@ -394,6 +604,216 @@
{/if} + {#if activeSection === 'api-keys'} +
+
+
+

+ + API Keys +

+ +
+

+ Use API keys to render Typst documents programmatically via POST /v1/render. + Each key allows up to 60 requests/minute. + View API docs → +

+ + +
+
+

+ Requests — {usagePeriod === '1hr' ? 'Last 60 Min' : usagePeriod === '1day' ? 'Last 24 Hours' : 'Last 7 Days'} + {#if usageData.length > 0} + + ({usageData.reduce((s, p) => s + p.count, 0)} total) + + {/if} +

+
+ {#each ([['1hr', '1 hr'], ['1day', '1 day'], ['1week', '1 week']] as const) as [val, label]} + + {/each} +
+
+
+ {#if usageLoading} +
+ Loading... +
+ {:else if usageData.length === 0} +
+ No usage yet — make your first API call to see data here. +
+ {:else} + + {/if} +
+
+ + {#if newlyCreatedKey} +
+
+
+

+ + Key created: {newlyCreatedKey.name} +

+

Copy this key now — it will not be shown again.

+
+ +
+
+ {newlyCreatedKey.key} + +
+
+ {/if} + + {#if showCreateKeyForm} +
+

+ + Create API Key +

+ {#if createKeyError} +
{createKeyError}
+ {/if} +
+
+ + +
+ +
+
+ {/if} + + {#if apiKeysError} +
{apiKeysError}
+ {/if} + + {#if apiKeysLoading} +
+ + Loading keys... +
+ {:else if apiKeys.length === 0} +
+ +

No API keys yet. Create one to get started.

+
+ {:else} +
+ {#each apiKeys as key (key.id)} +
+
+ +
+
+

{key.name}

+

{key.key_prefix}... · {key.rate_limit}/min

+
+ +
+ {#if confirmRegenerateId === key.id} +
+ Regenerate? + + +
+ {:else if confirmDeleteKeyId === key.id} +
+ Delete? + + +
+ {:else} + + + {/if} +
+
+ {/each} +
+ {/if} +
+
+ {/if} + {#if activeSection === 'admin' && $userStore?.is_admin}