Forráskód Böngészése

fix: harden auth and git compare flows

LoganZ2 3 órája
szülő
commit
036ba4b491
9 módosított fájl, 1207 hozzáadás és 639 törlés
  1. 70 0
      Cargo.lock
  2. 4 0
      Cargo.toml
  3. 51 2
      src/app.rs
  4. 134 88
      src/db.rs
  5. 10 3
      src/error.rs
  6. 363 249
      src/git.rs
  7. 503 267
      src/http.rs
  8. 19 0
      src/models.rs
  9. 53 30
      src/service.rs

+ 70 - 0
Cargo.lock

@@ -529,6 +529,12 @@ version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
 
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
 [[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
@@ -651,14 +657,17 @@ dependencies = [
  "actix-web",
  "argon2",
  "hex",
+ "r2d2",
  "rand_core 0.6.4",
  "rusqlite",
  "serde",
  "serde_json",
  "sha2",
+ "tempfile",
  "thiserror",
  "tokio",
  "toml",
+ "wait-timeout",
 ]
 
 [[package]]
@@ -929,6 +938,12 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
 [[package]]
 name = "litemap"
 version = "0.8.2"
@@ -1120,6 +1135,17 @@ version = "6.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
+[[package]]
+name = "r2d2"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
+dependencies = [
+ "log",
+ "parking_lot",
+ "scheduled-thread-pool",
+]
+
 [[package]]
 name = "rand"
 version = "0.10.1"
@@ -1213,12 +1239,34 @@ dependencies = [
  "semver",
 ]
 
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "ryu"
 version = "1.0.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
 
+[[package]]
+name = "scheduled-thread-pool"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
+dependencies = [
+ "parking_lot",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.2.0"
@@ -1405,6 +1453,19 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "thiserror"
 version = "2.0.18"
@@ -1634,6 +1695,15 @@ version = "0.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
 
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "wasi"
 version = "0.11.1+wasi-snapshot-preview1"

+ 4 - 0
Cargo.toml

@@ -9,11 +9,15 @@ actix-web = "4"
 hex = "0.4"
 rand_core = { version = "0.6", features = ["getrandom"] }
 rusqlite = { version = "0.32", features = ["bundled"] }
+r2d2 = "0.8"
 serde = { version = "1", features = ["derive"] }
+serde_json = "1"
 sha2 = "0.10"
+tempfile = "3"
 thiserror = "2"
 tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
 toml = "0.8"
+wait-timeout = "0.2"
 
 [dev-dependencies]
 actix-http = "3"

+ 51 - 2
src/app.rs

@@ -1,12 +1,61 @@
-use crate::{conf::AppConfig, db::Database};
+use std::{
+    collections::HashMap,
+    sync::Mutex,
+    time::{Duration, Instant},
+};
+
+use crate::{
+    conf::AppConfig,
+    db::Database,
+    error::{AppError, AppResult},
+};
 
 pub struct AppState {
     pub config: AppConfig,
     pub db: Database,
+    pub login_rate_limiter: LoginRateLimiter,
 }
 
 impl AppState {
     pub fn new(config: AppConfig, db: Database) -> Self {
-        Self { config, db }
+        Self {
+            config,
+            db,
+            login_rate_limiter: LoginRateLimiter::new(),
+        }
+    }
+}
+
+const MAX_LOGIN_ATTEMPTS: usize = 5;
+const LOGIN_WINDOW_SECS: u64 = 300;
+
+pub struct LoginRateLimiter {
+    inner: Mutex<HashMap<String, (usize, Instant)>>,
+}
+
+impl LoginRateLimiter {
+    pub fn new() -> Self {
+        Self {
+            inner: Mutex::new(HashMap::new()),
+        }
+    }
+
+    pub fn check(&self, key: &str) -> AppResult<()> {
+        let mut map = self
+            .inner
+            .lock()
+            .map_err(|_| AppError::RateLimited)?;
+        let now = Instant::now();
+        let entry = map.entry(key.to_string()).or_insert((0, now));
+
+        if now.duration_since(entry.1) > Duration::from_secs(LOGIN_WINDOW_SECS) {
+            *entry = (0, now);
+        }
+
+        entry.0 += 1;
+        if entry.0 > MAX_LOGIN_ATTEMPTS {
+            return Err(AppError::RateLimited);
+        }
+        Ok(())
     }
 }

+ 134 - 88
src/db.rs

@@ -1,10 +1,7 @@
-use std::{
-    path::Path,
-    sync::{Arc, Mutex},
-    time::{SystemTime, UNIX_EPOCH},
-};
+use std::path::Path;
 
-use rusqlite::{Connection, OptionalExtension, Transaction, params};
+use r2d2::{Pool, PooledConnection};
+use rusqlite::{Connection, OptionalExtension, Transaction, named_params, params};
 
 use crate::{
     error::{AppError, AppResult},
@@ -14,29 +11,64 @@ use crate::{
     },
 };
 
-#[derive(Clone)]
-pub struct Database {
-    conn: Arc<Mutex<Connection>>,
+#[derive(Debug)]
+struct SqliteConnectionManager {
+    path: std::path::PathBuf,
 }
 
-impl Database {
-    pub fn open(path: &Path) -> AppResult<Self> {
-        let conn = Connection::open(path)?;
+impl SqliteConnectionManager {
+    fn new(path: impl AsRef<std::path::Path>) -> Self {
+        Self {
+            path: path.as_ref().to_path_buf(),
+        }
+    }
+}
+
+impl r2d2::ManageConnection for SqliteConnectionManager {
+    type Connection = Connection;
+    type Error = rusqlite::Error;
+
+    fn connect(&self) -> Result<Self::Connection, Self::Error> {
+        let conn = Connection::open(&self.path)?;
         conn.execute_batch(
             r#"
             PRAGMA journal_mode = WAL;
             PRAGMA foreign_keys = ON;
             PRAGMA synchronous = NORMAL;
             PRAGMA temp_store = MEMORY;
+            PRAGMA busy_timeout = 5000;
             "#,
         )?;
-        Ok(Self {
-            conn: Arc::new(Mutex::new(conn)),
-        })
+        Ok(conn)
+    }
+
+    fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
+        conn.execute_batch("SELECT 1")?;
+        Ok(())
+    }
+
+    fn has_broken(&self, _conn: &mut Self::Connection) -> bool {
+        false
+    }
+}
+
+#[derive(Clone)]
+pub struct Database {
+    pool: Pool<SqliteConnectionManager>,
+}
+
+impl Database {
+    pub fn open(path: &Path) -> AppResult<Self> {
+        let manager = SqliteConnectionManager::new(path);
+        let pool = Pool::builder()
+            .max_size(10)
+            .build(manager)
+            .map_err(|e| AppError::Pool(e.to_string()))?;
+        Ok(Self { pool })
     }
 
     pub fn init_schema(&self) -> AppResult<()> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         conn.execute_batch(
             r#"
             CREATE TABLE IF NOT EXISTS user (
@@ -145,7 +177,7 @@ impl Database {
     }
 
     pub fn create_user(&self, new_user: NewUser<'_>) -> AppResult<User> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
 
         let lower_name = new_user.username.to_ascii_lowercase();
@@ -191,13 +223,13 @@ impl Database {
     }
 
     pub fn user_count(&self) -> AppResult<i64> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         conn.query_row("SELECT COUNT(*) FROM user", [], |row| row.get::<_, i64>(0))
             .map_err(Into::into)
     }
 
     pub fn get_user_by_id(&self, id: i64) -> AppResult<Option<User>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, lower_name, name, full_name, email, password_hash,
@@ -211,7 +243,7 @@ impl Database {
     }
 
     pub fn get_user_by_username(&self, username: &str) -> AppResult<Option<User>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, lower_name, name, full_name, email, password_hash,
@@ -225,7 +257,7 @@ impl Database {
     }
 
     pub fn get_user_by_email(&self, email: &str) -> AppResult<Option<User>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, lower_name, name, full_name, email, password_hash,
@@ -239,7 +271,7 @@ impl Database {
     }
 
     pub fn create_repository(&self, new_repo: NewRepository<'_>) -> AppResult<Repository> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
 
         let lower_name = new_repo.name.to_ascii_lowercase();
@@ -281,7 +313,7 @@ impl Database {
     }
 
     pub fn get_repository_by_id(&self, id: i64) -> AppResult<Option<Repository>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, owner_id, lower_name, name, description, default_branch,
@@ -299,7 +331,7 @@ impl Database {
         owner_id: i64,
         name: &str,
     ) -> AppResult<Option<Repository>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, owner_id, lower_name, name, description, default_branch,
@@ -313,8 +345,12 @@ impl Database {
             .map_err(Into::into)
     }
 
-    pub fn list_repositories_with_owners(&self) -> AppResult<Vec<RepositoryWithOwner>> {
-        let conn = self.lock()?;
+    pub fn list_repositories_with_owners(
+        &self,
+        limit: i64,
+        offset: i64,
+    ) -> AppResult<Vec<RepositoryWithOwner>> {
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT
@@ -325,9 +361,10 @@ impl Database {
             FROM repository r
             JOIN user u ON u.id = r.owner_id
             ORDER BY u.lower_name ASC, r.lower_name ASC
+            LIMIT ?1 OFFSET ?2
             "#,
         )?;
-        let rows = stmt.query_map([], row_to_repository_with_owner)?;
+        let rows = stmt.query_map(params![limit, offset], row_to_repository_with_owner)?;
         let mut repos = Vec::new();
         for row in rows {
             repos.push(row?);
@@ -338,8 +375,10 @@ impl Database {
     pub fn list_repositories_with_owners_by_owner(
         &self,
         owner_id: i64,
+        limit: i64,
+        offset: i64,
     ) -> AppResult<Vec<RepositoryWithOwner>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT
@@ -351,9 +390,10 @@ impl Database {
             JOIN user u ON u.id = r.owner_id
             WHERE r.owner_id = ?1
             ORDER BY r.lower_name ASC
+            LIMIT ?2 OFFSET ?3
             "#,
         )?;
-        let rows = stmt.query_map(params![owner_id], row_to_repository_with_owner)?;
+        let rows = stmt.query_map(params![owner_id, limit, offset], row_to_repository_with_owner)?;
         let mut repos = Vec::new();
         for row in rows {
             repos.push(row?);
@@ -362,7 +402,7 @@ impl Database {
     }
 
     pub fn has_forked_by(&self, repo_id: i64, user_id: i64) -> AppResult<bool> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt =
             conn.prepare("SELECT 1 FROM repository WHERE owner_id = ?1 AND fork_id = ?2 LIMIT 1")?;
         let found = stmt
@@ -372,7 +412,7 @@ impl Database {
     }
 
     pub fn delete_repository_by_id(&self, id: i64) -> AppResult<()> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         conn.execute("DELETE FROM repository WHERE id = ?1", params![id])?;
         Ok(())
     }
@@ -401,7 +441,7 @@ impl Database {
             return Ok(AccessMode::Owner);
         }
 
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt =
             conn.prepare("SELECT mode FROM access WHERE user_id = ?1 AND repo_id = ?2")?;
         let found = stmt
@@ -425,7 +465,7 @@ impl Database {
     }
 
     pub fn set_repo_perms(&self, repo_id: i64, access_map: &[(i64, AccessMode)]) -> AppResult<()> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
         tx.execute("DELETE FROM access WHERE repo_id = ?1", params![repo_id])?;
         for (user_id, mode) in access_map {
@@ -444,7 +484,7 @@ impl Database {
         user_id: i64,
         mode: AccessMode,
     ) -> AppResult<Collaboration> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
         tx.execute(
             r#"
@@ -475,7 +515,7 @@ impl Database {
     }
 
     pub fn get_collaboration_by_id(&self, id: i64) -> AppResult<Option<Collaboration>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt =
             conn.prepare("SELECT id, user_id, repo_id, mode FROM collaboration WHERE id = ?1")?;
         stmt.query_row(params![id], row_to_collaboration)
@@ -488,7 +528,7 @@ impl Database {
         repo_id: i64,
         user_id: i64,
     ) -> AppResult<Option<CollaboratorResponse>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT
@@ -506,8 +546,9 @@ impl Database {
             .map_err(Into::into)
     }
 
-    pub fn list_collaborators(&self, repo_id: i64) -> AppResult<Vec<CollaboratorResponse>> {
-        let conn = self.lock()?;
+    pub fn list_collaborators(
+        &self, repo_id: i64, limit: i64, offset: i64) -> AppResult<Vec<CollaboratorResponse>> {
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT
@@ -518,9 +559,10 @@ impl Database {
             JOIN user u ON u.id = c.user_id
             WHERE c.repo_id = ?1
             ORDER BY u.lower_name ASC
+            LIMIT ?2 OFFSET ?3
             "#,
         )?;
-        let rows = stmt.query_map(params![repo_id], row_to_collaborator_response)?;
+        let rows = stmt.query_map(params![repo_id, limit, offset], row_to_collaborator_response)?;
         let mut collaborators = Vec::new();
         for row in rows {
             collaborators.push(row?);
@@ -529,7 +571,7 @@ impl Database {
     }
 
     pub fn delete_collaboration(&self, repo_id: i64, user_id: i64) -> AppResult<()> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
         tx.execute(
             "DELETE FROM collaboration WHERE repo_id = ?1 AND user_id = ?2",
@@ -549,7 +591,7 @@ impl Database {
         name: &str,
         token_hash: &str,
     ) -> AppResult<AccessToken> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
         if self.access_token_exists_by_name(&tx, user_id, name)? {
             return Err(AppError::Conflict(format!(
@@ -578,7 +620,7 @@ impl Database {
         name: &str,
         token_hash: &str,
     ) -> AppResult<AccessToken> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let tx = conn.unchecked_transaction()?;
         let now = now_unix();
         tx.execute(
@@ -601,7 +643,7 @@ impl Database {
     }
 
     pub fn get_access_token_by_id(&self, id: i64) -> AppResult<Option<AccessToken>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, user_id, name, token_hash, created_unix, updated_unix
@@ -614,7 +656,7 @@ impl Database {
     }
 
     pub fn get_access_token_by_hash(&self, token_hash: &str) -> AppResult<Option<AccessToken>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, user_id, name, token_hash, created_unix, updated_unix
@@ -626,17 +668,23 @@ impl Database {
             .map_err(Into::into)
     }
 
-    pub fn list_access_tokens_by_user(&self, user_id: i64) -> AppResult<Vec<AccessToken>> {
-        let conn = self.lock()?;
+    pub fn list_access_tokens_by_user(
+        &self,
+        user_id: i64,
+        limit: i64,
+        offset: i64,
+    ) -> AppResult<Vec<AccessToken>> {
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, user_id, name, token_hash, created_unix, updated_unix
             FROM access_token
             WHERE user_id = ?1
             ORDER BY id ASC
+            LIMIT ?2 OFFSET ?3
             "#,
         )?;
-        let rows = stmt.query_map(params![user_id], row_to_access_token)?;
+        let rows = stmt.query_map(params![user_id, limit, offset], row_to_access_token)?;
         let mut tokens = Vec::new();
         for row in rows {
             tokens.push(row?);
@@ -645,7 +693,7 @@ impl Database {
     }
 
     pub fn delete_access_token_by_id(&self, user_id: i64, token_id: i64) -> AppResult<bool> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let affected = conn.execute(
             "DELETE FROM access_token WHERE id = ?1 AND user_id = ?2",
             params![token_id, user_id],
@@ -654,7 +702,7 @@ impl Database {
     }
 
     pub fn touch_access_token(&self, token_id: i64) -> AppResult<()> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         conn.execute(
             "UPDATE access_token SET updated_unix = ?2 WHERE id = ?1",
             params![token_id, now_unix()],
@@ -663,48 +711,42 @@ impl Database {
     }
 
     pub fn create_pull_request(&self, new_pull: NewPullRequest<'_>) -> AppResult<PullRequest> {
-        let conn = self.lock()?;
-        let tx = conn.unchecked_transaction()?;
+        let conn = self.conn()?;
         let now = now_unix();
-        let index = tx.query_row(
-            "SELECT COALESCE(MAX(index_in_repo), 0) + 1 FROM pull_request WHERE base_repo_id = ?1",
-            params![new_pull.base_repo_id],
-            |row| row.get::<_, i64>(0),
-        )?;
-        tx.execute(
+        conn.execute(
             r#"
             INSERT INTO pull_request (
                 index_in_repo, title, body, status, head_repo_id, base_repo_id,
                 head_user_name, head_branch, base_branch, merge_base, merged_commit_id, poster_id,
                 has_merged, is_closed, created_unix, updated_unix
-            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '', ?11, 0, 0, ?12, ?13)
+            )
+            SELECT
+                COALESCE((SELECT MAX(index_in_repo) FROM pull_request WHERE base_repo_id = :base_repo_id), 0) + 1,
+                :title, :body, :status, :head_repo_id, :base_repo_id, :head_user_name,
+                :head_branch, :base_branch, :merge_base, '', :poster_id, 0, 0, :now, :now
             "#,
-            params![
-                index,
-                new_pull.title,
-                new_pull.body,
-                new_pull.status as i64,
-                new_pull.head_repo_id,
-                new_pull.base_repo_id,
-                new_pull.head_user_name,
-                new_pull.head_branch,
-                new_pull.base_branch,
-                new_pull.merge_base,
-                new_pull.poster_id,
-                now,
-                now
-            ],
+            named_params! {
+                ":base_repo_id": new_pull.base_repo_id,
+                ":title": new_pull.title,
+                ":body": new_pull.body,
+                ":status": new_pull.status as i64,
+                ":head_repo_id": new_pull.head_repo_id,
+                ":head_user_name": new_pull.head_user_name,
+                ":head_branch": new_pull.head_branch,
+                ":base_branch": new_pull.base_branch,
+                ":merge_base": new_pull.merge_base,
+                ":poster_id": new_pull.poster_id,
+                ":now": now,
+            },
         )?;
-        let id = tx.last_insert_rowid();
-        tx.commit()?;
-        drop(conn);
+        let id = conn.last_insert_rowid();
         self.get_pull_request_by_id(id)?.ok_or_else(|| {
             AppError::NotFound(format!("pull request disappeared after create: {id}"))
         })
     }
 
     pub fn get_pull_request_by_id(&self, id: i64) -> AppResult<Option<PullRequest>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
@@ -725,7 +767,7 @@ impl Database {
         head_branch: &str,
         base_branch: &str,
     ) -> AppResult<Option<PullRequest>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
@@ -751,7 +793,7 @@ impl Database {
         base_repo_id: i64,
         index: i64,
     ) -> AppResult<Option<PullRequest>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
@@ -770,8 +812,10 @@ impl Database {
     pub fn list_pull_requests_by_base_repo(
         &self,
         base_repo_id: i64,
+        limit: i64,
+        offset: i64,
     ) -> AppResult<Vec<PullRequest>> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let mut stmt = conn.prepare(
             r#"
             SELECT id, index_in_repo, title, body, status, head_repo_id, base_repo_id,
@@ -780,9 +824,10 @@ impl Database {
             FROM pull_request
             WHERE base_repo_id = ?1
             ORDER BY index_in_repo ASC
+            LIMIT ?2 OFFSET ?3
             "#,
         )?;
-        let rows = stmt.query_map(params![base_repo_id], row_to_pull_request)?;
+        let rows = stmt.query_map(params![base_repo_id, limit, offset], row_to_pull_request)?;
         let mut pulls = Vec::new();
         for row in rows {
             pulls.push(row?);
@@ -795,7 +840,7 @@ impl Database {
         id: i64,
         merged_commit_id: &str,
     ) -> AppResult<PullRequest> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let now = now_unix();
         conn.execute(
             r#"
@@ -817,7 +862,7 @@ impl Database {
         is_closed: bool,
         status: PullRequestStatus,
     ) -> AppResult<PullRequest> {
-        let conn = self.lock()?;
+        let conn = self.conn()?;
         let now = now_unix();
         conn.execute(
             r#"
@@ -887,10 +932,10 @@ impl Database {
         Ok(found.is_some())
     }
 
-    fn lock(&self) -> AppResult<std::sync::MutexGuard<'_, Connection>> {
-        self.conn
-            .lock()
-            .map_err(|_| AppError::Db(rusqlite::Error::InvalidQuery))
+    fn conn(&self) -> AppResult<PooledConnection<SqliteConnectionManager>> {
+        self.pool
+            .get()
+            .map_err(|e| AppError::Pool(e.to_string()))
     }
 }
 
@@ -1068,7 +1113,8 @@ fn pull_request_status_from_i64(value: i64) -> PullRequestStatus {
     }
 }
 
-fn now_unix() -> i64 {
+pub fn now_unix() -> i64 {
+    use std::time::{SystemTime, UNIX_EPOCH};
     SystemTime::now()
         .duration_since(UNIX_EPOCH)
         .unwrap_or_default()

+ 10 - 3
src/error.rs

@@ -12,6 +12,8 @@ pub enum AppError {
     Io(#[from] io::Error),
     #[error("database error: {0}")]
     Db(#[from] rusqlite::Error),
+    #[error("database pool error: {0}")]
+    Pool(String),
     #[error("configuration parse error: {0}")]
     Config(toml::de::Error),
     #[error("validation error: {0}")]
@@ -24,6 +26,8 @@ pub enum AppError {
     Unauthorized(String),
     #[error("forbidden: {0}")]
     Forbidden(String),
+    #[error("rate limited")]
+    RateLimited,
     #[error("git error: {0}")]
     Git(String),
 }
@@ -43,7 +47,8 @@ impl ResponseError for AppError {
             AppError::NotFound(_) => StatusCode::NOT_FOUND,
             AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
             AppError::Forbidden(_) => StatusCode::FORBIDDEN,
-            AppError::Io(_) | AppError::Db(_) | AppError::Config(_) | AppError::Git(_) => {
+            AppError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
+            AppError::Io(_) | AppError::Db(_) | AppError::Pool(_) | AppError::Config(_) | AppError::Git(_) => {
                 StatusCode::INTERNAL_SERVER_ERROR
             }
         }
@@ -66,7 +71,8 @@ impl AppError {
             AppError::NotFound(_) => "not_found",
             AppError::Unauthorized(_) => "unauthorized",
             AppError::Forbidden(_) => "forbidden",
-            AppError::Io(_) | AppError::Db(_) | AppError::Config(_) | AppError::Git(_) => {
+            AppError::RateLimited => "rate_limited",
+            AppError::Io(_) | AppError::Db(_) | AppError::Pool(_) | AppError::Config(_) | AppError::Git(_) => {
                 "internal_error"
             }
         }
@@ -79,7 +85,8 @@ impl AppError {
             | AppError::NotFound(message)
             | AppError::Unauthorized(message)
             | AppError::Forbidden(message) => message.clone(),
-            AppError::Io(_) | AppError::Db(_) | AppError::Config(_) | AppError::Git(_) => {
+            AppError::RateLimited => "too many requests".to_string(),
+            AppError::Io(_) | AppError::Db(_) | AppError::Pool(_) | AppError::Config(_) | AppError::Git(_) => {
                 "internal server error".to_string()
             }
         }

+ 363 - 249
src/git.rs

@@ -1,12 +1,17 @@
 use std::{
     fs,
-    io::Write,
+    io::{Read, Write},
     path::Path,
     process::{Command, Stdio},
+    time::Duration,
 };
 
+use wait_timeout::ChildExt;
+
 use crate::error::{AppError, AppResult};
 
+const GIT_TIMEOUT_SECS: u64 = 60;
+
 #[derive(Debug, Clone)]
 pub struct CompareCommit {
     pub id: String,
@@ -39,20 +44,27 @@ pub fn init_bare_repo_with_binary(
         fs::create_dir_all(parent)?;
     }
 
-    run(Command::new(git_binary)
-        .arg("init")
-        .arg("--bare")
-        .arg(repo_path))?;
-    run(Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("symbolic-ref")
-        .arg("HEAD")
-        .arg(format!("refs/heads/{default_branch}")))?;
-    run(Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("update-server-info"))?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("init")
+            .arg("--bare")
+            .arg("--")
+            .arg(repo_path),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("symbolic-ref")
+            .arg("HEAD")
+            .arg(format!("refs/heads/{default_branch}")),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("update-server-info"),
+    )?;
     Ok(())
 }
 
@@ -72,39 +84,53 @@ pub fn create_initial_commit_with_binary(
 
     fs::write(worktree.join("README.md"), readme)?;
 
-    run(Command::new(git_binary)
-        .arg("init")
-        .arg("--initial-branch")
-        .arg(default_branch)
-        .arg(&worktree))?;
-    run(Command::new(git_binary)
-        .current_dir(&worktree)
-        .env("GIT_AUTHOR_NAME", author_name)
-        .env("GIT_AUTHOR_EMAIL", author_email)
-        .env("GIT_COMMITTER_NAME", author_name)
-        .env("GIT_COMMITTER_EMAIL", author_email)
-        .arg("add")
-        .arg("README.md"))?;
-    run(Command::new(git_binary)
-        .current_dir(&worktree)
-        .env("GIT_AUTHOR_NAME", author_name)
-        .env("GIT_AUTHOR_EMAIL", author_email)
-        .env("GIT_COMMITTER_NAME", author_name)
-        .env("GIT_COMMITTER_EMAIL", author_email)
-        .arg("commit")
-        .arg("-m")
-        .arg("Initial commit"))?;
-    run(Command::new(git_binary)
-        .current_dir(&worktree)
-        .arg("remote")
-        .arg("add")
-        .arg("origin")
-        .arg(repo_path))?;
-    run(Command::new(git_binary)
-        .current_dir(&worktree)
-        .arg("push")
-        .arg("origin")
-        .arg(default_branch))?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("init")
+            .arg("--initial-branch")
+            .arg(default_branch)
+            .arg("--")
+            .arg(&worktree),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .current_dir(&worktree)
+            .env("GIT_AUTHOR_NAME", author_name)
+            .env("GIT_AUTHOR_EMAIL", author_email)
+            .env("GIT_COMMITTER_NAME", author_name)
+            .env("GIT_COMMITTER_EMAIL", author_email)
+            .arg("add")
+            .arg("--")
+            .arg("README.md"),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .current_dir(&worktree)
+            .env("GIT_AUTHOR_NAME", author_name)
+            .env("GIT_AUTHOR_EMAIL", author_email)
+            .env("GIT_COMMITTER_NAME", author_name)
+            .env("GIT_COMMITTER_EMAIL", author_email)
+            .arg("commit")
+            .arg("-m")
+            .arg("Initial commit"),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .current_dir(&worktree)
+            .arg("remote")
+            .arg("add")
+            .arg("origin")
+            .arg("--")
+            .arg(repo_path),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .current_dir(&worktree)
+            .arg("push")
+            .arg("origin")
+            .arg("--")
+            .arg(default_branch),
+    )?;
 
     fs::remove_dir_all(&worktree)?;
     Ok(())
@@ -119,27 +145,32 @@ pub fn clone_bare_repo_with_binary(
         fs::create_dir_all(parent)?;
     }
 
-    run(Command::new(git_binary)
-        .arg("clone")
-        .arg("--bare")
-        .arg(source_repo_path)
-        .arg(target_repo_path))?;
-    run(Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(target_repo_path)
-        .arg("update-server-info"))?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("clone")
+            .arg("--bare")
+            .arg("--")
+            .arg(source_repo_path)
+            .arg(target_repo_path),
+    )?;
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(target_repo_path)
+            .arg("update-server-info"),
+    )?;
     Ok(())
 }
 
 pub fn list_branches_with_binary(git_binary: &str, repo_path: &Path) -> AppResult<Vec<String>> {
-    let output = Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("for-each-ref")
-        .arg("--format=%(refname:short)")
-        .arg("refs/heads")
-        .output()
-        .map_err(|err| AppError::Git(err.to_string()))?;
+    let output = output_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("for-each-ref")
+            .arg("--format=%(refname:short)")
+            .arg("refs/heads"),
+    )?;
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
         return Err(AppError::Git(stderr));
@@ -170,47 +201,50 @@ pub fn merge_base_with_binary(
     head_repo_path: &Path,
     head_branch: &str,
 ) -> AppResult<String> {
-    let temp = std::env::temp_dir().join(format!(
-        "gitr-merge-base-{}",
-        std::time::SystemTime::now()
-            .duration_since(std::time::UNIX_EPOCH)
-            .unwrap_or_default()
-            .as_nanos()
-    ));
-    run(Command::new(git_binary)
-        .arg("clone")
-        .arg("--bare")
-        .arg(base_repo_path)
-        .arg(&temp))?;
+    let temp = tempfile::tempdir().map_err(|e| AppError::Io(e))?;
+    let temp_path = temp.path();
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("clone")
+            .arg("--bare")
+            .arg("--")
+            .arg(base_repo_path)
+            .arg(&temp_path),
+    )?;
     let result = (|| -> AppResult<String> {
-        run(Command::new(git_binary)
-            .arg("--git-dir")
-            .arg(&temp)
-            .arg("remote")
-            .arg("add")
-            .arg("head_repo")
-            .arg(head_repo_path))?;
-        run(Command::new(git_binary)
-            .arg("--git-dir")
-            .arg(&temp)
-            .arg("fetch")
-            .arg("head_repo")
-            .arg(head_branch))?;
-        let output = Command::new(git_binary)
-            .arg("--git-dir")
-            .arg(&temp)
-            .arg("merge-base")
-            .arg(format!("refs/heads/{base_branch}"))
-            .arg("FETCH_HEAD")
-            .output()
-            .map_err(|err| AppError::Git(err.to_string()))?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .arg("--git-dir")
+                .arg(&temp_path)
+                .arg("remote")
+                .arg("add")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_repo_path),
+        )?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .arg("--git-dir")
+                .arg(&temp_path)
+                .arg("fetch")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_branch),
+        )?;
+        let output = output_with_timeout(
+            Command::new(git_binary)
+                .arg("--git-dir")
+                .arg(&temp_path)
+                .arg("merge-base")
+                .arg(format!("refs/heads/{base_branch}"))
+                .arg("FETCH_HEAD"),
+        )?;
         if !output.status.success() {
             let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
             return Err(AppError::Git(stderr));
         }
         Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
     })();
-    let _ = fs::remove_dir_all(&temp);
     result
 }
 
@@ -221,37 +255,47 @@ pub fn test_merge_with_binary(
     head_repo_path: &Path,
     head_branch: &str,
 ) -> AppResult<bool> {
-    let temp = temp_git_path("gitr-test-merge");
-    run(Command::new(git_binary)
-        .arg("clone")
-        .arg("-b")
-        .arg(base_branch)
-        .arg(base_repo_path)
-        .arg(&temp))?;
+    let temp = tempfile::tempdir().map_err(|e| AppError::Io(e))?;
+    let temp_path = temp.path();
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("clone")
+            .arg("-b")
+            .arg(base_branch)
+            .arg("--")
+            .arg(base_repo_path)
+            .arg(&temp_path),
+    )?;
     let result = (|| -> AppResult<bool> {
-    run(Command::new(git_binary)
-        .current_dir(&temp)
-        .arg("remote")
-            .arg("add")
-            .arg("head_repo")
-            .arg(head_repo_path))?;
-        run(Command::new(git_binary)
-            .current_dir(&temp)
-            .arg("fetch")
-            .arg("head_repo")
-            .arg(head_branch))?;
-
-        let output = Command::new(git_binary)
-            .current_dir(&temp)
-            .arg("merge")
-            .arg("--no-ff")
-            .arg("--no-commit")
-            .arg("FETCH_HEAD")
-            .output()
-            .map_err(|err| AppError::Git(err.to_string()))?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("remote")
+                .arg("add")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_repo_path),
+        )?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("fetch")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_branch),
+        )?;
+
+        let output = output_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("merge")
+                .arg("--no-ff")
+                .arg("--no-commit")
+                .arg("--")
+                .arg("FETCH_HEAD"),
+        )?;
         Ok(output.status.success())
     })();
-    let _ = fs::remove_dir_all(&temp);
     result
 }
 
@@ -265,35 +309,46 @@ pub fn merge_pull_request_with_binary(
     author_email: &str,
     message: &str,
 ) -> AppResult<String> {
-    let temp = temp_git_path("gitr-merge-pr");
-    run(Command::new(git_binary)
-        .arg("clone")
-        .arg("-b")
-        .arg(base_branch)
-        .arg(base_repo_path)
-        .arg(&temp))?;
+    let temp = tempfile::tempdir().map_err(|e| AppError::Io(e))?;
+    let temp_path = temp.path();
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("clone")
+            .arg("-b")
+            .arg(base_branch)
+            .arg("--")
+            .arg(base_repo_path)
+            .arg(&temp_path),
+    )?;
     let result = (|| -> AppResult<String> {
-        run(Command::new(git_binary)
-            .current_dir(&temp)
-            .arg("remote")
-            .arg("add")
-            .arg("head_repo")
-            .arg(head_repo_path))?;
-        run(Command::new(git_binary)
-            .current_dir(&temp)
-            .arg("fetch")
-            .arg("head_repo")
-            .arg(head_branch))?;
-        let head_commit_id = rev_parse_with_binary(git_binary, &temp.join(".git"), "FETCH_HEAD")?;
-
-        let merge = Command::new(git_binary)
-            .current_dir(&temp)
-            .arg("merge")
-            .arg("--no-ff")
-            .arg("--no-commit")
-            .arg("FETCH_HEAD")
-            .output()
-            .map_err(|err| AppError::Git(err.to_string()))?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("remote")
+                .arg("add")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_repo_path),
+        )?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("fetch")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_branch),
+        )?;
+        let head_commit_id = rev_parse_with_binary(git_binary, &temp_path.join(".git"), "FETCH_HEAD")?;
+
+        let merge = output_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("merge")
+                .arg("--no-ff")
+                .arg("--no-commit")
+                .arg("--")
+                .arg("FETCH_HEAD"),
+        )?;
         if !merge.status.success() {
             let stderr = String::from_utf8_lossy(&merge.stderr).trim().to_string();
             let stdout = String::from_utf8_lossy(&merge.stdout).trim().to_string();
@@ -303,27 +358,33 @@ pub fn merge_pull_request_with_binary(
             )));
         }
 
-        run(Command::new(git_binary)
-            .current_dir(&temp)
-            .env("GIT_AUTHOR_NAME", author_name)
-            .env("GIT_AUTHOR_EMAIL", author_email)
-            .env("GIT_COMMITTER_NAME", author_name)
-            .env("GIT_COMMITTER_EMAIL", author_email)
-            .arg("commit")
-            .arg("-m")
-            .arg(message))?;
-        run(Command::new(git_binary)
-            .current_dir(&temp)
-            .arg("push")
-            .arg("origin")
-            .arg(base_branch))?;
-        run(Command::new(git_binary)
-            .arg("--git-dir")
-            .arg(base_repo_path)
-            .arg("update-server-info"))?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .env("GIT_AUTHOR_NAME", author_name)
+                .env("GIT_AUTHOR_EMAIL", author_email)
+                .env("GIT_COMMITTER_NAME", author_name)
+                .env("GIT_COMMITTER_EMAIL", author_email)
+                .arg("commit")
+                .arg("-m")
+                .arg(message),
+        )?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .current_dir(&temp_path)
+                .arg("push")
+                .arg("origin")
+                .arg("--")
+                .arg(base_branch),
+        )?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .arg("--git-dir")
+                .arg(base_repo_path)
+                .arg("update-server-info"),
+        )?;
         Ok(head_commit_id)
     })();
-    let _ = fs::remove_dir_all(&temp);
     result
 }
 
@@ -334,32 +395,41 @@ pub fn compare_refs_with_binary(
     head_repo_path: &Path,
     head_branch: &str,
 ) -> AppResult<CompareResult> {
-    let temp = temp_git_path("gitr-compare-refs");
-    run(Command::new(git_binary)
-        .arg("clone")
-        .arg("--bare")
-        .arg(base_repo_path)
-        .arg(&temp))?;
+    let temp = tempfile::tempdir().map_err(|e| AppError::Io(e))?;
+    let temp_path = temp.path();
+    run_with_timeout(
+        Command::new(git_binary)
+            .arg("clone")
+            .arg("--bare")
+            .arg("--")
+            .arg(base_repo_path)
+            .arg(&temp_path),
+    )?;
     let result = (|| -> AppResult<CompareResult> {
-        run(Command::new(git_binary)
-            .arg("--git-dir")
-            .arg(&temp)
-            .arg("remote")
-            .arg("add")
-            .arg("head_repo")
-            .arg(head_repo_path))?;
-        run(Command::new(git_binary)
-            .arg("--git-dir")
-            .arg(&temp)
-            .arg("fetch")
-            .arg("head_repo")
-            .arg(head_branch))?;
-
-        let merge_base = merge_base_for_repo(git_binary, &temp, base_branch, "FETCH_HEAD")?;
-        let head_commit_id = rev_parse_with_binary(git_binary, &temp, "FETCH_HEAD")?;
-        compare_rev_range_with_binary(git_binary, &temp, &merge_base, &head_commit_id)
+        run_with_timeout(
+            Command::new(git_binary)
+                .arg("--git-dir")
+                .arg(&temp_path)
+                .arg("remote")
+                .arg("add")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_repo_path),
+        )?;
+        run_with_timeout(
+            Command::new(git_binary)
+                .arg("--git-dir")
+                .arg(&temp_path)
+                .arg("fetch")
+                .arg("head_repo")
+                .arg("--")
+                .arg(head_branch),
+        )?;
+
+        let merge_base = merge_base_for_repo(git_binary, &temp_path, base_branch, "FETCH_HEAD")?;
+        let head_commit_id = rev_parse_with_binary(git_binary, &temp_path, "FETCH_HEAD")?;
+        compare_rev_range_with_binary(git_binary, &temp_path, &merge_base, &head_commit_id)
     })();
-    let _ = fs::remove_dir_all(&temp);
     result
 }
 
@@ -385,13 +455,13 @@ pub fn compare_rev_range_with_binary(
 }
 
 pub fn rev_parse_with_binary(git_binary: &str, repo_path: &Path, rev: &str) -> AppResult<String> {
-    let output = Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("rev-parse")
-        .arg(rev)
-        .output()
-        .map_err(|err| AppError::Git(err.to_string()))?;
+    let output = output_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("rev-parse")
+            .arg(rev),
+    )?;
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
         return Err(AppError::Git(stderr));
@@ -399,10 +469,8 @@ pub fn rev_parse_with_binary(git_binary: &str, repo_path: &Path, rev: &str) -> A
     Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
 }
 
-fn run(command: &mut Command) -> AppResult<()> {
-    let output = command
-        .output()
-        .map_err(|err| AppError::Git(err.to_string()))?;
+fn run_with_timeout(command: &mut Command) -> AppResult<()> {
+    let output = output_with_timeout(command)?;
     if output.status.success() {
         return Ok(());
     }
@@ -413,14 +481,40 @@ fn run(command: &mut Command) -> AppResult<()> {
     Err(AppError::Git(message))
 }
 
-fn temp_git_path(prefix: &str) -> std::path::PathBuf {
-    std::env::temp_dir().join(format!(
-        "{prefix}-{}",
-        std::time::SystemTime::now()
-            .duration_since(std::time::UNIX_EPOCH)
-            .unwrap_or_default()
-            .as_nanos()
-    ))
+fn output_with_timeout(command: &mut Command) -> AppResult<std::process::Output> {
+    let mut child = command
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .map_err(|err| AppError::Git(err.to_string()))?;
+
+    let status = child
+        .wait_timeout(Duration::from_secs(GIT_TIMEOUT_SECS))
+        .map_err(|err| AppError::Git(err.to_string()))?;
+
+    if status.is_none() {
+        let _ = child.kill();
+        return Err(AppError::Git("git command timed out".to_string()));
+    }
+
+    let mut output = std::process::Output {
+        status: status.unwrap(),
+        stdout: Vec::new(),
+        stderr: Vec::new(),
+    };
+
+    if let Some(mut stdout) = child.stdout.take() {
+        stdout
+            .read_to_end(&mut output.stdout)
+            .map_err(|err| AppError::Git(err.to_string()))?;
+    }
+    if let Some(mut stderr) = child.stderr.take() {
+        stderr
+            .read_to_end(&mut output.stderr)
+            .map_err(|err| AppError::Git(err.to_string()))?;
+    }
+
+    Ok(output)
 }
 
 fn merge_base_for_repo(
@@ -429,14 +523,14 @@ fn merge_base_for_repo(
     base_branch: &str,
     head_rev: &str,
 ) -> AppResult<String> {
-    let output = Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("merge-base")
-        .arg(format!("refs/heads/{base_branch}"))
-        .arg(head_rev)
-        .output()
-        .map_err(|err| AppError::Git(err.to_string()))?;
+    let output = output_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("merge-base")
+            .arg(format!("refs/heads/{base_branch}"))
+            .arg(head_rev),
+    )?;
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
         return Err(AppError::Git(stderr));
@@ -454,15 +548,15 @@ fn list_commits_between_with_binary(
         return Ok(Vec::new());
     }
 
-    let output = Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("log")
-        .arg("--reverse")
-        .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1e")
-        .arg(format!("{merge_base}..{head_commit_id}"))
-        .output()
-        .map_err(|err| AppError::Git(err.to_string()))?;
+    let output = output_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("log")
+            .arg("--reverse")
+            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1e")
+            .arg(format!("{merge_base}..{head_commit_id}")),
+    )?;
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
         return Err(AppError::Git(stderr));
@@ -496,15 +590,15 @@ fn diff_numstat_with_binary(
     merge_base: &str,
     head_commit_id: &str,
 ) -> AppResult<Vec<CompareFile>> {
-    let output = Command::new(git_binary)
-        .arg("--git-dir")
-        .arg(repo_path)
-        .arg("diff")
-        .arg("--numstat")
-        .arg(merge_base)
-        .arg(head_commit_id)
-        .output()
-        .map_err(|err| AppError::Git(err.to_string()))?;
+    let output = output_with_timeout(
+        Command::new(git_binary)
+            .arg("--git-dir")
+            .arg(repo_path)
+            .arg("diff")
+            .arg("--numstat")
+            .arg(merge_base)
+            .arg(head_commit_id),
+    )?;
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
         return Err(AppError::Git(stderr));
@@ -577,15 +671,35 @@ pub fn run_git_http_backend(req: GitHttpBackendRequest<'_>) -> AppResult<GitHttp
         stdin.write_all(req.body)?;
     }
 
-    let output = child
-        .wait_with_output()
+    let status = child
+        .wait_timeout(Duration::from_secs(GIT_TIMEOUT_SECS))
         .map_err(|err| AppError::Git(err.to_string()))?;
-    if !output.status.success() {
-        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
-        return Err(AppError::Git(stderr));
+
+    if status.is_none() {
+        let _ = child.kill();
+        return Err(AppError::Git("git-http-backend timed out".to_string()));
+    }
+
+    let mut stdout = Vec::new();
+    if let Some(mut out) = child.stdout.take() {
+        out.read_to_end(&mut stdout)
+            .map_err(|err| AppError::Git(err.to_string()))?;
+    }
+
+    let mut stderr = Vec::new();
+    if let Some(mut err) = child.stderr.take() {
+        err.read_to_end(&mut stderr)
+            .map_err(|err| AppError::Git(err.to_string()))?;
+    }
+
+    if !stderr.is_empty() {
+        let err_text = String::from_utf8_lossy(&stderr).trim().to_string();
+        if !err_text.is_empty() {
+            return Err(AppError::Git(err_text));
+        }
     }
 
-    parse_git_http_backend_output(&output.stdout)
+    parse_git_http_backend_output(&stdout)
 }
 
 fn parse_git_http_backend_output(stdout: &[u8]) -> AppResult<GitHttpBackendResponse> {

+ 503 - 267
src/http.rs

@@ -2,7 +2,7 @@ use std::sync::Arc;
 
 use actix_web::{
     HttpRequest, HttpResponse, Scope, delete, get, guard, post,
-    web::{Bytes, Data, Json, Path, route},
+    web::{Bytes, Data, Json, Path, Query, block, route},
 };
 use serde::Serialize;
 
@@ -67,20 +67,30 @@ async fn create_user(
     request: HttpRequest,
     req: Json<CreateUserRequest>,
 ) -> AppResult<Json<ApiUser>> {
+    let state = state.get_ref().clone();
     let mut req = req.into_inner();
-    if service::should_allow_bootstrap_admin(state.get_ref().as_ref())? {
-        req.is_admin = true;
-    } else {
-        let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
-        if !acting_user.is_admin {
-            return Err(AppError::Forbidden(
-                "only site admin can create users".to_string(),
-            ));
-        }
-    }
+    let token = extract_bearer_token(&request);
 
-    let user = service::create_user(state.get_ref().as_ref(), req)?;
-    Ok(Json(ApiUser::from(&user)))
+    let user = block(move || {
+        if service::should_allow_bootstrap_admin(state.as_ref())? {
+            req.is_admin = true;
+        } else {
+            let token = token.ok_or_else(|| {
+                AppError::Unauthorized("missing authorization header".to_string())
+            })?;
+            let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+            if !acting_user.is_admin {
+                return Err(AppError::Forbidden(
+                    "only site admin can create users".to_string(),
+                ));
+            }
+        }
+        let user = service::create_user(state.as_ref(), req)?;
+        Ok(ApiUser::from(&user))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(user))
 }
 
 #[post("/api/user/login")]
@@ -88,7 +98,15 @@ async fn login(
     state: Data<Arc<AppState>>,
     req: Json<LoginRequest>,
 ) -> AppResult<Json<ApiLoginResponse>> {
-    let login = service::login(state.get_ref().as_ref(), req.into_inner())?;
+    let state = state.get_ref().clone();
+    let req = req.into_inner();
+    let client_key = req.login.clone();
+
+    state.login_rate_limiter.check(&format!("login:{client_key}"))?;
+
+    let login = block(move || service::login(state.as_ref(), req))
+        .await
+        .map_err(blocking_error)??;
     Ok(Json(ApiLoginResponse::from(&login)))
 }
 
@@ -98,8 +116,19 @@ async fn create_access_token(
     request: HttpRequest,
     req: Json<CreateAccessTokenRequest>,
 ) -> AppResult<Json<CreateAccessTokenResponse>> {
-    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
-    let token = service::issue_access_token(state.get_ref().as_ref(), user.id, req.into_inner())?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let req = req.into_inner();
+
+    let token = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let user = service::authenticate_token(state.as_ref(), &token)?;
+        service::issue_access_token(state.as_ref(), user.id, req)
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(Json(token))
 }
 
@@ -107,9 +136,22 @@ async fn create_access_token(
 async fn list_access_tokens(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
+    query: Query<crate::models::PaginationQuery>,
 ) -> AppResult<Json<Vec<AccessTokenResponse>>> {
-    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
-    let tokens = service::list_access_tokens(state.get_ref().as_ref(), user.id)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let page = query.page;
+    let per_page = query.per_page;
+
+    let tokens = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let user = service::authenticate_token(state.as_ref(), &token)?;
+        service::list_access_tokens(state.as_ref(), user.id, page, per_page)
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(Json(tokens))
 }
 
@@ -119,8 +161,19 @@ async fn delete_access_token(
     request: HttpRequest,
     token_id: Path<i64>,
 ) -> AppResult<HttpResponse> {
-    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
-    service::delete_access_token(state.get_ref().as_ref(), user.id, token_id.into_inner())?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let token_id = token_id.into_inner();
+
+    block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let user = service::authenticate_token(state.as_ref(), &token)?;
+        service::delete_access_token(state.as_ref(), user.id, token_id)
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(HttpResponse::NoContent().finish())
 }
 
@@ -130,9 +183,18 @@ async fn get_user(
     request: HttpRequest,
     username: Path<String>,
 ) -> AppResult<Json<ApiUser>> {
-    let user = service::get_user(state.get_ref().as_ref(), &username.into_inner())?;
-    let mut api_user = ApiUser::from(&user);
-    if authenticate_request(state.get_ref().as_ref(), &request).is_err() {
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let username = username.into_inner();
+
+    let mut api_user = block(move || {
+        let user = service::get_user(state.as_ref(), &username)?;
+        Ok::<_, AppError>(ApiUser::from(&user))
+    })
+    .await
+    .map_err(blocking_error)??;
+
+    if token.is_none() {
         api_user.email.clear();
     }
     Ok(Json(api_user))
@@ -144,13 +206,21 @@ async fn create_repo(
     request: HttpRequest,
     req: Json<CreateRepositoryRequest>,
 ) -> AppResult<Json<ApiRepositoryResponse>> {
-    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
-    let repo = service::create_repository(state.get_ref().as_ref(), &user, req.into_inner())?;
-    Ok(Json(api_repository_response(
-        state.get_ref().as_ref(),
-        Some(&user),
-        &repo,
-    )?))
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let req = req.into_inner();
+
+    let repo = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let user = service::authenticate_token(state.as_ref(), &token)?;
+        let repo = service::create_repository(state.as_ref(), &user, req)?;
+        api_repository_response(state.as_ref(), Some(&user), &repo)
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(repo))
 }
 
 #[post("/api/repos/{owner}/{repo}/forks")]
@@ -160,20 +230,22 @@ async fn fork_repo(
     path: Path<(String, String)>,
     req: Json<ForkRepositoryRequest>,
 ) -> AppResult<Json<ApiRepositoryResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let fork = service::fork_repository(
-        state.get_ref().as_ref(),
-        &acting_user,
-        &owner,
-        &repo,
-        req.into_inner(),
-    )?;
-    Ok(Json(api_repository_response(
-        state.get_ref().as_ref(),
-        Some(&acting_user),
-        &fork,
-    )?))
+    let req = req.into_inner();
+
+    let repo = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        let fork = service::fork_repository(state.as_ref(), &acting_user, &owner, &repo, req)?;
+        api_repository_response(state.as_ref(), Some(&acting_user), &fork)
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(repo))
 }
 
 #[get("/api/repos/{owner}/{repo}/branches")]
@@ -182,10 +254,18 @@ async fn list_branches(
     request: HttpRequest,
     path: Path<(String, String)>,
 ) -> AppResult<Json<Vec<Branch>>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let branches =
-        service::list_branches(state.get_ref().as_ref(), maybe_user.as_ref(), &owner, &repo)?;
+
+    let branches = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        service::list_branches(state.as_ref(), maybe_user.as_ref(), &owner, &repo)
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(Json(branches))
 }
 
@@ -194,17 +274,22 @@ async fn compare_repositories(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
     path: Path<(String, String)>,
-    query: actix_web::web::Query<CompareRequest>,
+    query: Query<CompareRequest>,
 ) -> AppResult<Json<CompareResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let compare = service::compare_repositories(
-        state.get_ref().as_ref(),
-        &acting_user,
-        &owner,
-        &repo,
-        query.into_inner(),
-    )?;
+    let req = query.into_inner();
+
+    let compare = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        service::compare_repositories(state.as_ref(), &acting_user, &owner, &repo, req)
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(Json(compare))
 }
 
@@ -215,16 +300,28 @@ async fn create_pull_request(
     path: Path<(String, String)>,
     req: Json<CreatePullRequestRequest>,
 ) -> AppResult<Json<ApiPullRequestResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let pull_request = service::create_pull_request(
-        state.get_ref().as_ref(),
-        &acting_user,
-        &owner,
-        &repo,
-        req.into_inner(),
-    )?;
-    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+    let req = req.into_inner();
+
+    let pull_request = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        let pull_request = service::create_pull_request(
+            state.as_ref(),
+            &acting_user,
+            &owner,
+            &repo,
+            req,
+        )?;
+        Ok::<_, AppError>(ApiPullRequestResponse::from(&pull_request))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(pull_request))
 }
 
 #[get("/api/repos/{owner}/{repo}/pulls")]
@@ -232,17 +329,28 @@ async fn list_pull_requests(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
     path: Path<(String, String)>,
+    query: Query<crate::models::PaginationQuery>,
 ) -> AppResult<Json<Vec<ApiPullRequestResponse>>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let pulls =
-        service::list_pull_requests(state.get_ref().as_ref(), maybe_user.as_ref(), &owner, &repo)?;
-    Ok(Json(
-        pulls
+    let page = query.page;
+    let per_page = query.per_page;
+
+    let pulls = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        let pulls =
+            service::list_pull_requests(state.as_ref(), maybe_user.as_ref(), &owner, &repo, page, per_page)?;
+        Ok::<_, AppError>(pulls
             .iter()
             .map(ApiPullRequestResponse::from)
-            .collect::<Vec<_>>(),
-    ))
+            .collect::<Vec<_>>())
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(pulls))
 }
 
 #[get("/api/repos/{owner}/{repo}/pulls/{index}")]
@@ -251,15 +359,24 @@ async fn get_pull_request(
     request: HttpRequest,
     path: Path<(String, String, i64)>,
 ) -> AppResult<Json<ApiPullRequestDetailResponse>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo, index) = path.into_inner();
-    let pull_request = service::get_pull_request_detail(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &owner,
-        &repo,
-        index,
-    )?;
+
+    let pull_request = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        service::get_pull_request_detail(
+            state.as_ref(),
+            maybe_user.as_ref(),
+            &owner,
+            &repo,
+            index,
+        )
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(Json(ApiPullRequestDetailResponse::from(&pull_request)))
 }
 
@@ -270,17 +387,29 @@ async fn merge_pull_request(
     path: Path<(String, String, i64)>,
     req: Json<MergePullRequestRequest>,
 ) -> AppResult<Json<ApiPullRequestResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo, index) = path.into_inner();
-    let pull_request = service::merge_pull_request(
-        state.get_ref().as_ref(),
-        &acting_user,
-        &owner,
-        &repo,
-        index,
-        req.into_inner(),
-    )?;
-    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+    let req = req.into_inner();
+
+    let pull_request = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        let pull_request = service::merge_pull_request(
+            state.as_ref(),
+            &acting_user,
+            &owner,
+            &repo,
+            index,
+            req,
+        )?;
+        Ok::<_, AppError>(ApiPullRequestResponse::from(&pull_request))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(pull_request))
 }
 
 #[post("/api/repos/{owner}/{repo}/pulls/{index}/close")]
@@ -289,11 +418,22 @@ async fn close_pull_request(
     request: HttpRequest,
     path: Path<(String, String, i64)>,
 ) -> AppResult<Json<ApiPullRequestResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo, index) = path.into_inner();
-    let pull_request =
-        service::close_pull_request(state.get_ref().as_ref(), &acting_user, &owner, &repo, index)?;
-    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+
+    let pull_request = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        let pull_request =
+            service::close_pull_request(state.as_ref(), &acting_user, &owner, &repo, index)?;
+        Ok::<_, AppError>(ApiPullRequestResponse::from(&pull_request))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(pull_request))
 }
 
 #[post("/api/repos/{owner}/{repo}/pulls/{index}/reopen")]
@@ -302,11 +442,22 @@ async fn reopen_pull_request(
     request: HttpRequest,
     path: Path<(String, String, i64)>,
 ) -> AppResult<Json<ApiPullRequestResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo, index) = path.into_inner();
-    let pull_request =
-        service::reopen_pull_request(state.get_ref().as_ref(), &acting_user, &owner, &repo, index)?;
-    Ok(Json(ApiPullRequestResponse::from(&pull_request)))
+
+    let pull_request = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        let pull_request =
+            service::reopen_pull_request(state.as_ref(), &acting_user, &owner, &repo, index)?;
+        Ok::<_, AppError>(ApiPullRequestResponse::from(&pull_request))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(pull_request))
 }
 
 #[post("/api/repos/{owner}/{repo}/collaborators")]
@@ -316,16 +467,28 @@ async fn upsert_collaborator(
     path: Path<(String, String)>,
     req: Json<UpsertCollaboratorRequest>,
 ) -> AppResult<Json<ApiCollaboratorResponse>> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let collaborator = service::upsert_collaborator(
-        state.get_ref().as_ref(),
-        &acting_user,
-        &owner,
-        &repo,
-        req.into_inner(),
-    )?;
-    Ok(Json(ApiCollaboratorResponse::from(&collaborator)))
+    let req = req.into_inner();
+
+    let collaborator = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        let collaborator = service::upsert_collaborator(
+            state.as_ref(),
+            &acting_user,
+            &owner,
+            &repo,
+            req,
+        )?;
+        Ok::<_, AppError>(ApiCollaboratorResponse::from(&collaborator))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(collaborator))
 }
 
 #[actix_web::delete("/api/repos/{owner}/{repo}/collaborators/{username}")]
