diff --git a/api/Cargo.lock b/api/Cargo.lock index 899f816..9fb25ec 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -2476,6 +2476,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/api/Cargo.toml b/api/Cargo.toml index ad6f5ec..dff2a3a 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" actix-web = "4.12.1" chrono = { version = "0.4.43", features = ["serde"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } -reqwest = { version = "0.13.1", features = ["json"] } +reqwest = { version = "0.13.1", features = ["json", "query"] } sentry = { version = "0.46.1", features = ["actix", "tracing"] } serde = "1.0.228" sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] } diff --git a/api/src/auth.rs b/api/src/auth.rs index fc3950a..e6e0b2b 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -6,14 +6,18 @@ use jsonwebtoken::{ }; use serde::Deserialize; -pub struct UserId(pub String); +#[derive(Clone)] +pub struct User { + pub id: String, + pub name: String, +} pub struct JWT { reqwest_client: reqwest::Client, } pub trait AuthImpl { - async fn for_protected(&self, token: &str) -> Result; + async fn for_protected(&self, token: &str) -> Result; } pub enum Auth { @@ -23,6 +27,7 @@ pub enum Auth { #[derive(Deserialize)] struct Claims { sub: String, + name: String, } impl JWT { @@ -32,7 +37,7 @@ impl JWT { } impl AuthImpl for JWT { - async fn for_protected(&self, token: &str) -> Result { + async fn for_protected(&self, token: &str) -> Result { let frontend_url = env::var("FRONTEND_BASE_URL").unwrap_or_else(|_| "http://localhost:5173".to_string()); @@ -57,12 +62,15 @@ impl AuthImpl for JWT { _ => crate::error::Error::Unauthorized, })?; - Ok(UserId(token_data.claims.sub)) + Ok(User { + id: token_data.claims.sub, + name: token_data.claims.name, + }) } } impl AuthImpl for Auth { - async fn for_protected(&self, token: &str) -> Result { + async fn for_protected(&self, token: &str) -> Result { match self { Auth::JWT(jwt) => jwt.for_protected(token).await, } diff --git a/api/src/endpoints/get_repos.rs b/api/src/endpoints/get_repos.rs index fd5c61e..38c5a6a 100644 --- a/api/src/endpoints/get_repos.rs +++ b/api/src/endpoints/get_repos.rs @@ -2,38 +2,42 @@ use actix_web::{HttpRequest, HttpResponse, web}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::{AppState, account::AccountRepository, error::Result}; +use crate::{AppState, account::AccountRepository, auth::User, error::Result}; #[derive(Debug, Serialize, Deserialize)] -struct Repository { - id: u64, - name: String, - full_name: String, - description: Option, - language: Option, +pub struct Repository { + pub id: u64, + pub name: String, + pub full_name: String, + pub description: Option, + pub language: Option, #[serde(alias = "stargazers_count")] - stars: Option, - updated_at: DateTime, - private: bool, + pub stars: Option, + pub updated_at: DateTime, + pub private: bool, } pub async fn get_repos( app_state: web::Data, - path: web::Path, + req: web::ReqData, ) -> Result { - let user_id = path.into_inner(); + let user = req.into_inner(); let query = AccountRepository::new(&app_state.pool); - let token = query.get_access_token(&user_id).await?; + let token = query.get_access_token(&user.id).await?; let response = app_state .reqwest_client - .get("https://api.github.com/user/repos?affiliation=owner") + .get("https://api.github.com/user/repos?affiliation=owner&sort=updated&direction=desc&per_page=5") .bearer_auth(token) .send() .await?; response.error_for_status_ref()?; - let data = response.json::>().await?; - tracing::debug!(github_response = ?data.iter().filter(|r| r.private == true).collect::>(), "received repos"); + let data = response + .json::>() + .await? + .into_iter() + .filter(|r| r.private == false) + .collect::>(); Ok(HttpResponse::Ok().json(data)) } diff --git a/api/src/endpoints/mod.rs b/api/src/endpoints/mod.rs index 0df3bf5..cd5087c 100644 --- a/api/src/endpoints/mod.rs +++ b/api/src/endpoints/mod.rs @@ -1 +1,2 @@ pub mod get_repos; +pub mod search_repos; diff --git a/api/src/endpoints/search_repos.rs b/api/src/endpoints/search_repos.rs new file mode 100644 index 0000000..696aac7 --- /dev/null +++ b/api/src/endpoints/search_repos.rs @@ -0,0 +1,50 @@ +use crate::{ + account::AccountRepository, auth::User, endpoints::get_repos::Repository, error::Result, +}; +use actix_web::{ + HttpRequest, HttpResponse, + web::{self, ReqData}, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +#[derive(Deserialize)] +pub(crate) struct SearchQuery { + q: String, +} + +#[derive(Serialize, Deserialize)] +struct SearchResponse { + items: Vec, +} + +pub async fn search_repos( + app_state: web::Data, + query: web::Query, + req: ReqData, +) -> Result { + let user = req.into_inner(); + let token = AccountRepository::new(&app_state.pool) + .get_access_token(&user.id) + .await?; + + let search_query = format!("user:{} {} fork:true", user.name, query.q); + let response = app_state + .reqwest_client + .get("https://api.github.com/search/repositories") + .query(&[("q", &search_query)]) + .bearer_auth(&token) + .send() + .await? + .json::() + .await?; + + Ok(HttpResponse::Ok().json( + response + .items + .into_iter() + .filter(|r| r.private == false) + .collect::>(), + )) +} diff --git a/api/src/main.rs b/api/src/main.rs index 2ca0091..b732dee 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -6,7 +6,12 @@ mod middleware; use std::env; -use actix_web::{App, HttpServer, middleware::from_fn, rt::System, web}; +use actix_web::{ + App, HttpServer, + middleware::from_fn, + rt::System, + web::{self, route}, +}; use sqlx::PgPool; use tracing::level_filters::LevelFilter; use tracing_subscriber::{ @@ -59,9 +64,11 @@ async fn run() -> std::io::Result<()> { web::scope("/api").service( web::scope("/v0").service( web::scope("/user") + .route("/repos", web::get().to(endpoints::get_repos::get_repos)) + .wrap(from_fn(middleware::protected)) .route( - "/{user_id}/repos", - web::get().to(endpoints::get_repos::get_repos), + "/repos/search", + web::get().to(endpoints::search_repos::search_repos), ) .wrap(from_fn(middleware::protected)), ), diff --git a/api/src/middleware/protected.rs b/api/src/middleware/protected.rs index fc9ab6c..8be7ac5 100644 --- a/api/src/middleware/protected.rs +++ b/api/src/middleware/protected.rs @@ -20,8 +20,8 @@ pub async fn protected( .map_into_right_body()); }; - let user_id = app_state.auth.for_protected(&token).await?; - req.extensions_mut().insert(user_id); + let user = app_state.auth.for_protected(&token).await?; + req.extensions_mut().insert(user); next.call(req) .await diff --git a/bun.lock b/bun.lock index 9014c74..37784fb 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "jose": "^6.1.3", + "nprogress": "^0.2.0", }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", @@ -21,6 +22,7 @@ "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.17", + "@types/nprogress": "^0.2.3", "drizzle-kit": "^0.31.8", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.7.2", @@ -290,6 +292,8 @@ "@types/node": ["@types/node@22.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="], + "@types/nprogress": ["@types/nprogress@0.2.3", "", {}, "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA=="], + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], "@vercel/nft": ["@vercel/nft@1.2.0", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@rollup/pluginutils": "^5.1.3", "acorn": "^8.6.0", "acorn-import-attributes": "^1.9.5", "async-sema": "^3.1.1", "bindings": "^1.4.0", "estree-walker": "2.0.2", "glob": "^13.0.0", "graceful-fs": "^4.2.9", "node-gyp-build": "^4.2.2", "picomatch": "^4.0.2", "resolve-from": "^5.0.0" }, "bin": { "nft": "out/cli.js" } }, "sha512-68326CAWJmd6P1cUgUmufor5d4ocPbpLxiy9TKG6U/a4aWEx9aC+NIzaDI6GmBZVpt3+MkO3OwnQ2YcgJg12Qw=="], @@ -438,6 +442,8 @@ "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + "nprogress": ["nprogress@0.2.0", "", {}, "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], diff --git a/package.json b/package.json index 7ab20ef..cf45078 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.17", + "@types/nprogress": "^0.2.3", "drizzle-kit": "^0.31.8", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.7.2", @@ -38,6 +39,7 @@ "bits-ui": "^2.15.4", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", - "jose": "^6.1.3" + "jose": "^6.1.3", + "nprogress": "^0.2.0" } } diff --git a/src/lib/Repos.svelte b/src/lib/Repos.svelte index 73fa80e..c54bb96 100644 --- a/src/lib/Repos.svelte +++ b/src/lib/Repos.svelte @@ -1,32 +1,57 @@
diff --git a/src/routes/layout.css b/src/routes/layout.css index 09c6bbc..c470ce0 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -61,6 +61,10 @@ h1 { @apply hover:bg-gray-800; } +#nprogress .bar { + top: 3.5rem; +} + button:default { border-radius: 8px; border: 1px solid transparent;