feat: configurable OAuth (Google + TikTok SSO), project membership, inline file preview

- Auth: configurable OAuthProvider enum supporting Google OAuth and TikTok SSO
- Auth: /auth/provider endpoint for frontend to detect active provider
- Auth: user role system (admin via ADMIN_USERS env var sees all projects)
- Projects: project_members many-to-many table with role (owner/member)
- Projects: membership-based access control, auto-add creator as owner
- Projects: member management API (list/add/remove)
- Files: remove Content-Disposition attachment header, let browser decide
- Health: public /tori/api/health endpoint for k8s probes
This commit is contained in:
Fam Zheng 2026-03-17 03:42:38 +00:00
parent 63f0582f54
commit 28a00dd2f3
7 changed files with 504 additions and 98 deletions

View File

@ -19,10 +19,70 @@ const CSRF_COOKIE: &str = "tori_session_csrf";
const COOKIE_PATH: &str = "/"; const COOKIE_PATH: &str = "/";
const SESSION_SECS: i64 = 7 * 86400; const SESSION_SECS: i64 = 7 * 86400;
#[derive(Debug, Clone)]
pub enum OAuthProvider {
Google {
client_id: String,
client_secret: String,
},
TikTokSso {
client_id: String,
client_secret: String,
},
}
impl OAuthProvider {
fn authorize_url(&self) -> &str {
match self {
Self::Google { .. } => "https://accounts.google.com/o/oauth2/v2/auth",
Self::TikTokSso { .. } => "https://sso.tiktok-intl.com/oauth2/authorize",
}
}
fn token_url(&self) -> &str {
match self {
Self::Google { .. } => "https://oauth2.googleapis.com/token",
Self::TikTokSso { .. } => "https://sso.tiktok-intl.com/oauth2/access_token",
}
}
fn userinfo_url(&self) -> Option<&str> {
match self {
Self::Google { .. } => None, // uses id_token
Self::TikTokSso { .. } => Some("https://sso.tiktok-intl.com/oauth2/userinfo"),
}
}
fn client_id(&self) -> &str {
match self {
Self::Google { client_id, .. } | Self::TikTokSso { client_id, .. } => client_id,
}
}
fn client_secret(&self) -> &str {
match self {
Self::Google { client_secret, .. } | Self::TikTokSso { client_secret, .. } => client_secret,
}
}
fn scope(&self) -> &str {
match self {
Self::Google { .. } => "openid%20email%20profile",
Self::TikTokSso { .. } => "read",
}
}
fn name(&self) -> &str {
match self {
Self::Google { .. } => "google",
Self::TikTokSso { .. } => "tiktok-sso",
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthConfig { pub struct AuthConfig {
pub google_client_id: String, pub provider: OAuthProvider,
pub google_client_secret: String,
pub jwt_secret: String, pub jwt_secret: String,
pub public_url: String, pub public_url: String,
} }
@ -110,7 +170,7 @@ async fn generate_token(
} }
} }
// --- Google OAuth --- // --- OAuth login/callback ---
pub fn router(state: Arc<AppState>) -> Router { pub fn router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
@ -119,9 +179,15 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/me", get(me)) .route("/me", get(me))
.route("/logout", post(logout)) .route("/logout", post(logout))
.route("/token", post(generate_token)) .route("/token", post(generate_token))
.route("/provider", get(get_provider))
.with_state(state) .with_state(state)
} }
async fn get_provider(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let provider = state.auth.as_ref().map(|a| a.provider.name());
Json(serde_json::json!({ "provider": provider }))
}
fn build_cookie(name: &str, value: String, max_age_secs: i64) -> Cookie<'static> { fn build_cookie(name: &str, value: String, max_age_secs: i64) -> Cookie<'static> {
let mut c = Cookie::new(name.to_owned(), value); let mut c = Cookie::new(name.to_owned(), value);
c.set_path(COOKIE_PATH); c.set_path(COOKIE_PATH);
@ -144,12 +210,14 @@ async fn login(State(state): State<Arc<AppState>>) -> Response {
let csrf = uuid::Uuid::new_v4().to_string(); let csrf = uuid::Uuid::new_v4().to_string();
let redirect_uri = format!("{}/tori/api/auth/callback", auth.public_url); let redirect_uri = format!("{}/tori/api/auth/callback", auth.public_url);
let provider = &auth.provider;
let url = format!( let url = format!(
"https://accounts.google.com/o/oauth2/v2/auth?\ "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
client_id={}&redirect_uri={}&response_type=code&\ provider.authorize_url(),
scope=openid%20email%20profile&access_type=online&state={}", pct_encode(provider.client_id()),
pct_encode(&auth.google_client_id),
pct_encode(&redirect_uri), pct_encode(&redirect_uri),
provider.scope(),
pct_encode(&csrf), pct_encode(&csrf),
); );
@ -165,16 +233,14 @@ struct CallbackParams {
#[derive(Deserialize)] #[derive(Deserialize)]
struct TokenResponse { struct TokenResponse {
access_token: Option<String>,
id_token: Option<String>, id_token: Option<String>,
} }
#[derive(Deserialize)] struct UserInfo {
struct GoogleUserInfo {
sub: String, sub: String,
email: String, email: String,
#[serde(default)]
name: String, name: String,
#[serde(default)]
picture: String, picture: String,
} }
@ -187,6 +253,7 @@ async fn callback(
Some(a) => a, Some(a) => a,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(), None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
}; };
let provider = &auth.provider;
// CSRF check // CSRF check
match jar.get(CSRF_COOKIE) { match jar.get(CSRF_COOKIE) {
@ -199,11 +266,11 @@ async fn callback(
// Exchange code for token // Exchange code for token
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let token_res = client let token_res = client
.post("https://oauth2.googleapis.com/token") .post(provider.token_url())
.form(&[ .form(&[
("code", params.code.as_str()), ("code", params.code.as_str()),
("client_id", &auth.google_client_id), ("client_id", provider.client_id()),
("client_secret", &auth.google_client_secret), ("client_secret", provider.client_secret()),
("redirect_uri", &redirect_uri), ("redirect_uri", &redirect_uri),
("grant_type", "authorization_code"), ("grant_type", "authorization_code"),
]) ])
@ -217,44 +284,72 @@ async fn callback(
}, },
Ok(r) => { Ok(r) => {
let body = r.text().await.unwrap_or_default(); let body = r.text().await.unwrap_or_default();
tracing::error!("Google token exchange failed: {}", body); tracing::error!("{} token exchange failed: {}", provider.name(), body);
return (StatusCode::BAD_GATEWAY, "Google token exchange failed").into_response(); return (StatusCode::BAD_GATEWAY, "Token exchange failed").into_response();
} }
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Token request failed: {}", e)).into_response(), Err(e) => return (StatusCode::BAD_GATEWAY, format!("Token request failed: {}", e)).into_response(),
}; };
let id_token = match token_body.id_token { // Get user info — provider-specific
Some(t) => t, let user_info = match provider.userinfo_url() {
None => return (StatusCode::BAD_GATEWAY, "No id_token in response").into_response(), Some(userinfo_url) => {
}; // TikTok SSO: call userinfo endpoint with access_token
let access_token = match &token_body.access_token {
// Decode id_token payload (no verification needed - just received from Google over HTTPS) Some(t) => t,
let user_info = match decode_google_id_token(&id_token) { None => return (StatusCode::BAD_GATEWAY, "No access_token in response").into_response(),
Some(u) => u, };
None => return (StatusCode::BAD_GATEWAY, "Failed to decode id_token").into_response(), match fetch_userinfo(&client, userinfo_url, access_token).await {
Ok(u) => u,
Err(e) => return (StatusCode::BAD_GATEWAY, e).into_response(),
}
}
None => {
// Google: decode id_token
let id_token = match &token_body.id_token {
Some(t) => t,
None => return (StatusCode::BAD_GATEWAY, "No id_token in response").into_response(),
};
match decode_jwt_payload(id_token) {
Some(u) => u,
None => return (StatusCode::BAD_GATEWAY, "Failed to decode id_token").into_response(),
}
}
}; };
// Upsert user // Upsert user
let user_id = format!("google:{}", user_info.sub); let user_id = format!("{}:{}", provider.name(), user_info.sub);
// Determine user role: check ADMIN_USERS env var (comma-separated emails or usernames)
let role = {
let admin_list = std::env::var("ADMIN_USERS").unwrap_or_default();
let is_admin = !admin_list.is_empty() && admin_list.split(',').any(|a| {
let a = a.trim();
a == user_info.email || a == user_info.name || a == user_info.sub
});
if is_admin { "admin" } else { "user" }
};
let _ = sqlx::query( let _ = sqlx::query(
"INSERT INTO users (id, email, name, picture) "INSERT INTO users (id, email, name, picture, role)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
email = excluded.email, email = excluded.email,
name = excluded.name, name = excluded.name,
picture = excluded.picture, picture = excluded.picture,
role = excluded.role,
last_login_at = datetime('now')" last_login_at = datetime('now')"
) )
.bind(&user_id) .bind(&user_id)
.bind(&user_info.email) .bind(&user_info.email)
.bind(&user_info.name) .bind(&user_info.name)
.bind(&user_info.picture) .bind(&user_info.picture)
.bind(role)
.execute(&state.db.pool) .execute(&state.db.pool)
.await; .await;
tracing::info!("User logged in: {} ({})", user_info.email, user_id); tracing::info!("User logged in: {} ({})", user_info.email, user_id);
// Sign JWT // Sign session JWT
let exp = chrono::Utc::now().timestamp() + SESSION_SECS; let exp = chrono::Utc::now().timestamp() + SESSION_SECS;
let claims = Claims { let claims = Claims {
sub: user_id, sub: user_id,
@ -288,14 +383,14 @@ async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
}; };
#[derive(Serialize)] #[derive(Serialize)]
struct UserInfo { struct MeResponse {
id: String, id: String,
email: String, email: String,
name: String, name: String,
picture: String, picture: String,
} }
let user: Option<UserInfo> = sqlx::query_as::<_, (String, String, String, String)>( let user: Option<MeResponse> = sqlx::query_as::<_, (String, String, String, String)>(
"SELECT id, email, name, picture FROM users WHERE id = ?" "SELECT id, email, name, picture FROM users WHERE id = ?"
) )
.bind(&claims.sub) .bind(&claims.sub)
@ -303,7 +398,7 @@ async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
.await .await
.ok() .ok()
.flatten() .flatten()
.map(|(id, email, name, picture)| UserInfo { id, email, name, picture }); .map(|(id, email, name, picture)| MeResponse { id, email, name, picture });
match user { match user {
Some(u) => Json(u).into_response(), Some(u) => Json(u).into_response(),
@ -349,8 +444,57 @@ fn extract_claims(jar: &CookieJar, jwt_secret: &str) -> Option<Claims> {
.map(|d| d.claims) .map(|d| d.claims)
} }
fn decode_google_id_token(id_token: &str) -> Option<GoogleUserInfo> { /// Fetch user info from an OAuth userinfo endpoint (TikTok SSO style)
let parts: Vec<&str> = id_token.split('.').collect(); async fn fetch_userinfo(
client: &reqwest::Client,
url: &str,
access_token: &str,
) -> Result<UserInfo, String> {
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
sub: String,
#[serde(default)]
email: String,
#[serde(default)]
name: String,
}
let resp = client
.get(url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Userinfo request failed: {}", e))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Userinfo failed: {}", body));
}
let raw: Raw = resp.json().await.map_err(|e| format!("Userinfo parse error: {}", e))?;
Ok(UserInfo {
sub: raw.sub,
email: raw.email,
name: raw.name,
picture: String::new(),
})
}
/// Decode JWT payload without verification (for Google id_token received over HTTPS)
fn decode_jwt_payload(jwt: &str) -> Option<UserInfo> {
#[derive(Deserialize)]
struct Raw {
sub: String,
#[serde(default)]
email: String,
#[serde(default)]
name: String,
#[serde(default)]
picture: String,
}
let parts: Vec<&str> = jwt.split('.').collect();
if parts.len() != 3 { if parts.len() != 3 {
return None; return None;
} }
@ -359,14 +503,16 @@ fn decode_google_id_token(id_token: &str) -> Option<GoogleUserInfo> {
3 => format!("{}=", parts[1]), 3 => format!("{}=", parts[1]),
_ => parts[1].to_string(), _ => parts[1].to_string(),
}; };
let payload = base64_decode_url_safe(&padded)?; let standard = padded.replace('-', "+").replace('_', "/");
serde_json::from_slice(&payload).ok()
}
fn base64_decode_url_safe(input: &str) -> Option<Vec<u8>> {
let standard = input.replace('-', "+").replace('_', "/");
use base64::Engine; use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(&standard).ok() let payload = base64::engine::general_purpose::STANDARD.decode(&standard).ok()?;
let raw: Raw = serde_json::from_slice(&payload).ok()?;
Some(UserInfo {
sub: raw.sub,
email: raw.email,
name: raw.name,
picture: raw.picture,
})
} }
fn pct_encode(s: &str) -> String { fn pct_encode(s: &str) -> String {

View File

@ -101,21 +101,7 @@ async fn get_file(
let mime = mime_guess::from_path(&full) let mime = mime_guess::from_path(&full)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
let filename = full ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
(
[
(axum::http::header::CONTENT_TYPE, mime),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
bytes,
)
.into_response()
} }
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
} }