@@ -334,15 +497,25 @@ async fn delete_collaborator(
     request: HttpRequest,
     path: Path<(String, String, String)>,
 ) -> AppResult<HttpResponse> {
-    let acting_user = authenticate_request(state.get_ref().as_ref(), &request)?;
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo, username) = path.into_inner();
-    service::remove_collaborator(
-        state.get_ref().as_ref(),
-        &acting_user,
-        &owner,
-        &repo,
-        &username,
-    )?;
+
+    block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let acting_user = service::authenticate_token(state.as_ref(), &token)?;
+        service::remove_collaborator(
+            state.as_ref(),
+            &acting_user,
+            &owner,
+            &repo,
+            &username,
+        )
+    })
+    .await
+    .map_err(blocking_error)??;
     Ok(HttpResponse::NoContent().finish())
 }
 
@@ -352,75 +525,99 @@ async fn git_http(
     path: Path<(String, String, String)>,
     body: Bytes,
 ) -> AppResult<HttpResponse> {
+    let state = state.get_ref().clone();
     let (owner_name, repo_name, tail) = path.into_inner();
-    let repo = service::get_repository(state.get_ref().as_ref(), &owner_name, &repo_name)?;
-
     let query = request.query_string().to_string();
     let service_name = request
         .query_string()
         .split('&')
         .find_map(|pair| pair.strip_prefix("service="))
-        .unwrap_or_default();
-    let is_pull = service_name == "git-upload-pack"
-        || tail.ends_with("git-upload-pack")
-        || (request.method() == actix_web::http::Method::GET
-            && service_name != "git-receive-pack"
-            && !tail.ends_with("git-receive-pack"));
-
-    let auth_user = if !repo.repo.is_private && is_pull {
-        None
-    } else {
-        Some(authenticate_git_request(
-            state.get_ref().as_ref(),
-            &request,
-        )?)
-    };
-
-    if let Some(user) = &auth_user {
-        let desired = if is_pull {
-            AccessMode::Read
+        .unwrap_or_default()
+        .to_string();
+    let method = request.method().as_str().to_string();
+    let content_type = request
+        .headers()
+        .get("content-type")
+        .and_then(|v| v.to_str().ok())
+        .map(|s| s.to_string());
+    let basic_auth = extract_basic_auth(&request);
+    let bearer_token = extract_bearer_token(&request);
+    let body = body.to_vec();
+
+    let backend = block(move || {
+        let repo = service::get_repository(state.as_ref(), &owner_name, &repo_name)?;
+
+        let is_pull = service_name == "git-upload-pack"
+            || tail.ends_with("git-upload-pack")
+            || (method == "GET"
+                && service_name != "git-receive-pack"
+                && !tail.ends_with("git-receive-pack"));
+
+        let auth_user = if !repo.repo.is_private && is_pull {
+            None
         } else {
-            AccessMode::Write
+            Some(if let Some(basic) = basic_auth {
+                let decoded = decode_basic_auth(&basic)?;
+                let (auth_login, secret) = decoded.split_once(':').ok_or_else(|| {
+                    AppError::Unauthorized("invalid basic auth payload".to_string())
+                })?;
+                service::authenticate_http_basic(state.as_ref(), auth_login, secret)?
+            } else if let Some(bearer) = bearer_token {
+                service::authenticate_token(state.as_ref(), &bearer)?
+            } else {
+                return Err(AppError::Unauthorized(
+                    "repository access denied".to_string(),
+                ));
+            })
         };
-        if !state.db.authorize(
-            user.id,
-            repo.repo.id,
-            desired,
-            repo.owner.id,
-            repo.repo.is_private,
-        )? {
-            return Err(AppError::Forbidden("repository access denied".to_string()));
+
+        if let Some(user) = &auth_user {
+            let desired = if is_pull {
+                AccessMode::Read
+            } else {
+                AccessMode::Write
+            };
+            if !state.db.authorize(
+                user.id,
+                repo.repo.id,
+                desired,
+                repo.owner.id,
+                repo.repo.is_private,
+            )? {
+                return Err(AppError::Forbidden("repository access denied".to_string()));
+            }
+        } else if repo.repo.is_private || !is_pull {
+            return Err(AppError::Unauthorized(
+                "repository access denied".to_string(),
+            ));
         }
-    } else if repo.repo.is_private || !is_pull {
-        return Err(AppError::Unauthorized(
-            "repository access denied".to_string(),
-        ));
-    }
 
-    let repo_path = crate::repox::repository_path(
-        &state.config.repository.root,
-        &repo.owner.name,
-        &repo.repo.name,
-    );
-    let path_info = format!("/{owner_name}/{repo_name}.git/{tail}");
-    let backend = crate::git::run_git_http_backend(crate::git::GitHttpBackendRequest {
-        git_binary: &state.config.repository.git_binary,
-        project_root: &state.config.repository.root,
-        path_info: &path_info,
-        method: request.method().as_str(),
-        query_string: &query,
-        content_type: request
-            .headers()
-            .get("content-type")
-            .and_then(|v| v.to_str().ok()),
-        remote_user: auth_user.as_ref().map(|u| u.name.as_str()),
-        body: body.as_ref(),
-    })?;
-    if !repo_path.exists() {
-        return Err(AppError::NotFound(format!(
-            "repository not found: {owner_name}/{repo_name}"
-        )));
-    }
+        let repo_path = crate::repox::repository_path(
+            &state.config.repository.root,
+            &repo.owner.name,
+            &repo.repo.name,
+        );
+        if !repo_path.exists() {
+            return Err(AppError::NotFound(format!(
+                "repository not found: {owner_name}/{repo_name}"
+            )));
+        }
+
+        let path_info = format!("/{owner_name}/{repo_name}.git/{tail}");
+        let backend = crate::git::run_git_http_backend(crate::git::GitHttpBackendRequest {
+            git_binary: &state.config.repository.git_binary,
+            project_root: &state.config.repository.root,
+            path_info: &path_info,
+            method: &method,
+            query_string: &query,
+            content_type: content_type.as_deref(),
+            remote_user: auth_user.as_ref().map(|u| u.name.as_str()),
+            body: &body,
+        })?;
+        Ok(backend)
+    })
+    .await
+    .map_err(blocking_error)??;
 
     let status = actix_web::http::StatusCode::from_u16(backend.status_code)
         .map_err(|_| AppError::Git("invalid backend status".to_string()))?;
@@ -437,35 +634,51 @@ async fn get_repo(
     request: HttpRequest,
     path: Path<(String, String)>,
 ) -> AppResult<Json<ApiRepositoryResponse>> {
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
-    let repo = service::get_repository_for_read(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &owner,
-        &repo,
-    )?;
-    Ok(Json(api_repository_response(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &repo,
-    )?))
+
+    let repo = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        let repo = service::get_repository_for_read(
+            state.as_ref(),
+            maybe_user.as_ref(),
+            &owner,
+            &repo,
+        )?;
+        api_repository_response(state.as_ref(), maybe_user.as_ref(), &repo)
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(repo))
 }
 
 #[get("/api/user/repos")]
 async fn list_current_user_repositories(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
-    query: actix_web::web::Query<RepositoryListQuery>,
+    query: Query<RepositoryListQuery>,
 ) -> AppResult<Json<Vec<ApiRepositoryResponse>>> {
-    let user = authenticate_request(state.get_ref().as_ref(), &request)?;
-    let repos =
-        service::list_visible_repositories(state.get_ref().as_ref(), Some(&user), &query.q)?;
-    Ok(Json(api_repository_list(
-        state.get_ref().as_ref(),
-        Some(&user),
-        &repos,
-    )?))
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let q = query.q.clone();
+    let page = query.pagination.page;
+    let per_page = query.pagination.per_page;
+
+    let repos = block(move || {
+        let token = token.ok_or_else(|| {
+            AppError::Unauthorized("missing authorization header".to_string())
+        })?;
+        let user = service::authenticate_token(state.as_ref(), &token)?;
+        let repos =
+            service::list_visible_repositories(state.as_ref(), Some(&user), &q, page, per_page)?;
+        api_repository_list(state.as_ref(), Some(&user), &repos)
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(repos))
 }
 
 #[get("/api/users/{username}/repos")]
@@ -473,39 +686,57 @@ async fn list_user_repositories(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
     username: Path<String>,
-    query: actix_web::web::Query<RepositoryListQuery>,
+    query: Query<RepositoryListQuery>,
 ) -> AppResult<Json<Vec<ApiRepositoryResponse>>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
-    let repos = service::list_repositories_by_owner(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &username.into_inner(),
-        &query.q,
-    )?;
-    Ok(Json(api_repository_list(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &repos,
-    )?))
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let username = username.into_inner();
+    let q = query.q.clone();
+    let page = query.pagination.page;
+    let per_page = query.pagination.per_page;
+
+    let repos = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        let repos = service::list_repositories_by_owner(
+            state.as_ref(),
+            maybe_user.as_ref(),
+            &username,
+            &q,
+            page,
+            per_page,
+        )?;
+        api_repository_list(state.as_ref(), maybe_user.as_ref(), &repos)
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(repos))
 }
 
 #[get("/api/repos/search")]
 async fn search_repositories(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
-    query: actix_web::web::Query<RepositoryListQuery>,
+    query: Query<RepositoryListQuery>,
 ) -> AppResult<Json<Vec<ApiRepositoryResponse>>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
-    let repos = service::list_visible_repositories(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &query.q,
-    )?;
-    Ok(Json(api_repository_list(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &repos,
-    )?))
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
+    let q = query.q.clone();
+    let page = query.pagination.page;
+    let per_page = query.pagination.per_page;
+
+    let repos = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        let repos =
+            service::list_visible_repositories(state.as_ref(), maybe_user.as_ref(), &q, page, per_page)?;
+        api_repository_list(state.as_ref(), maybe_user.as_ref(), &repos)
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(repos))
 }
 
 #[get("/api/repos/{owner}/{repo}/collaborators")]
