summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2025-01-27 22:57:15 +0100
committerJoel Klinghed <the_jk@spawned.biz>2025-01-27 22:57:15 +0100
commit9d14d9ca39b46042c4196382613dd9c4bf711fcb (patch)
treea01c21f158b43cea3aea67e90530e49cd061cd11
parent59035c4532110a9fec8309bbda55d8ba6d14ce94 (diff)
Add user keys to database
Next step is to generate authorized_keys files for git server based on keys.
-rw-r--r--server/.sqlx/query-322c1c671af1dc5daf2303180e5ebb59fc78c9a71a34ec3f4309c2b495b1eeee.json58
-rw-r--r--server/.sqlx/query-7dff27857ca04ff487c71c6cc575b5888bfd6b7e3aa60fb50d58fb2e46231e79.json25
-rw-r--r--server/.sqlx/query-947c8d9f63c791b1e56eb85cd4423b22f84fd28b8a07f21fbcee0d907873c591.json12
-rw-r--r--server/.sqlx/query-a384335c775a26f6780e8b843507c69b3d34a4ef3f30ce5fefc0d3bc2e284faa.json58
-rw-r--r--server/.sqlx/query-ec7cb8523695d2bf73721631e4732e97e6119d519263f8b188550fc2bfe707f3.json12
-rw-r--r--server/migrations/1_initial_eyeballs.sql13
-rw-r--r--server/src/api_model.rs35
-rw-r--r--server/src/main.rs164
-rw-r--r--server/src/tests.rs109
9 files changed, 486 insertions, 0 deletions
diff --git a/server/.sqlx/query-322c1c671af1dc5daf2303180e5ebb59fc78c9a71a34ec3f4309c2b495b1eeee.json b/server/.sqlx/query-322c1c671af1dc5daf2303180e5ebb59fc78c9a71a34ec3f4309c2b495b1eeee.json
new file mode 100644
index 0000000..9ec46e5
--- /dev/null
+++ b/server/.sqlx/query-322c1c671af1dc5daf2303180e5ebb59fc78c9a71a34ec3f4309c2b495b1eeee.json
@@ -0,0 +1,58 @@
+{
+ "db_name": "MySQL",
+ "query": "SELECT id,kind,data,comment FROM user_keys WHERE id=? AND user=?",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": {
+ "type": "LongLong",
+ "flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
+ "char_set": 63,
+ "max_size": 20
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "kind",
+ "type_info": {
+ "type": "VarString",
+ "flags": "NOT_NULL | NO_DEFAULT_VALUE",
+ "char_set": 224,
+ "max_size": 512
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "data",
+ "type_info": {
+ "type": "VarString",
+ "flags": "NOT_NULL | NO_DEFAULT_VALUE",
+ "char_set": 224,
+ "max_size": 32768
+ }
+ },
+ {
+ "ordinal": 3,
+ "name": "comment",
+ "type_info": {
+ "type": "VarString",
+ "flags": "NOT_NULL | NO_DEFAULT_VALUE",
+ "char_set": 224,
+ "max_size": 4096
+ }
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "322c1c671af1dc5daf2303180e5ebb59fc78c9a71a34ec3f4309c2b495b1eeee"
+}
diff --git a/server/.sqlx/query-7dff27857ca04ff487c71c6cc575b5888bfd6b7e3aa60fb50d58fb2e46231e79.json b/server/.sqlx/query-7dff27857ca04ff487c71c6cc575b5888bfd6b7e3aa60fb50d58fb2e46231e79.json
new file mode 100644
index 0000000..5b52cc0
--- /dev/null
+++ b/server/.sqlx/query-7dff27857ca04ff487c71c6cc575b5888bfd6b7e3aa60fb50d58fb2e46231e79.json
@@ -0,0 +1,25 @@
+{
+ "db_name": "MySQL",
+ "query": "SELECT COUNT(id) AS count FROM user_keys WHERE user=?",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "count",
+ "type_info": {
+ "type": "LongLong",
+ "flags": "NOT_NULL | BINARY",
+ "char_set": 63,
+ "max_size": 21
+ }
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "7dff27857ca04ff487c71c6cc575b5888bfd6b7e3aa60fb50d58fb2e46231e79"
+}
diff --git a/server/.sqlx/query-947c8d9f63c791b1e56eb85cd4423b22f84fd28b8a07f21fbcee0d907873c591.json b/server/.sqlx/query-947c8d9f63c791b1e56eb85cd4423b22f84fd28b8a07f21fbcee0d907873c591.json
new file mode 100644
index 0000000..4ce7b53
--- /dev/null
+++ b/server/.sqlx/query-947c8d9f63c791b1e56eb85cd4423b22f84fd28b8a07f21fbcee0d907873c591.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "MySQL",
+ "query": "DELETE FROM user_keys WHERE id=? AND user=?",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": []
+ },
+ "hash": "947c8d9f63c791b1e56eb85cd4423b22f84fd28b8a07f21fbcee0d907873c591"
+}
diff --git a/server/.sqlx/query-a384335c775a26f6780e8b843507c69b3d34a4ef3f30ce5fefc0d3bc2e284faa.json b/server/.sqlx/query-a384335c775a26f6780e8b843507c69b3d34a4ef3f30ce5fefc0d3bc2e284faa.json
new file mode 100644
index 0000000..9d50b8a
--- /dev/null
+++ b/server/.sqlx/query-a384335c775a26f6780e8b843507c69b3d34a4ef3f30ce5fefc0d3bc2e284faa.json
@@ -0,0 +1,58 @@
+{
+ "db_name": "MySQL",
+ "query": "SELECT id,kind,data,comment FROM user_keys WHERE user=? ORDER BY id LIMIT ? OFFSET ?",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": {
+ "type": "LongLong",
+ "flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
+ "char_set": 63,
+ "max_size": 20
+ }
+ },
+ {
+ "ordinal": 1,
+ "name": "kind",
+ "type_info": {
+ "type": "VarString",
+ "flags": "NOT_NULL | NO_DEFAULT_VALUE",
+ "char_set": 224,
+ "max_size": 512
+ }
+ },
+ {
+ "ordinal": 2,
+ "name": "data",
+ "type_info": {
+ "type": "VarString",
+ "flags": "NOT_NULL | NO_DEFAULT_VALUE",
+ "char_set": 224,
+ "max_size": 32768
+ }
+ },
+ {
+ "ordinal": 3,
+ "name": "comment",
+ "type_info": {
+ "type": "VarString",
+ "flags": "NOT_NULL | NO_DEFAULT_VALUE",
+ "char_set": 224,
+ "max_size": 4096
+ }
+ }
+ ],
+ "parameters": {
+ "Right": 3
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "a384335c775a26f6780e8b843507c69b3d34a4ef3f30ce5fefc0d3bc2e284faa"
+}
diff --git a/server/.sqlx/query-ec7cb8523695d2bf73721631e4732e97e6119d519263f8b188550fc2bfe707f3.json b/server/.sqlx/query-ec7cb8523695d2bf73721631e4732e97e6119d519263f8b188550fc2bfe707f3.json
new file mode 100644
index 0000000..b1aa378
--- /dev/null
+++ b/server/.sqlx/query-ec7cb8523695d2bf73721631e4732e97e6119d519263f8b188550fc2bfe707f3.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "MySQL",
+ "query": "INSERT INTO user_keys (user, kind, data, comment) VALUES (?, ?, ?, ?)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 4
+ },
+ "nullable": []
+ },
+ "hash": "ec7cb8523695d2bf73721631e4732e97e6119d519263f8b188550fc2bfe707f3"
+}
diff --git a/server/migrations/1_initial_eyeballs.sql b/server/migrations/1_initial_eyeballs.sql
index 3f31781..728c329 100644
--- a/server/migrations/1_initial_eyeballs.sql
+++ b/server/migrations/1_initial_eyeballs.sql
@@ -69,3 +69,16 @@ CREATE TABLE IF NOT EXISTS review_users (
ON DELETE CASCADE
ON UPDATE RESTRICT
);
+
+CREATE TABLE IF NOT EXISTS user_keys (
+ id BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ user VARCHAR(128) NOT NULL,
+ kind VARCHAR(128) NOT NULL,
+ data VARCHAR(8192) NOT NULL,
+ comment VARCHAR(1024) NOT NULL,
+
+ CONSTRAINT `fk_user_keys_user`
+ FOREIGN KEY (user) REFERENCES users (id)
+ ON DELETE CASCADE
+ ON UPDATE RESTRICT
+);
diff --git a/server/src/api_model.rs b/server/src/api_model.rs
index 2dc20f1..f062935 100644
--- a/server/src/api_model.rs
+++ b/server/src/api_model.rs
@@ -201,3 +201,38 @@ pub struct StatusResponse {
)]
pub error: Option<&'static str>,
}
+
+#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)]
+pub struct UserKey {
+ #[schema(example = 1u64)]
+ pub id: u64,
+ #[schema(example = "ssh-rsa")]
+ pub kind: String,
+ #[schema(example = "AAAAfoobar==")]
+ pub data: String,
+ #[schema(example = "user@host 1970-01-01")]
+ pub comment: String,
+}
+
+#[derive(Deserialize, Serialize, ToSchema)]
+pub struct UserKeyData<'r> {
+ #[schema(example = "ssh-rsa")]
+ pub kind: &'r str,
+ #[schema(example = "AAAAfoobar==")]
+ pub data: &'r str,
+ #[schema(example = "user@host 1970-01-01")]
+ pub comment: Option<&'r str>,
+}
+
+#[derive(Deserialize, Serialize, ToSchema)]
+pub struct UserKeys {
+ #[schema(example = 0u32)]
+ pub offset: u32,
+ #[schema(example = 10u32)]
+ pub limit: u32,
+ #[schema(example = 2u32)]
+ pub total_count: u32,
+ #[schema(example = false)]
+ pub more: bool,
+ pub keys: Vec<UserKey>,
+}
diff --git a/server/src/main.rs b/server/src/main.rs
index d0547c1..298a418 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -44,6 +44,10 @@ struct Db(sqlx::MySqlPool);
reviews,
review,
users,
+ user_key_add,
+ user_key_get,
+ user_key_del,
+ user_keys,
),
modifiers(&AuthApiAddon),
)]
@@ -662,6 +666,162 @@ async fn users(
})
}
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Key added to current user", body = api_model::UserKey),
+ (status = 400, description = "Key too large"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[post("/user/keys/add", data = "<data>")]
+async fn user_key_add(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ data: Json<api_model::UserKeyData<'_>>,
+) -> Result<Json<api_model::UserKey>, Custom<&'static str>> {
+ if data.data.len() > 8192 {
+ return Err(Custom(Status::BadRequest, "Key is too large"));
+ }
+
+ let comment = data.comment.unwrap_or("");
+ let result = sqlx::query!(
+ "INSERT INTO user_keys (user, kind, data, comment) VALUES (?, ?, ?, ?)",
+ session.user_id,
+ data.kind,
+ data.data,
+ comment,
+ )
+ .execute(&mut **db)
+ .await
+ .unwrap();
+
+ Ok(Json(api_model::UserKey {
+ id: result.last_insert_id(),
+ kind: data.kind.to_string(),
+ data: data.data.to_string(),
+ comment: comment.to_string(),
+ }))
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "User key", body = api_model::UserKey),
+ (status = 404, description = "No such key"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[get("/user/keys/<id>")]
+async fn user_key_get(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ id: u64,
+) -> Result<Json<api_model::UserKey>, NotFound<&'static str>> {
+ let user_key = sqlx::query!(
+ "SELECT id,kind,data,comment FROM user_keys WHERE id=? AND user=?",
+ id,
+ session.user_id,
+ )
+ .fetch_one(&mut **db)
+ .map_ok(|r| api_model::UserKey {
+ id: r.id,
+ kind: r.kind,
+ data: r.data,
+ comment: r.comment,
+ })
+ .map_err(|_| NotFound("No such user key"))
+ .await?;
+
+ Ok(Json(user_key))
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Key removed from current user"),
+ (status = 404, description = "No such key for current user"),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[delete("/user/keys/<id>")]
+async fn user_key_del(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ id: u64,
+) -> Result<&'static str, Custom<&'static str>> {
+ let result = sqlx::query!(
+ "DELETE FROM user_keys WHERE id=? AND user=?",
+ id,
+ session.user_id,
+ )
+ .execute(&mut **db)
+ .await
+ .unwrap();
+ if result.rows_affected() == 0 {
+ return Err(Custom(Status::NotFound, "No such key for current user"));
+ }
+
+ Ok("")
+}
+
+#[utoipa::path(
+ responses(
+ (status = 200, description = "Get all keys for user", body = api_model::UserKeys),
+ ),
+ security(
+ ("session" = []),
+ ),
+)]
+#[get("/user/keys?<limit>&<offset>")]
+async fn user_keys(
+ mut db: Connection<Db>,
+ session: auth::Session,
+ limit: Option<u32>,
+ offset: Option<u32>,
+) -> Json<api_model::UserKeys> {
+ let uw_offset = offset.unwrap_or(0);
+ let uw_limit = limit.unwrap_or(10);
+ let entries = sqlx::query!(
+ "SELECT id,kind,data,comment FROM user_keys WHERE user=? ORDER BY id LIMIT ? OFFSET ?",
+ session.user_id,
+ uw_limit,
+ uw_offset
+ )
+ .fetch(&mut **db)
+ .map_ok(|r| api_model::UserKey {
+ id: r.id,
+ kind: r.kind,
+ data: r.data,
+ comment: r.comment,
+ })
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let count = sqlx::query!(
+ "SELECT COUNT(id) AS count FROM user_keys WHERE user=?",
+ session.user_id,
+ )
+ .fetch_one(&mut **db)
+ .map_ok(|r| r.count)
+ .await
+ .unwrap();
+
+ let u32_count = u32::try_from(count).unwrap();
+
+ Json(api_model::UserKeys {
+ offset: uw_offset,
+ limit: uw_limit,
+ total_count: u32_count,
+ more: uw_offset + uw_limit < u32_count,
+ keys: entries,
+ })
+}
+
async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result {
match Db::fetch(&rocket) {
Some(db) => match sqlx::migrate!().run(&**db).await {
@@ -695,6 +855,10 @@ fn rocket_from_config(figment: Figment) -> Rocket<Build> {
review,
review_encoded_path,
users,
+ user_key_add,
+ user_key_get,
+ user_key_del,
+ user_keys,
],
)
.attach(auth::stage(basepath))
diff --git a/server/src/tests.rs b/server/src/tests.rs
index f1489d8..4567c49 100644
--- a/server/src/tests.rs
+++ b/server/src/tests.rs
@@ -208,6 +208,27 @@ async fn get_reviews<'a>(client: &Client, project_url: impl Display) -> api_mode
.unwrap()
}
+async fn get_user_key_from<'a>(request: LocalRequest<'a>) -> api_model::UserKey {
+ request
+ .header(&FAKE_IP)
+ .dispatch()
+ .await
+ .into_json::<api_model::UserKey>()
+ .await
+ .unwrap()
+}
+
+async fn get_user_keys<'a>(client: &Client) -> api_model::UserKeys {
+ client
+ .get("/api/v1/user/keys")
+ .header(&FAKE_IP)
+ .dispatch()
+ .await
+ .into_json::<api_model::UserKeys>()
+ .await
+ .unwrap()
+}
+
#[rocket::async_test]
async fn test_not_logged_in_status() {
let client = async_client_with_private_database(function_name!().to_string()).await;
@@ -818,3 +839,91 @@ async fn test_project_reviews_unknown_project() {
.await;
assert_eq!(reviews.status(), Status::NotFound);
}
+
+#[rocket::async_test]
+async fn test_user_keys_empty() {
+ let client = async_client_with_private_database(function_name!().to_string()).await;
+
+ login(&client).await;
+
+ let user_keys = get_user_keys(&client).await;
+ assert_eq!(user_keys.total_count, 0);
+ assert_eq!(user_keys.more, false);
+ assert_eq!(user_keys.keys.len(), 0);
+}
+
+#[rocket::async_test]
+async fn test_user_keys_add() {
+ let client = async_client_with_private_database(function_name!().to_string()).await;
+
+ login(&client).await;
+
+ let key1 = get_user_key_from(client.post("/api/v1/user/keys/add").json(
+ &api_model::UserKeyData {
+ kind: "kind",
+ data: "data",
+ comment: None,
+ },
+ ))
+ .await;
+
+ assert_eq!(key1.kind, "kind");
+ assert_eq!(key1.data, "data");
+ assert_eq!(key1.comment, "");
+
+ let mut user_keys = get_user_keys(&client).await;
+ assert_eq!(user_keys.total_count, 1);
+ assert_eq!(user_keys.more, false);
+ assert_eq!(user_keys.keys.len(), 1);
+ let key2 = user_keys.keys.get(0).unwrap();
+ assert_eq!(*key2, key1);
+
+ let key3 = get_user_key_from(client.get(format!("/api/v1/user/keys/{}", key1.id))).await;
+ assert_eq!(key3, key1);
+
+ let key4 = get_user_key_from(client.post("/api/v1/user/keys/add").json(
+ &api_model::UserKeyData {
+ kind: "another kind",
+ data: "more data",
+ comment: Some("comment"),
+ },
+ ))
+ .await;
+
+ user_keys = get_user_keys(&client).await;
+ assert_eq!(user_keys.total_count, 2);
+ assert_eq!(user_keys.more, false);
+ assert_eq!(user_keys.keys.len(), 2);
+ let key5 = user_keys.keys.get(0).unwrap();
+ let key6 = user_keys.keys.get(1).unwrap();
+ assert_eq!(*key5, key1);
+ assert_eq!(*key6, key4);
+}
+
+#[rocket::async_test]
+async fn test_user_keys_del() {
+ let client = async_client_with_private_database(function_name!().to_string()).await;
+
+ login(&client).await;
+
+ let key = get_user_key_from(client.post("/api/v1/user/keys/add").json(
+ &api_model::UserKeyData {
+ kind: "kind",
+ data: "data",
+ comment: None,
+ },
+ ))
+ .await;
+
+ let delete = client
+ .delete(format!("/api/v1/user/keys/{}", key.id))
+ .header(&FAKE_IP)
+ .dispatch()
+ .await;
+ assert_eq!(delete.status(), Status::Ok);
+
+ let user_keys = get_user_keys(&client).await;
+ assert_eq!(user_keys.total_count, 0);
+ assert_eq!(user_keys.more, false);
+ assert_eq!(user_keys.keys.len(), 0);
+}