diff --git a/api/Cargo.lock b/api/Cargo.lock index b359e55..f7b3cbb 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -248,6 +248,7 @@ dependencies = [ "aws-sdk-dynamodb", "chrono", "jsonwebtoken", + "mime_guess", "reqwest 0.13.1", "sentry", "serde", @@ -2114,6 +2115,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4104,6 +4115,12 @@ dependencies = [ "libc", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/api/Cargo.toml b/api/Cargo.toml index 0a0dc01..d811c5e 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -9,6 +9,7 @@ aws-config = "1.8.12" aws-sdk-dynamodb = "1.102.0" chrono = { version = "0.4.43", features = ["serde"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } +mime_guess = "2.0.5" reqwest = { version = "0.13.1", features = ["json", "query"] } sentry = { version = "0.46.1", features = ["actix", "tracing"] } serde = "1.0.228" diff --git a/api/src/auth.rs b/api/src/auth.rs index e6e0b2b..e139ee6 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -1,4 +1,4 @@ -use std::{env, sync::Arc}; +use std::env; use crate::error::Result; use jsonwebtoken::{ diff --git a/api/src/endpoints/add_repo.rs b/api/src/endpoints/add_repo.rs index 3b6b8d2..06bc382 100644 --- a/api/src/endpoints/add_repo.rs +++ b/api/src/endpoints/add_repo.rs @@ -6,9 +6,11 @@ use crate::AppState; pub async fn add_repo( app_state: web::Data, user: web::ReqData, - payload: web::Json, + repo: web::Json, ) -> Result { - let repo = payload.into_inner(); - validate_repo(app_state.clone(), &repo).await?; - app_state.user.add_repository(&user.id, repo).await + validate_repo(app_state.clone(), &repo, &user.id).await?; + app_state + .user + .add_repository(&user.id, repo.into_inner()) + .await } diff --git a/api/src/endpoints/get_repos.rs b/api/src/endpoints/get_repos.rs index 43de182..1bb3c7f 100644 --- a/api/src/endpoints/get_repos.rs +++ b/api/src/endpoints/get_repos.rs @@ -24,9 +24,8 @@ pub struct Repository { pub async fn get_repos( app_state: web::Data, - req: web::ReqData, + user: web::ReqData, ) -> Result { - let user = req.into_inner(); let token = app_state.user.get_access_token(&user.id).await?; let response = app_state @@ -38,7 +37,7 @@ pub async fn get_repos( response.error_for_status_ref()?; let added_ids = app_state .user - .get_repositories(&user.id) + .get_repositories_user(&user.id) .await? .into_iter() .map(|r| r.id) @@ -47,11 +46,9 @@ pub async fn get_repos( .json::>() .await? .into_iter() - .filter_map(|mut r| { - (!r.private).then(|| { - r.added = added_ids.contains(&r.id.to_string()); - r - }) + .map(|mut r| { + r.added = added_ids.contains(&r.id.to_string()); + r }) .collect::>(); diff --git a/api/src/endpoints/mod.rs b/api/src/endpoints/mod.rs index dd6524d..5427951 100644 --- a/api/src/endpoints/mod.rs +++ b/api/src/endpoints/mod.rs @@ -1,4 +1,5 @@ pub mod add_repo; pub mod get_repos; pub mod global_repos; +pub mod proxy_file; pub mod search_repos; diff --git a/api/src/endpoints/proxy_file.rs b/api/src/endpoints/proxy_file.rs new file mode 100644 index 0000000..7398fc1 --- /dev/null +++ b/api/src/endpoints/proxy_file.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; + +use crate::{ + AppState, + error::{Error, Result}, +}; + +#[derive(serde::Deserialize)] +pub struct Params { + repo_id: String, + file: String, +} + +pub async fn proxy_file( + app_state: web::Data, + path: web::Path, +) -> Result { + let repo = app_state + .user + .get_approved_repository(&path.repo_id) + .await? + .ok_or(Error::NotFound)?; + let token = app_state.user.get_access_token(&repo.owner_id).await?; + + let url = format!( + "https://raw.githubusercontent.com/{}/HEAD/dist/{}", + repo.full_name, path.file + ); + + let response = app_state + .reqwest_client + .get(&url) + .bearer_auth(token) + .send() + .await?; + response.error_for_status_ref()?; + + let bytes = response.bytes().await?; + let mime = mime_guess::from_path(&path.file) + .first_or_octet_stream() + .to_string(); + + Ok(HttpResponse::Ok() + .content_type(mime) + .insert_header(("Cache-Control", "public, max-age=3600")) + .body(bytes)) +} diff --git a/api/src/endpoints/search_repos.rs b/api/src/endpoints/search_repos.rs index 04703a7..5f8cf2f 100644 --- a/api/src/endpoints/search_repos.rs +++ b/api/src/endpoints/search_repos.rs @@ -1,6 +1,6 @@ use crate::{auth::User, endpoints::get_repos::Repository, error::Result}; use actix_web::{ - HttpRequest, HttpResponse, + HttpResponse, web::{self, ReqData}, }; use serde::{Deserialize, Serialize}; @@ -20,9 +20,8 @@ struct SearchResponse { pub async fn search_repos( app_state: web::Data, query: web::Query, - req: ReqData, + user: ReqData, ) -> Result { - let user = req.into_inner(); let token = app_state.user.get_access_token(&user.id).await?; let search_query = format!("user:{} {} fork:true", user.name, query.q); diff --git a/api/src/error.rs b/api/src/error.rs index 8ed81dd..de239dd 100644 --- a/api/src/error.rs +++ b/api/src/error.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpResponse, ResponseError, http::StatusCode}; +use actix_web::{HttpResponse, ResponseError}; use aws_sdk_dynamodb::error::SdkError; use serde::Serialize; use thiserror::Error; @@ -25,6 +25,8 @@ pub enum Error { AlreadyExists, #[error("validation failed: {0}")] ValidationFailed(String), + #[error("not found")] + NotFound, } impl From> for Error { @@ -49,9 +51,10 @@ impl ResponseError for Error { Error::AlreadyExists => HttpResponse::BadRequest().json(ErrorResponse { error: "item already exists".to_string(), }), - Error::ValidationFailed(msg) => HttpResponse::BadRequest().json(ErrorResponse { - error: msg.clone(), - }), + Error::ValidationFailed(msg) => { + HttpResponse::BadRequest().json(ErrorResponse { error: msg.clone() }) + } + Error::NotFound => HttpResponse::NotFound().finish(), _ => HttpResponse::InternalServerError().finish(), } } diff --git a/api/src/main.rs b/api/src/main.rs index e9af6cd..6e5668b 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -89,6 +89,10 @@ async fn run() -> std::io::Result<()> { .route( "/repos", web::get().to(endpoints::global_repos::global_repos), + ) + .route( + "/repos/{repo_id}/files/{file:.*}", + web::get().to(endpoints::proxy_file::proxy_file), ), ), ) diff --git a/api/src/user.rs b/api/src/user.rs index d01ade2..29923fd 100644 --- a/api/src/user.rs +++ b/api/src/user.rs @@ -18,6 +18,16 @@ pub struct UserRepository { pub struct RepositorySchema { pub id: String, pub full_name: String, + #[serde(skip)] + pub owner_id: String, + pub description: String, +} + +#[derive(Serialize)] +pub struct GlobalRepositoriesResponse { + pub id: String, + pub full_name: String, + pub description: String, } impl UserRepository { @@ -38,7 +48,7 @@ impl UserRepository { .map_err(|_| crate::error::Error::AccessToken) } - pub async fn get_repositories(&self, user_id: &str) -> Result> { + pub async fn get_repositories_user(&self, user_id: &str) -> Result> { let response = self .dynamodb_client .query() @@ -59,6 +69,8 @@ impl UserRepository { .strip_prefix("REPO#")? .to_string(), full_name: item.get("full_name")?.as_s().ok()?.to_string(), + owner_id: item.get("owner_id")?.as_s().ok()?.to_string(), + description: item.get("description")?.as_s().ok()?.to_string(), }) }) .collect::>(); @@ -70,7 +82,9 @@ impl UserRepository { let response = self .dynamodb_client .query() - .key_condition_expression("pk = :pk") + .table_name(&self.table_name) + .index_name("gsi1") + .key_condition_expression("gsi1pk = :pk") .expression_attribute_values(":pk", AttributeValue::S("REPOS".into())) .send() .await? @@ -80,13 +94,53 @@ impl UserRepository { if (*item.get("approved")?.as_bool().ok()?) == false { return None; }; - Some(item.get("full_name")?.as_s().ok()?.to_string()) + Some(GlobalRepositoriesResponse { + id: item + .get("sk")? + .as_s() + .ok()? + .strip_prefix("REPO#")? + .to_string(), + full_name: item.get("full_name")?.as_s().ok()?.to_string(), + description: item.get("description")?.as_s().ok()?.to_string(), + }) }) - .collect::>(); + .collect::>(); Ok(HttpResponse::Ok().json(response)) } + pub async fn get_approved_repository(&self, repo_id: &str) -> Result> { + let response = self + .dynamodb_client + .query() + .table_name(&self.table_name) + .index_name("gsi1") + .key_condition_expression("gsi1pk = :pk") + .filter_expression("sk = :sk AND approved = :approved") + .expression_attribute_values(":pk", AttributeValue::S("REPOS".into())) + .expression_attribute_values(":sk", AttributeValue::S(format!("REPO#{repo_id}"))) + .expression_attribute_values(":approved", AttributeValue::Bool(true)) + .send() + .await?; + + let repo = response.items().first().and_then(|item| { + Some(RepositorySchema { + id: item + .get("sk")? + .as_s() + .ok()? + .strip_prefix("REPO#")? + .to_string(), + full_name: item.get("full_name")?.as_s().ok()?.to_string(), + owner_id: item.get("owner_id")?.as_s().ok()?.to_string(), + description: item.get("description")?.as_s().ok()?.to_string(), + }) + }); + + Ok(repo) + } + pub async fn add_repository( &self, user_id: &str, @@ -105,6 +159,8 @@ impl UserRepository { .item("gsi1sk", AttributeValue::S(now.clone())) .item("imported_at", AttributeValue::S(now)) .item("approved", AttributeValue::Bool(false)) + .item("owner_id", AttributeValue::S(user_id.into())) + .item("description", AttributeValue::S(repo.description.clone())) .send() .await; diff --git a/api/src/validate.rs b/api/src/validate.rs index e6d9577..b681053 100644 --- a/api/src/validate.rs +++ b/api/src/validate.rs @@ -4,13 +4,19 @@ use crate::AppState; use crate::error::{Error, Result}; use crate::user::RepositorySchema; -pub async fn validate_repo(app_state: web::Data, repo: &RepositorySchema) -> Result<()> { +pub async fn validate_repo( + app_state: web::Data, + repo: &RepositorySchema, + user_id: &str, +) -> Result<()> { + let token = app_state.user.get_access_token(&user_id).await?; let response = app_state .reqwest_client .get(format!( "https://raw.githubusercontent.com/{}/HEAD/dist/index.html", repo.full_name )) + .bearer_auth(token) .send() .await?; diff --git a/src/lib/Header.svelte b/src/lib/Header.svelte index 10f2155..a4d7444 100644 --- a/src/lib/Header.svelte +++ b/src/lib/Header.svelte @@ -18,10 +18,10 @@ }; -
+
goto('/')}> -

Godot Host

+

Project Host

{#if !user && !$session.isPending} diff --git a/src/lib/Projects.svelte b/src/lib/Projects.svelte index aee54ce..15c8dd6 100644 --- a/src/lib/Projects.svelte +++ b/src/lib/Projects.svelte @@ -1,83 +1,93 @@ -
- {#each mockProjects as project (project.id)} -
-
- {project.name} +{#if isLoading} +
+ +
+{:else if hasError} +
+

Failed to load projects. Please try again later.

+
+{:else if isEmpty} +
+

No projects available yet.

+
+{:else} +
+ {#each projects as project (project.id)} +
+
+ {project.name} -
- { - goto(`/p/${project.id}`).catch((e) => { - console.warn(e); - }); - }} +
- - + { + goto(`/p/${project.id}`).catch((e) => { + console.warn(e); + }); + }} + > + + +
+
+ +
+

{project.name}

+

{project.description}

- -
-

{project.name}

-

{project.description}

-
-
- {/each} -
+ {/each} +
+{/if} diff --git a/src/lib/project/resolve.ts b/src/lib/project/resolve.ts new file mode 100644 index 0000000..7dafcae --- /dev/null +++ b/src/lib/project/resolve.ts @@ -0,0 +1,28 @@ +import type { Project, RepoDefinition } from "$lib/types/project"; + +export async function resolveProjectData(repo: RepoDefinition): Promise { + const { id, full_name: fullName } = repo; + let full_name_p = fullName.split("/") + const name = full_name_p[1] ?? fullName; + + let description; + const repoResponse = await fetch(`https://api.github.com/repos/${fullName}`); + if (repoResponse.ok) { + const repoData = await repoResponse.json(); + description = repoData.description ?? `by ${full_name_p[0]}`; + } + + let thumbnail = `https://raw.githubusercontent.com/${fullName}/HEAD/thumbnail.webp` + const thumbResponse = await fetch(thumbnail, { method: 'HEAD' }); + if (!thumbResponse.ok) { + thumbnail = `https://picsum.photos/seed/${encodeURIComponent(fullName.replaceAll("/", "-"))}/400/225`; + } + + return { + id, + full_name: fullName, + name, + thumbnail, + description + } +} diff --git a/src/lib/types/project.ts b/src/lib/types/project.ts index d57fcad..dd64da7 100644 --- a/src/lib/types/project.ts +++ b/src/lib/types/project.ts @@ -1,7 +1,12 @@ export type Project = { - id: number; - name: string; - thumbnail?: string; - description: string; - url?: string; + id: string; + full_name: string; + name: string; + thumbnail?: string; + description?: string; +}; + +export type RepoDefinition = { + id: string; + full_name: string; }; diff --git a/src/routes/p/[id]/+page.svelte b/src/routes/p/[id]/+page.svelte index 5876184..774aa57 100644 --- a/src/routes/p/[id]/+page.svelte +++ b/src/routes/p/[id]/+page.svelte @@ -5,7 +5,7 @@ import { Maximize, Minimize, ArrowLeft } from '@lucide/svelte'; const id = $derived(page.params.id); - const src = $derived(`/projects/${id}/index.html`); + const src = $derived(`/api/v0/repos/${id}/files/index.html`); let isFullscreen = $state(false); let containerRef = $state(null);