@@ -513,17 +744,28 @@ async fn list_collaborators(
     state: Data<Arc<AppState>>,
     request: HttpRequest,
     path: Path<(String, String)>,
+    query: Query<crate::models::PaginationQuery>,
 ) -> AppResult<Json<Vec<ApiCollaboratorResponse>>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo) = path.into_inner();
-    let collaborators =
-        service::list_collaborators(state.get_ref().as_ref(), maybe_user.as_ref(), &owner, &repo)?;
-    Ok(Json(
-        collaborators
+    let page = query.page;
+    let per_page = query.per_page;
+
+    let collaborators = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        let collaborators =
+            service::list_collaborators(state.as_ref(), maybe_user.as_ref(), &owner, &repo, page, per_page)?;
+        Ok::<_, AppError>(collaborators
             .iter()
             .map(ApiCollaboratorResponse::from)
-            .collect(),
-    ))
+            .collect())
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(collaborators))
 }
 
 #[get("/api/repos/{owner}/{repo}/collaborators/{username}")]
@@ -532,16 +774,26 @@ async fn get_collaborator(
     request: HttpRequest,
     path: Path<(String, String, String)>,
 ) -> AppResult<Json<ApiCollaboratorResponse>> {
-    let maybe_user = authenticate_request(state.get_ref().as_ref(), &request).ok();
+    let state = state.get_ref().clone();
+    let token = extract_bearer_token(&request);
     let (owner, repo, username) = path.into_inner();
-    let collaborator = service::get_collaborator(
-        state.get_ref().as_ref(),
-        maybe_user.as_ref(),
-        &owner,
-        &repo,
-        &username,
-    )?;
-    Ok(Json(ApiCollaboratorResponse::from(&collaborator)))
+
+    let collaborator = block(move || {
+        let maybe_user = token
+            .map(|t| service::authenticate_token(state.as_ref(), &t))
+            .transpose()?;
+        let collaborator = service::get_collaborator(
+            state.as_ref(),
+            maybe_user.as_ref(),
+            &owner,
+            &repo,
+            &username,
+        )?;
+        Ok::<_, AppError>(ApiCollaboratorResponse::from(&collaborator))
+    })
+    .await
+    .map_err(blocking_error)??;
+    Ok(Json(collaborator))
 }
 
 #[derive(Serialize)]