View File

@ -106,6 +106,7 @@ async fn proxy_impl(
} }
#[allow(dead_code)]
fn render_markdown_page(markdown: &str, title: &str) -> String { fn render_markdown_page(markdown: &str, title: &str) -> String {
use pulldown_cmark::{Parser, Options, html}; use pulldown_cmark::{Parser, Options, html};
let mut opts = Options::empty(); let mut opts = Options::empty();

View File

@ -5,13 +5,13 @@ use axum::{
Json, Router, Json, Router,
}; };
use axum::http::Extensions; use axum::http::Extensions;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::AppState; use crate::AppState;
use crate::db::Project; use crate::db::Project;
use super::{ApiResult, db_err}; use super::{ApiResult, db_err};
use super::auth::Claims; use super::auth::Claims;
fn owner_id(ext: &Extensions) -> &str { fn user_id(ext: &Extensions) -> &str {
ext.get::<Claims>().map(|c| c.sub.as_str()).unwrap_or("") ext.get::<Claims>().map(|c| c.sub.as_str()).unwrap_or("")
} }
@ -28,20 +28,86 @@ pub struct UpdateProject {
pub description: Option<String>, pub description: Option<String>,
} }
#[derive(Serialize)]
struct MemberResponse {
user_id: String,
role: String,
email: String,
name: String,
}
#[derive(Deserialize)]
struct AddMemberRequest {
user_id: String,
#[serde(default = "default_role")]
role: String,
}
fn default_role() -> String {
"owner".to_string()
}
pub fn router(state: Arc<AppState>) -> Router { pub fn router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.route("/projects", get(list_projects).post(create_project)) .route("/projects", get(list_projects).post(create_project))
.route("/projects/{id}", get(get_project).put(update_project).delete(delete_project)) .route("/projects/{id}", get(get_project).put(update_project).delete(delete_project))
.route("/projects/{id}/members", get(list_members).post(add_member))
.route("/projects/{id}/members/{user_id}", axum::routing::delete(remove_member))
.with_state(state) .with_state(state)
} }
/// Check if user is admin (users.role = 'admin')
async fn is_admin(pool: &sqlx::SqlitePool, uid: &str) -> bool {
if uid.is_empty() {
return true; // auth not configured
}
sqlx::query_scalar::<_, bool>(
"SELECT COALESCE((SELECT role = 'admin' FROM users WHERE id = ?), 0)"
)
.bind(uid)
.fetch_one(pool)
.await
.unwrap_or(false)
}
/// Check if user can access a project. Admin users can access all projects.
async fn can_access(pool: &sqlx::SqlitePool, project_id: &str, uid: &str) -> bool {
if uid.is_empty() {
return true; // auth not configured
}
if is_admin(pool, uid).await {
return true;
}
sqlx::query_scalar::<_, bool>(
"SELECT COUNT(*) > 0 FROM project_members WHERE project_id = ? AND user_id = ?"
)
.bind(project_id)
.bind(uid)
.fetch_one(pool)
.await
.unwrap_or(false)
}
async fn list_projects( async fn list_projects(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
ext: Extensions, ext: Extensions,
) -> ApiResult<Vec<Project>> { ) -> ApiResult<Vec<Project>> {
let uid = owner_id(&ext); let uid = user_id(&ext);
if uid.is_empty() || is_admin(&state.db.pool, uid).await {
// Auth not configured or admin user — show all
return sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE deleted = 0 ORDER BY updated_at DESC"
)
.fetch_all(&state.db.pool)
.await
.map(Json)
.map_err(db_err);
}
sqlx::query_as::<_, Project>( sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE deleted = 0 AND (owner_id = ? OR owner_id = '') ORDER BY updated_at DESC" "SELECT p.* FROM projects p \
JOIN project_members pm ON p.id = pm.project_id \
WHERE p.deleted = 0 AND pm.user_id = ? \
ORDER BY p.updated_at DESC"
) )
.bind(uid) .bind(uid)
.fetch_all(&state.db.pool) .fetch_all(&state.db.pool)
@ -56,8 +122,8 @@ async fn create_project(
Json(input): Json<CreateProject>, Json(input): Json<CreateProject>,
) -> ApiResult<Project> { ) -> ApiResult<Project> {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let uid = owner_id(&ext); let uid = user_id(&ext);
sqlx::query_as::<_, Project>( let project = sqlx::query_as::<_, Project>(
"INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?) RETURNING *" "INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?) RETURNING *"
) )
.bind(&id) .bind(&id)
@ -66,8 +132,20 @@ async fn create_project(
.bind(uid) .bind(uid)
.fetch_one(&state.db.pool) .fetch_one(&state.db.pool)
.await .await
.map(Json) .map_err(db_err)?;
.map_err(db_err)
// Auto-add creator as admin member
if !uid.is_empty() {
let _ = sqlx::query(
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')"
)
.bind(&id)
.bind(uid)
.execute(&state.db.pool)
.await;
}
Ok(Json(project))
} }
async fn get_project( async fn get_project(
@ -75,12 +153,14 @@ async fn get_project(
ext: Extensions, ext: Extensions,
Path(id): Path<String>, Path(id): Path<String>,
) -> ApiResult<Option<Project>> { ) -> ApiResult<Option<Project>> {
let uid = owner_id(&ext); let uid = user_id(&ext);
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
return Ok(Json(None));
}
sqlx::query_as::<_, Project>( sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')" "SELECT * FROM projects WHERE id = ?"
) )
.bind(&id) .bind(&id)
.bind(uid)
.fetch_optional(&state.db.pool) .fetch_optional(&state.db.pool)
.await .await
.map(Json) .map(Json)
@ -93,30 +173,30 @@ async fn update_project(
Path(id): Path<String>, Path(id): Path<String>,
Json(input): Json<UpdateProject>, Json(input): Json<UpdateProject>,
) -> ApiResult<Option<Project>> { ) -> ApiResult<Option<Project>> {
let uid = owner_id(&ext); let uid = user_id(&ext);
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
return Ok(Json(None));
}
if let Some(name) = &input.name { if let Some(name) = &input.name {
sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')") sqlx::query("UPDATE projects SET name = ?, updated_at = datetime('now') WHERE id = ?")
.bind(name) .bind(name)
.bind(&id) .bind(&id)
.bind(uid)
.execute(&state.db.pool) .execute(&state.db.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
} }
if let Some(desc) = &input.description { if let Some(desc) = &input.description {
sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ? AND (owner_id = ? OR owner_id = '')") sqlx::query("UPDATE projects SET description = ?, updated_at = datetime('now') WHERE id = ?")
.bind(desc) .bind(desc)
.bind(&id) .bind(&id)
.bind(uid)
.execute(&state.db.pool) .execute(&state.db.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
} }
sqlx::query_as::<_, Project>( sqlx::query_as::<_, Project>(
"SELECT * FROM projects WHERE id = ? AND (owner_id = ? OR owner_id = '')" "SELECT * FROM projects WHERE id = ?"
) )
.bind(&id) .bind(&id)
.bind(uid)
.fetch_optional(&state.db.pool) .fetch_optional(&state.db.pool)
.await .await
.map(Json) .map(Json)
@ -128,12 +208,14 @@ async fn delete_project(
ext: Extensions, ext: Extensions,
Path(id): Path<String>, Path(id): Path<String>,
) -> ApiResult<bool> { ) -> ApiResult<bool> {
let uid = owner_id(&ext); let uid = user_id(&ext);
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
return Ok(Json(false));
}
let result = sqlx::query( let result = sqlx::query(
"UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0 AND (owner_id = ? OR owner_id = '')" "UPDATE projects SET deleted = 1, updated_at = datetime('now') WHERE id = ? AND deleted = 0"
) )
.bind(&id) .bind(&id)
.bind(uid)
.execute(&state.db.pool) .execute(&state.db.pool)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
@ -158,3 +240,75 @@ async fn delete_project(
Ok(Json(true)) Ok(Json(true))
} }
// --- Member management ---
async fn list_members(
State(state): State<Arc<AppState>>,
ext: Extensions,
Path(id): Path<String>,
) -> ApiResult<Vec<MemberResponse>> {
let uid = user_id(&ext);
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
return Ok(Json(vec![]));
}
let members: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT pm.user_id, pm.role, COALESCE(u.email, ''), COALESCE(u.name, '') \
FROM project_members pm \
LEFT JOIN users u ON pm.user_id = u.id \
WHERE pm.project_id = ? \
ORDER BY pm.created_at ASC"
)
.bind(&id)
.fetch_all(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(members.into_iter().map(|(user_id, role, email, name)| {
MemberResponse { user_id, role, email, name }
}).collect()))
}
async fn add_member(
State(state): State<Arc<AppState>>,
ext: Extensions,
Path(id): Path<String>,
Json(input): Json<AddMemberRequest>,
) -> ApiResult<bool> {
let uid = user_id(&ext);
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
return Ok(Json(false));
}
let result = sqlx::query(
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)"
)
.bind(&id)
.bind(&input.user_id)
.bind(&input.role)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(result.rows_affected() > 0))
}
async fn remove_member(
State(state): State<Arc<AppState>>,
ext: Extensions,
Path((id, member_id)): Path<(String, String)>,
) -> ApiResult<bool> {
let uid = user_id(&ext);
if !uid.is_empty() && !can_access(&state.db.pool, &id, uid).await {
return Ok(Json(false));
}
let result = sqlx::query(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?"
)
.bind(&id)
.bind(&member_id)
.execute(&state.db.pool)
.await
.map_err(db_err)?;
Ok(Json(result.rows_affected() > 0))
}