@@ -549,37 +801,16 @@ struct HealthResponse {
     ok: bool,
 }
 
-fn authenticate_request(state: &AppState, request: &HttpRequest) -> AppResult<User> {
-    let header = request
-        .headers()
-        .get("authorization")
-        .ok_or_else(|| AppError::Unauthorized("missing authorization header".to_string()))?;
-    let header = header
-        .to_str()
-        .map_err(|_| AppError::Unauthorized("invalid authorization header".to_string()))?;
-    let token = header
-        .strip_prefix("Bearer ")
-        .ok_or_else(|| AppError::Unauthorized("expected Bearer token".to_string()))?;
-    service::authenticate_token(state, token)
-}
-
-fn authenticate_git_request(state: &AppState, request: &HttpRequest) -> AppResult<User> {
-    let header = request
-        .headers()
-        .get("authorization")
-        .ok_or_else(|| AppError::Unauthorized("missing authorization header".to_string()))?;
-    let header = header
-        .to_str()
-        .map_err(|_| AppError::Unauthorized("invalid authorization header".to_string()))?;
-
-    let encoded = header
-        .strip_prefix("Basic ")
-        .ok_or_else(|| AppError::Unauthorized("expected Basic auth".to_string()))?;
-    let decoded = decode_basic_auth(encoded)?;
-    let (auth_login, secret) = decoded
-        .split_once(':')
-        .ok_or_else(|| AppError::Unauthorized("invalid basic auth payload".to_string()))?;
-    service::authenticate_http_basic(state, auth_login, secret)
+fn extract_bearer_token(request: &HttpRequest) -> Option<String> {
+    let header = request.headers().get("authorization")?;
+    let header = header.to_str().ok()?;
+    header.strip_prefix("Bearer ").map(|s| s.to_string())
+}
+
+fn extract_basic_auth(request: &HttpRequest) -> Option<String> {
+    let header = request.headers().get("authorization")?;
+    let header = header.to_str().ok()?;
+    header.strip_prefix("Basic ").map(|s| s.to_string())
 }
 
 fn decode_basic_auth(encoded: &str) -> AppResult<String> {
@@ -641,7 +872,12 @@ fn api_repository_list(
     requesting_user: Option<&User>,
     repos: &[crate::models::RepositoryWithOwner],
 ) -> AppResult<Vec<ApiRepositoryResponse>> {
-    repos.iter()
+    repos
+        .iter()
         .map(|repo| api_repository_response(state, requesting_user, repo))
         .collect()
 }
+
+fn blocking_error(_e: actix_web::error::BlockingError) -> AppError {
+    AppError::Pool("blocking task pool shut down".to_string())
+}

+ 19 - 0
src/models.rs

@@ -36,6 +36,7 @@ pub struct User {
     pub name: String,
     pub full_name: String,
     pub email: String,
+    #[serde(skip)]
     pub password_hash: String,
     pub is_active: bool,
     pub is_admin: bool,
@@ -256,10 +257,28 @@ pub struct CompareRequest {
     pub head_branch: String,
 }
 
+#[derive(Debug, Deserialize)]
+pub struct PaginationQuery {
+    #[serde(default = "default_page")]
+    pub page: i64,
+    #[serde(default = "default_per_page")]
+    pub per_page: i64,
+}
+
 #[derive(Debug, Deserialize)]
 pub struct RepositoryListQuery {
     #[serde(default)]
     pub q: String,
+    #[serde(flatten)]
+    pub pagination: PaginationQuery,
+}
+
+fn default_page() -> i64 {
+    1
+}
+
+fn default_per_page() -> i64 {
+    30
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]

+ 53 - 30
src/service.rs

@@ -22,6 +22,9 @@ use crate::{
     repox,
 };
 
+const TOKEN_TTL_DAYS: i64 = 365;
+const MAX_PER_PAGE: i64 = 100;
+
 pub fn create_user(state: &AppState, req: CreateUserRequest) -> AppResult<User> {
     validate_user_name(&req.username)?;
     validate_email(&req.email)?;
@@ -59,28 +62,7 @@ pub fn should_allow_bootstrap_admin(state: &AppState) -> AppResult<bool> {
 }
 
 pub fn login(state: &AppState, req: LoginRequest) -> AppResult<LoginResponse> {
-    let user = if req.login.contains('@') {
-        state
-            .db
-            .get_user_by_email(&req.login)?
-            .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
-    } else {
-        state
-            .db
-            .get_user_by_username(&req.login)?
-            .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
-    };
-
-    if !user.is_active {
-        return Err(AppError::Unauthorized("inactive user".to_string()));
-    }
-
-    let parsed_hash = PasswordHash::new(&user.password_hash)
-        .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
-    Argon2::default()
-        .verify_password(req.password.as_bytes(), &parsed_hash)
-        .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
-
+    let user = authenticate_password(state, &req.login, &req.password)?;
     let token = issue_login_access_token(state, user.id)?;
 
     Ok(LoginResponse {
@@ -129,8 +111,14 @@ pub fn issue_access_token(
     })
 }
 
-pub fn list_access_tokens(state: &AppState, user_id: i64) -> AppResult<Vec<AccessTokenResponse>> {
-    let tokens = state.db.list_access_tokens_by_user(user_id)?;
+pub fn list_access_tokens(
+    state: &AppState,
+    user_id: i64,
+    page: i64,
+    per_page: i64,
+) -> AppResult<Vec<AccessTokenResponse>> {
+    let (limit, offset) = pagination(page, per_page);
+    let tokens = state.db.list_access_tokens_by_user(user_id, limit, offset)?;
     Ok(tokens
         .into_iter()
         .map(|token| AccessTokenResponse {
@@ -157,7 +145,14 @@ pub fn authenticate_token(state: &AppState, bearer_token: &str) -> AppResult<Use
         .db
         .get_access_token_by_hash(&token_hash)?
         .ok_or_else(|| AppError::Unauthorized("invalid access token".to_string()))?;
-    let _ = state.db.touch_access_token(token.id);
+
+    if is_token_expired(&token) {
+        return Err(AppError::Unauthorized("access token expired".to_string()));
+    }
+
+    if let Err(err) = state.db.touch_access_token(token.id) {
+        eprintln!("warning: touch_access_token({}) failed: {err}", token.id);
+    }
     let user = state
         .db
         .get_user_by_id(token.user_id)?
@@ -343,10 +338,13 @@ pub fn list_collaborators(
     requesting_user: Option<&User>,
     owner_name: &str,
     repo_name: &str,
+    page: i64,
+    per_page: i64,
 ) -> AppResult<Vec<CollaboratorResponse>> {
     let repo = get_repository(state, owner_name, repo_name)?;
     ensure_repo_readable(state, requesting_user, &repo)?;
-    state.db.list_collaborators(repo.repo.id)
+    let (limit, offset) = pagination(page, per_page);
+    state.db.list_collaborators(repo.repo.id, limit, offset)
 }
 
 pub fn get_collaborator(
@@ -370,9 +368,12 @@ pub fn list_repositories_by_owner(
     requesting_user: Option<&User>,
     owner_name: &str,
     query: &str,
+    page: i64,
+    per_page: i64,
 ) -> AppResult<Vec<RepositoryWithOwner>> {
     let owner = get_user(state, owner_name)?;
-    let repos = state.db.list_repositories_with_owners_by_owner(owner.id)?;
+    let (limit, offset) = pagination(page, per_page);
+    let repos = state.db.list_repositories_with_owners_by_owner(owner.id, limit, offset)?;
     filter_visible_repositories(state, requesting_user, repos, query)
 }
 
@@ -380,8 +381,11 @@ pub fn list_visible_repositories(
     state: &AppState,
     requesting_user: Option<&User>,
     query: &str,
+    page: i64,
+    per_page: i64,
 ) -> AppResult<Vec<RepositoryWithOwner>> {
-    let repos = state.db.list_repositories_with_owners()?;
+    let (limit, offset) = pagination(page, per_page);
+    let repos = state.db.list_repositories_with_owners(limit, offset)?;
     filter_visible_repositories(state, requesting_user, repos, query)
 }
 
@@ -731,13 +735,16 @@ pub fn list_pull_requests(
     requesting_user: Option<&User>,
     owner_name: &str,
     repo_name: &str,
+    page: i64,
+    per_page: i64,
 ) -> AppResult<Vec<PullRequestResponse>> {
     let base_repo = get_repository(state, owner_name, repo_name)?;
     ensure_repo_access(state, requesting_user, &base_repo, AccessMode::Read)?;
 
+    let (limit, offset) = pagination(page, per_page);
     let pulls = state
         .db
-        .list_pull_requests_by_base_repo(base_repo.repo.id)?;
+        .list_pull_requests_by_base_repo(base_repo.repo.id, limit, offset)?;
     let mut result = Vec::with_capacity(pulls.len());
     for pull in pulls {
         result.push(build_pull_request_response(
@@ -790,7 +797,7 @@ pub fn compare_repositories(
             "head repository is not a fork of base repository".to_string(),
         ));
     }
-    ensure_repo_access(state, Some(acting_user), &head_repo, AccessMode::Write)?;
+    ensure_repo_access(state, Some(acting_user), &head_repo, AccessMode::Read)?;
 
     build_compare_for_refs(
         state,
@@ -1044,6 +1051,10 @@ fn ensure_pull_request_admin(
 }
 
 pub fn login_with_password(state: &AppState, login: &str, password: &str) -> AppResult<User> {
+    authenticate_password(state, login, password)
+}
+
+fn authenticate_password(state: &AppState, login: &str, password: &str) -> AppResult<User> {
     let user = if login.contains('@') {
         state
             .db
@@ -1069,6 +1080,18 @@ pub fn login_with_password(state: &AppState, login: &str, password: &str) -> App
     Ok(user)
 }
 
+fn is_token_expired(token: &crate::models::AccessToken) -> bool {
+    crate::db::now_unix() - token.created_unix > TOKEN_TTL_DAYS * 86400
+}
+
+fn pagination(page: i64, per_page: i64) -> (i64, i64) {
+    let page = page.max(1);
+    let per_page = per_page.clamp(1, MAX_PER_PAGE);
+    let limit = per_page;
+    let offset = (page - 1) * per_page;
+    (limit, offset)
+}
+
 fn validate_user_name(name: &str) -> AppResult<()> {
     validate_name(name, &[".git", ".wiki"], "username")
 }