View File

@ -249,6 +249,79 @@ impl Database {
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
// Migration: add role column to users (admin = see all projects)
let _ = sqlx::query(
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"
)
.execute(&self.pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id),
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'owner',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, user_id)
)"
)
.execute(&self.pool)
.await?;
// Migration: assign all existing memberless projects to the first user (or leave for manual assignment)
// When auth is not configured, owner_id is empty — these projects are visible to everyone
// When a user logs in and creates projects, they get auto-added as admin
{
// Find existing projects with owner_id set but no members yet
let owned: Vec<(String, String)> = sqlx::query_as(
"SELECT p.id, p.owner_id FROM projects p \
WHERE p.deleted = 0 AND p.owner_id != '' \
AND NOT EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id)"
)
.fetch_all(&self.pool)
.await
.unwrap_or_default();
for (pid, uid) in owned {
let _ = sqlx::query(
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')"
)
.bind(&pid)
.bind(&uid)
.execute(&self.pool)
.await;
}
// For orphan projects (no owner, no members), assign to first user if one exists
let first_user: Option<(String,)> = sqlx::query_as(
"SELECT id FROM users ORDER BY created_at ASC LIMIT 1"
)
.fetch_optional(&self.pool)
.await
.unwrap_or(None);
if let Some((first_uid,)) = first_user {
let orphans: Vec<(String,)> = sqlx::query_as(
"SELECT p.id FROM projects p \
WHERE p.deleted = 0 \
AND NOT EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id)"
)
.fetch_all(&self.pool)
.await
.unwrap_or_default();
for (pid,) in orphans {
let _ = sqlx::query(
"INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')"
)
.bind(&pid)
.bind(&first_uid)
.execute(&self.pool)
.await;
}
}
}
Ok(()) Ok(())
} }
} }

View File

@ -123,25 +123,41 @@ async fn main() -> anyhow::Result<()> {
let obj_root = std::env::var("OBJ_ROOT").unwrap_or_else(|_| "/data/obj".to_string()); let obj_root = std::env::var("OBJ_ROOT").unwrap_or_else(|_| "/data/obj".to_string());
let auth_config = match ( let auth_config = {
std::env::var("GOOGLE_CLIENT_ID"), let jwt_secret = std::env::var("JWT_SECRET")
std::env::var("GOOGLE_CLIENT_SECRET"), .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
) { let public_url = std::env::var("PUBLIC_URL")
(Ok(client_id), Ok(client_secret)) => { .unwrap_or_else(|_| "https://tori.euphon.cloud".to_string());
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Try TikTok SSO first, then Google OAuth
let public_url = std::env::var("PUBLIC_URL") if let (Ok(id), Ok(secret)) = (
.unwrap_or_else(|_| "https://tori.euphon.cloud".to_string()); std::env::var("SSO_CLIENT_ID"),
tracing::info!("Google OAuth enabled (public_url={})", public_url); std::env::var("SSO_CLIENT_SECRET"),
) {
tracing::info!("TikTok SSO enabled (public_url={})", public_url);
Some(api::auth::AuthConfig { Some(api::auth::AuthConfig {
google_client_id: client_id, provider: api::auth::OAuthProvider::TikTokSso {
google_client_secret: client_secret, client_id: id,
client_secret: secret,
},
jwt_secret, jwt_secret,
public_url, public_url,
}) })
} } else if let (Ok(id), Ok(secret)) = (
_ => { std::env::var("GOOGLE_CLIENT_ID"),
tracing::warn!("GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET not set, auth disabled"); std::env::var("GOOGLE_CLIENT_SECRET"),
) {
tracing::info!("Google OAuth enabled (public_url={})", public_url);
Some(api::auth::AuthConfig {
provider: api::auth::OAuthProvider::Google {
client_id: id,
client_secret: secret,
},
jwt_secret,
public_url,
})
} else {
tracing::warn!("No OAuth configured (set SSO_CLIENT_ID/SSO_CLIENT_SECRET or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET)");
None None
} }
}; };
@ -156,6 +172,10 @@ async fn main() -> anyhow::Result<()> {
}); });
let app = Router::new() let app = Router::new()
// Health check (public, for k8s probes)
.route("/tori/api/health", axum::routing::get(|| async {
axum::Json(serde_json::json!({"status": "ok"}))
}))
// Auth routes are public // Auth routes are public
.nest("/tori/api/auth", api::auth::router(state.clone())) .nest("/tori/api/auth", api::auth::router(state.clone()))
// Protected API routes // Protected API routes

View File

@ -1,5 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'
import { auth } from '../api' import { auth } from '../api'
const provider = ref<string | null>(null)
onMounted(async () => {
try {
const res = await fetch(`${import.meta.env.BASE_URL.replace(/\/$/, '')}/api/auth/provider`)
if (res.ok) {
const data = await res.json()
provider.value = data.provider
}
} catch {
// fallback to generic
}
})
const providerLabel: Record<string, string> = {
'google': 'Google',
'tiktok-sso': 'TikTok SSO',
}
</script> </script>
<template> <template>
@ -7,14 +27,20 @@ import { auth } from '../api'
<div class="login-card"> <div class="login-card">
<h1 class="login-title">Tori</h1> <h1 class="login-title">Tori</h1>
<p class="login-subtitle">Sign in to continue</p> <p class="login-subtitle">Sign in to continue</p>
<a :href="auth.loginUrl" class="google-btn"> <a :href="auth.loginUrl" class="login-btn">
<svg class="google-icon" viewBox="0 0 24 24" width="18" height="18"> <!-- Google icon -->
<svg v-if="provider === 'google'" class="provider-icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/> <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/> <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/> <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/> <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg> </svg>
Sign in with Google <!-- Generic lock icon for other providers -->
<svg v-else class="provider-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Sign in{{ provider ? ` with ${providerLabel[provider] || provider}` : '' }}
</a> </a>
</div> </div>
</div> </div>
@ -51,7 +77,7 @@ import { auth } from '../api'
margin: 0 0 32px; margin: 0 0 32px;
} }
.google-btn { .login-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@ -67,12 +93,12 @@ import { auth } from '../api'
transition: background 0.15s, box-shadow 0.15s; transition: background 0.15s, box-shadow 0.15s;
} }
.google-btn:hover { .login-btn:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
} }
.google-icon { .provider-icon {
flex-shrink: 0; flex-shrink: 0;
} }
</style> </style>