Compare commits
13 Commits
5eccfe32da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
442d53c9d1
|
|||
|
94e921c900
|
|||
|
c1bcc2ce4a
|
|||
|
496ed2cb00
|
|||
|
d9622ea451
|
|||
|
cfd2e0d2c2
|
|||
|
8bce0bd957
|
|||
|
ddf47ff22d
|
|||
|
09f6f49390
|
|||
|
2bc6d1bdb0
|
|||
|
37fa5d4838
|
|||
|
33e1db94f5
|
|||
|
a2afc3fa05
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
.env*.local
|
||||||
|
|||||||
6
.vercelignore
Normal file
6
.vercelignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
.git
|
||||||
|
.vercel
|
||||||
|
api
|
||||||
|
cdk
|
||||||
@@ -1 +1,5 @@
|
|||||||
## Godot Host V2
|
## Project Hoster
|
||||||
|
|
||||||
|
### Attribution
|
||||||
|
This project is essentially a clone of https://godothost.vercel.app, but with less features.
|
||||||
|
This project exists as im unaware if the original is maintained, and when I brought up the idea to cs, he preferred a rewrite.
|
||||||
|
|||||||
102
api/Cargo.lock
generated
102
api/Cargo.lock
generated
@@ -19,6 +19,21 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-cors"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
|
||||||
|
dependencies = [
|
||||||
|
"actix-utils",
|
||||||
|
"actix-web",
|
||||||
|
"derive_more",
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-http"
|
name = "actix-http"
|
||||||
version = "3.11.2"
|
version = "3.11.2"
|
||||||
@@ -29,7 +44,7 @@ dependencies = [
|
|||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"brotli",
|
"brotli",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -243,14 +258,17 @@ dependencies = [
|
|||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"actix-cors",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-dynamodb",
|
"aws-sdk-dynamodb",
|
||||||
"chrono",
|
"chrono",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"mime_guess",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"sentry",
|
"sentry",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_dynamo",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -684,6 +702,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -1372,6 +1396,17 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1392,6 +1427,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -1736,7 +1772,7 @@ version = "0.1.19"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -1968,7 +2004,7 @@ version = "10.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e"
|
checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -2114,6 +2150,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2547,7 +2593,7 @@ version = "3.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2856,7 +2902,7 @@ version = "0.12.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -2894,10 +2940,11 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2 0.4.13",
|
"h2 0.4.13",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
@@ -2920,12 +2967,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3357,6 +3406,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_dynamo"
|
||||||
|
version = "4.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "873a97c3f7a67dd042bceb47d056d288424b82d4c66b0a25e1a3b34675620951"
|
||||||
|
dependencies = [
|
||||||
|
"aws-sdk-dynamodb",
|
||||||
|
"base64 0.21.7",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.149"
|
||||||
@@ -3530,7 +3591,7 @@ version = "0.8.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
@@ -3604,7 +3665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3646,7 +3707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -4104,6 +4165,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -4155,7 +4222,7 @@ version = "3.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
|
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"der",
|
"der",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
@@ -4172,7 +4239,7 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
|
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
@@ -4343,6 +4410,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.83"
|
version = "0.3.83"
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
actix-cors = "0.7.1"
|
||||||
actix-web = "4.12.1"
|
actix-web = "4.12.1"
|
||||||
aws-config = "1.8.12"
|
aws-config = "1.8.12"
|
||||||
aws-sdk-dynamodb = "1.102.0"
|
aws-sdk-dynamodb = "1.102.0"
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
||||||
reqwest = { version = "0.13.1", features = ["json", "query"] }
|
mime_guess = "2.0.5"
|
||||||
|
reqwest = { version = "0.13.1", features = ["json", "query", "stream"] }
|
||||||
sentry = { version = "0.46.1", features = ["actix", "tracing"] }
|
sentry = { version = "0.46.1", features = ["actix", "tracing"] }
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
|
serde_dynamo = { version = "4.3.0", features = ["aws-sdk-dynamodb+1"] }
|
||||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] }
|
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.49.0"
|
tokio = "1.49.0"
|
||||||
|
|||||||
13
api/Dockerfile
Normal file
13
api/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM rust:1.92-alpine3.20 AS builder
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN apk update && apk add musl-dev libressl-dev
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
COPY --from=builder /target/release/api /usr/local/bin/api
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/api"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{env, sync::Arc};
|
use std::env;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use jsonwebtoken::{
|
use jsonwebtoken::{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{auth::User, error::Result, user::RepositorySchema, validate::validate_repo};
|
use crate::{auth::User, error::Result, user::RepositoryDefinition, validate::validate_repo};
|
||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -6,9 +6,11 @@ use crate::AppState;
|
|||||||
pub async fn add_repo(
|
pub async fn add_repo(
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
user: web::ReqData<User>,
|
user: web::ReqData<User>,
|
||||||
payload: web::Json<RepositorySchema>,
|
repo: web::Json<RepositoryDefinition>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let repo = payload.into_inner();
|
validate_repo(app_state.clone(), &repo, &user.id).await?;
|
||||||
validate_repo(app_state.clone(), &repo).await?;
|
app_state
|
||||||
app_state.user.add_repository(&user.id, repo).await
|
.user
|
||||||
|
.add_repository(&user.id, repo.into_inner())
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ pub struct Repository {
|
|||||||
|
|
||||||
pub async fn get_repos(
|
pub async fn get_repos(
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
req: web::ReqData<User>,
|
user: web::ReqData<User>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let user = req.into_inner();
|
|
||||||
let token = app_state.user.get_access_token(&user.id).await?;
|
let token = app_state.user.get_access_token(&user.id).await?;
|
||||||
|
|
||||||
let response = app_state
|
let response = app_state
|
||||||
@@ -38,7 +37,7 @@ pub async fn get_repos(
|
|||||||
response.error_for_status_ref()?;
|
response.error_for_status_ref()?;
|
||||||
let added_ids = app_state
|
let added_ids = app_state
|
||||||
.user
|
.user
|
||||||
.get_repositories(&user.id)
|
.get_repositories_user(&user.id)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| r.id)
|
.map(|r| r.id)
|
||||||
@@ -47,12 +46,10 @@ pub async fn get_repos(
|
|||||||
.json::<Vec<Repository>>()
|
.json::<Vec<Repository>>()
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|mut r| {
|
.map(|mut r| {
|
||||||
(!r.private).then(|| {
|
|
||||||
r.added = added_ids.contains(&r.id.to_string());
|
r.added = added_ids.contains(&r.id.to_string());
|
||||||
r
|
r
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.collect::<Vec<Repository>>();
|
.collect::<Vec<Repository>>();
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(data))
|
Ok(HttpResponse::Ok().json(data))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod add_repo;
|
pub mod add_repo;
|
||||||
pub mod get_repos;
|
pub mod get_repos;
|
||||||
pub mod global_repos;
|
pub mod global_repos;
|
||||||
|
pub mod proxy_file;
|
||||||
pub mod search_repos;
|
pub mod search_repos;
|
||||||
|
|||||||
49
api/src/endpoints/proxy_file.rs
Normal file
49
api/src/endpoints/proxy_file.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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<AppState>,
|
||||||
|
path: web::Path<Params>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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()
|
||||||
|
.map_err(|_| crate::error::Error::NotFound)?;
|
||||||
|
|
||||||
|
let stream = response.bytes_stream();
|
||||||
|
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"))
|
||||||
|
.streaming(stream))
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{auth::User, endpoints::get_repos::Repository, error::Result};
|
use crate::{auth::User, endpoints::get_repos::Repository, error::Result};
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
HttpRequest, HttpResponse,
|
HttpResponse,
|
||||||
web::{self, ReqData},
|
web::{self, ReqData},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -20,9 +20,8 @@ struct SearchResponse {
|
|||||||
pub async fn search_repos(
|
pub async fn search_repos(
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
query: web::Query<SearchQuery>,
|
query: web::Query<SearchQuery>,
|
||||||
req: ReqData<User>,
|
user: ReqData<User>,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let user = req.into_inner();
|
|
||||||
let token = app_state.user.get_access_token(&user.id).await?;
|
let token = app_state.user.get_access_token(&user.id).await?;
|
||||||
|
|
||||||
let search_query = format!("user:{} {} fork:true", user.name, query.q);
|
let search_query = format!("user:{} {} fork:true", user.name, query.q);
|
||||||
@@ -36,11 +35,5 @@ pub async fn search_repos(
|
|||||||
.json::<SearchResponse>()
|
.json::<SearchResponse>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(
|
Ok(HttpResponse::Ok().json(response.items))
|
||||||
response
|
|
||||||
.items
|
|
||||||
.into_iter()
|
|
||||||
.filter(|r| r.private == false)
|
|
||||||
.collect::<Vec<Repository>>(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use actix_web::{HttpResponse, ResponseError, http::StatusCode};
|
use actix_web::{HttpResponse, ResponseError};
|
||||||
use aws_sdk_dynamodb::error::SdkError;
|
use aws_sdk_dynamodb::error::SdkError;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -25,6 +25,8 @@ pub enum Error {
|
|||||||
AlreadyExists,
|
AlreadyExists,
|
||||||
#[error("validation failed: {0}")]
|
#[error("validation failed: {0}")]
|
||||||
ValidationFailed(String),
|
ValidationFailed(String),
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: std::fmt::Debug> From<SdkError<E>> for Error {
|
impl<E: std::fmt::Debug> From<SdkError<E>> for Error {
|
||||||
@@ -49,8 +51,11 @@ impl ResponseError for Error {
|
|||||||
Error::AlreadyExists => HttpResponse::BadRequest().json(ErrorResponse {
|
Error::AlreadyExists => HttpResponse::BadRequest().json(ErrorResponse {
|
||||||
error: "item already exists".to_string(),
|
error: "item already exists".to_string(),
|
||||||
}),
|
}),
|
||||||
Error::ValidationFailed(msg) => HttpResponse::BadRequest().json(ErrorResponse {
|
Error::ValidationFailed(msg) => {
|
||||||
error: msg.clone(),
|
HttpResponse::BadRequest().json(ErrorResponse { error: msg.clone() })
|
||||||
|
}
|
||||||
|
Error::NotFound => HttpResponse::NotFound().json(ErrorResponse {
|
||||||
|
error: "not found".to_string(),
|
||||||
}),
|
}),
|
||||||
_ => HttpResponse::InternalServerError().finish(),
|
_ => HttpResponse::InternalServerError().finish(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ async fn run() -> std::io::Result<()> {
|
|||||||
.finish(),
|
.finish(),
|
||||||
)
|
)
|
||||||
.wrap(tracing_actix_web::TracingLogger::default())
|
.wrap(tracing_actix_web::TracingLogger::default())
|
||||||
|
.wrap(actix_cors::Cors::permissive())
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
web::get()
|
web::get()
|
||||||
@@ -89,11 +90,15 @@ async fn run() -> std::io::Result<()> {
|
|||||||
.route(
|
.route(
|
||||||
"/repos",
|
"/repos",
|
||||||
web::get().to(endpoints::global_repos::global_repos),
|
web::get().to(endpoints::global_repos::global_repos),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/repos/{repo_id}/files/{file:.*}",
|
||||||
|
web::get().to(endpoints::proxy_file::proxy_file),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.bind(("127.0.0.1", 8080))?
|
.bind((env::var("ADDRESS").unwrap_or("0.0.0.0".to_string()), 8080))?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
115
api/src/user.rs
115
api/src/user.rs
@@ -6,6 +6,7 @@ use actix_web::HttpResponse;
|
|||||||
use aws_sdk_dynamodb::types::AttributeValue;
|
use aws_sdk_dynamodb::types::AttributeValue;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_dynamo::from_item;
|
||||||
use sqlx::{PgPool, query_scalar};
|
use sqlx::{PgPool, query_scalar};
|
||||||
|
|
||||||
pub struct UserRepository {
|
pub struct UserRepository {
|
||||||
@@ -14,10 +15,52 @@ pub struct UserRepository {
|
|||||||
table_name: String,
|
table_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RepositoryDB {
|
||||||
|
sk: String,
|
||||||
|
full_name: String,
|
||||||
|
owner_id: String,
|
||||||
|
description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
approved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryDB {
|
||||||
|
fn into_repository(self) -> Option<RepositoryDefinition> {
|
||||||
|
Some(RepositoryDefinition {
|
||||||
|
id: self.sk.strip_prefix("REPO#")?.to_string(),
|
||||||
|
full_name: self.full_name,
|
||||||
|
owner_id: self.owner_id,
|
||||||
|
description: self.description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_global_response(self) -> Option<GlobalRepositoriesResponse> {
|
||||||
|
if !self.approved {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GlobalRepositoriesResponse {
|
||||||
|
id: self.sk.strip_prefix("REPO#")?.to_string(),
|
||||||
|
full_name: self.full_name,
|
||||||
|
description: self.description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct RepositorySchema {
|
pub struct RepositoryDefinition {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub full_name: 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 {
|
impl UserRepository {
|
||||||
@@ -38,7 +81,7 @@ impl UserRepository {
|
|||||||
.map_err(|_| crate::error::Error::AccessToken)
|
.map_err(|_| crate::error::Error::AccessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_repositories(&self, user_id: &str) -> Result<Vec<RepositorySchema>> {
|
pub async fn get_repositories_user(&self, user_id: &str) -> Result<Vec<RepositoryDefinition>> {
|
||||||
let response = self
|
let response = self
|
||||||
.dynamodb_client
|
.dynamodb_client
|
||||||
.query()
|
.query()
|
||||||
@@ -47,50 +90,72 @@ impl UserRepository {
|
|||||||
.expression_attribute_values(":pk", AttributeValue::S(format!("USER#{user_id}")))
|
.expression_attribute_values(":pk", AttributeValue::S(format!("USER#{user_id}")))
|
||||||
.expression_attribute_values(":sk", AttributeValue::S("REPO#".into()))
|
.expression_attribute_values(":sk", AttributeValue::S("REPO#".into()))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
|
|
||||||
|
let repos = response
|
||||||
.items()
|
.items()
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
Some(RepositorySchema {
|
let dynamo_repo: RepositoryDB = from_item(item.clone()).ok()?;
|
||||||
id: item
|
dynamo_repo.into_repository()
|
||||||
.get("sk")?
|
|
||||||
.as_s()
|
|
||||||
.ok()?
|
|
||||||
.strip_prefix("REPO#")?
|
|
||||||
.to_string(),
|
|
||||||
full_name: item.get("full_name")?.as_s().ok()?.to_string(),
|
|
||||||
})
|
})
|
||||||
})
|
.collect();
|
||||||
.collect::<Vec<RepositorySchema>>();
|
|
||||||
|
|
||||||
Ok(response)
|
Ok(repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn global_repositories(&self) -> Result<HttpResponse> {
|
pub async fn global_repositories(&self) -> Result<HttpResponse> {
|
||||||
let response = self
|
let response = self
|
||||||
.dynamodb_client
|
.dynamodb_client
|
||||||
.query()
|
.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()))
|
.expression_attribute_values(":pk", AttributeValue::S("REPOS".into()))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
|
|
||||||
|
let repos: Vec<GlobalRepositoriesResponse> = response
|
||||||
.items()
|
.items()
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| {
|
.filter_map(|item| {
|
||||||
if (*item.get("approved")?.as_bool().ok()?) == false {
|
let dynamo_repo: RepositoryDB = from_item(item.clone()).ok()?;
|
||||||
return None;
|
dynamo_repo.into_global_response()
|
||||||
};
|
|
||||||
Some(item.get("full_name")?.as_s().ok()?.to_string())
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>();
|
.collect();
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(response))
|
Ok(HttpResponse::Ok().json(repos))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_approved_repository(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
) -> Result<Option<RepositoryDefinition>> {
|
||||||
|
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| {
|
||||||
|
let dynamo_repo: RepositoryDB = from_item(item.clone()).ok()?;
|
||||||
|
dynamo_repo.into_repository()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_repository(
|
pub async fn add_repository(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
repo: RepositorySchema,
|
repo: RepositoryDefinition,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let response = self
|
let response = self
|
||||||
@@ -104,7 +169,9 @@ impl UserRepository {
|
|||||||
.item("gsi1pk", AttributeValue::S("REPOS".into()))
|
.item("gsi1pk", AttributeValue::S("REPOS".into()))
|
||||||
.item("gsi1sk", AttributeValue::S(now.clone()))
|
.item("gsi1sk", AttributeValue::S(now.clone()))
|
||||||
.item("imported_at", AttributeValue::S(now))
|
.item("imported_at", AttributeValue::S(now))
|
||||||
.item("approved", AttributeValue::Bool(false))
|
.item("approved", AttributeValue::Bool(true))
|
||||||
|
.item("owner_id", AttributeValue::S(user_id.into()))
|
||||||
|
.item("description", AttributeValue::S(repo.description.clone()))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ use actix_web::web;
|
|||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::user::RepositorySchema;
|
use crate::user::RepositoryDefinition;
|
||||||
|
|
||||||
pub async fn validate_repo(app_state: web::Data<AppState>, repo: &RepositorySchema) -> Result<()> {
|
pub async fn validate_repo(
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
|
repo: &RepositoryDefinition,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let token = app_state.user.get_access_token(&user_id).await?;
|
||||||
let response = app_state
|
let response = app_state
|
||||||
.reqwest_client
|
.reqwest_client
|
||||||
.get(format!(
|
.get(format!(
|
||||||
"https://raw.githubusercontent.com/{}/HEAD/dist/index.html",
|
"https://raw.githubusercontent.com/{}/HEAD/dist/index.html",
|
||||||
repo.full_name
|
repo.full_name
|
||||||
))
|
))
|
||||||
|
.bearer_auth(token)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ import { GhostV2Stack } from '../lib/cdk-stack';
|
|||||||
const app = new cdk.App();
|
const app = new cdk.App();
|
||||||
|
|
||||||
new GhostV2Stack(app, "GhostV2Stack-dev", {
|
new GhostV2Stack(app, "GhostV2Stack-dev", {
|
||||||
env: { region: "ca-central-1", account: process.env.AWS_ACCOUNT_ID! },
|
env: { region: "ca-central-1", account: process.env.AWS_ACCOUNT_ID! }, environment: "dev"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new GhostV2Stack(app, "GhostV2Stack-prod", {
|
||||||
|
env: { region: "ca-central-1", account: process.env.AWS_ACCOUNT_ID!, }, environment: "prod"
|
||||||
|
})
|
||||||
|
|||||||
11
cdk/cdk.context.json
Normal file
11
cdk/cdk.context.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"availability-zones:account=585061171043:region=ca-central-1": [
|
||||||
|
"ca-central-1a",
|
||||||
|
"ca-central-1b",
|
||||||
|
"ca-central-1d"
|
||||||
|
],
|
||||||
|
"hosted-zone:account=585061171043:domainName=lucalise.ca:region=ca-central-1": {
|
||||||
|
"Id": "/hostedzone/Z0948300LINP3SX1WI4O",
|
||||||
|
"Name": "lucalise.ca."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,136 @@
|
|||||||
import * as cdk from 'aws-cdk-lib/core';
|
import * as cdk from "aws-cdk-lib/core";
|
||||||
import { Construct } from 'constructs';
|
import { Construct } from "constructs";
|
||||||
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
|
||||||
|
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
||||||
|
import * as ecs from "aws-cdk-lib/aws-ecs";
|
||||||
|
import * as ssm from "aws-cdk-lib/aws-ssm"
|
||||||
|
import * as acm from "aws-cdk-lib/aws-certificatemanager"
|
||||||
|
import * as route53 from "aws-cdk-lib/aws-route53"
|
||||||
|
import * as route53Targets from "aws-cdk-lib/aws-route53-targets"
|
||||||
|
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
interface StackProps extends cdk.StackProps {
|
||||||
|
environment: string
|
||||||
|
}
|
||||||
|
|
||||||
export class GhostV2Stack extends cdk.Stack {
|
export class GhostV2Stack extends cdk.Stack {
|
||||||
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
constructor(scope: Construct, id: string, props: StackProps) {
|
||||||
super(scope, id, props);
|
super(scope, id, props);
|
||||||
|
|
||||||
const table = new dynamodb.TableV2(this, "ghostv2-table", {
|
const table = new dynamodb.TableV2(this, "ghostv2-table", {
|
||||||
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
||||||
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
||||||
billing: dynamodb.Billing.onDemand(),
|
billing: dynamodb.Billing.onDemand(),
|
||||||
})
|
});
|
||||||
table.addGlobalSecondaryIndex({
|
table.addGlobalSecondaryIndex({
|
||||||
indexName: "gsi1",
|
indexName: "gsi1",
|
||||||
partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
|
partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
|
||||||
sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING }
|
sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
|
||||||
|
});
|
||||||
|
if (props.environment !== "prod") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpc = new ec2.Vpc(this, "ghostv2 vpc", {
|
||||||
|
maxAzs: 2,
|
||||||
|
natGateways: 0,
|
||||||
|
subnetConfiguration: [
|
||||||
|
{
|
||||||
|
name: "Public",
|
||||||
|
subnetType: ec2.SubnetType.PUBLIC,
|
||||||
|
cidrMask: 24,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ecsSecurityGroup = new ec2.SecurityGroup(this, "ghostv2 security group", {
|
||||||
|
vpc,
|
||||||
|
description: "Security group for ECS Fargate tasks",
|
||||||
|
allowAllOutbound: true,
|
||||||
|
});
|
||||||
|
ecsSecurityGroup.addIngressRule(
|
||||||
|
ec2.Peer.anyIpv4(),
|
||||||
|
ec2.Port.tcp(80),
|
||||||
|
"Allow HTTP"
|
||||||
|
);
|
||||||
|
ecsSecurityGroup.addIngressRule(
|
||||||
|
ec2.Peer.anyIpv4(),
|
||||||
|
ec2.Port.tcp(443),
|
||||||
|
"Allow HTTPS"
|
||||||
|
);
|
||||||
|
ecsSecurityGroup.addIngressRule(
|
||||||
|
ec2.Peer.anyIpv4(),
|
||||||
|
ec2.Port.tcp(8080),
|
||||||
|
"Allow API"
|
||||||
|
);
|
||||||
|
|
||||||
|
const cluster = new ecs.Cluster(this, "ghostv2 cluster", {
|
||||||
|
vpc,
|
||||||
|
});
|
||||||
|
const taskDefinition = new ecs.FargateTaskDefinition(
|
||||||
|
this,
|
||||||
|
"ghostv2 api",
|
||||||
|
{
|
||||||
|
memoryLimitMiB: 512,
|
||||||
|
cpu: 256,
|
||||||
|
runtimePlatform: {
|
||||||
|
cpuArchitecture: ecs.CpuArchitecture.X86_64,
|
||||||
|
operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const secretImports = ["DATABASE_URL", "GH_CLIENT_ID", "GH_CLIENT_SECRET", "REPOS_TABLE_NAME", "SENTRY_DSN", "FRONTEND_BASE_URL"];
|
||||||
|
const secrets = secretImports.reduce<Record<string, ecs.Secret>>((acc, name) => {
|
||||||
|
acc[name] = ecs.Secret.fromSsmParameter(ssm.StringParameter.fromSecureStringParameterAttributes(this, `param${name}`, {
|
||||||
|
parameterName: `/ghostv2/${props.environment}/${name}`
|
||||||
|
}))
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
taskDefinition.addContainer("api", {
|
||||||
|
image: ecs.ContainerImage.fromAsset(path.join(__dirname, "../../api")),
|
||||||
|
environment: {
|
||||||
|
RUST_LOG: "info",
|
||||||
|
},
|
||||||
|
portMappings: [
|
||||||
|
{
|
||||||
|
containerPort: 8080,
|
||||||
|
protocol: ecs.Protocol.TCP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
secrets
|
||||||
|
});
|
||||||
|
table.grantReadWriteData(taskDefinition.taskRole);
|
||||||
|
const service = new ecs.FargateService(this, "ghostv2 service", {
|
||||||
|
cluster,
|
||||||
|
taskDefinition,
|
||||||
|
desiredCount: 1,
|
||||||
|
assignPublicIp: true,
|
||||||
|
vpcSubnets: {
|
||||||
|
subnetType: ec2.SubnetType.PUBLIC,
|
||||||
|
},
|
||||||
|
securityGroups: [ecsSecurityGroup],
|
||||||
|
capacityProviderStrategies: [
|
||||||
|
{
|
||||||
|
capacityProvider: "FARGATE_SPOT",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const hostedZone = route53.HostedZone.fromLookup(this, "hosted zone", { domainName: "lucalise.ca" })
|
||||||
|
const certificate = new acm.Certificate(this, "ghostv2 api cert", {
|
||||||
|
domainName: "api.ghostv2.lucalise.ca",
|
||||||
|
validation: acm.CertificateValidation.fromDns(hostedZone)
|
||||||
})
|
})
|
||||||
|
const alb = new elbv2.ApplicationLoadBalancer(this, "ghostv2 alb", {
|
||||||
|
vpc,
|
||||||
|
internetFacing: true,
|
||||||
|
securityGroup: ecsSecurityGroup,
|
||||||
|
});
|
||||||
|
alb.setAttribute("idle_timeout.timeout_seconds", "120")
|
||||||
|
alb.addRedirect({ sourcePort: 80, sourceProtocol: elbv2.ApplicationProtocol.HTTP, targetPort: 443, targetProtocol: elbv2.ApplicationProtocol.HTTPS })
|
||||||
|
const listener = alb.addListener("https listener", { port: 443, open: true, certificates: [certificate] })
|
||||||
|
listener.addTargets("ECSTargets", { port: 8080, protocol: elbv2.ApplicationProtocol.HTTP, targets: [service], healthCheck: { path: "/", port: "8080" } })
|
||||||
|
new route53.ARecord(this, "alb record", { zone: hostedZone, recordName: "api.ghostv2.lucalise.ca", target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(alb)) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
scripts/import-env.sh
Executable file
27
scripts/import-env.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
PREFIX="/ghostv2"
|
||||||
|
REGION="ca-central-1"
|
||||||
|
WRITE="$1"
|
||||||
|
|
||||||
|
while getopts "w" opt; do
|
||||||
|
case $opt in
|
||||||
|
w) WRITE=true ;;
|
||||||
|
*) echo "Usage $0 [-w]" && exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
[[ -z "$key" || "$key" =~ ^# ]] && continue
|
||||||
|
|
||||||
|
echo "Creating parameter: $PREFIX/$key"
|
||||||
|
|
||||||
|
if [[ "$WRITE" == "true" ]]; then
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name "$PREFIX/$key" \
|
||||||
|
--value "$value" \
|
||||||
|
--type SecureString \
|
||||||
|
--overwrite \
|
||||||
|
--region "$REGION" > /dev/null
|
||||||
|
fi
|
||||||
|
done < api/.env
|
||||||
9
src/lib/Footer.svelte
Normal file
9
src/lib/Footer.svelte
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="mt-24 flex justify-center border-t border-t-gray-800 p-2">
|
||||||
|
<a
|
||||||
|
class="rounded-sm p-2 transition-colors hover:bg-surface-muted"
|
||||||
|
href="https://git.lucalise.ca/lucalise/ghostv2"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-text-muted">Source & Attribution</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Avatar, Button, DropdownMenu, Separator } from 'bits-ui';
|
import { Avatar, Button, DropdownMenu, Separator } from 'bits-ui';
|
||||||
import { authClient } from './auth-client';
|
import { authClient } from './auth-client';
|
||||||
import Image from './Image.svelte';
|
import Image from './Image.svelte';
|
||||||
import { CircleUser, FolderGit2, User } from '@lucide/svelte';
|
import { CircleUser, FolderGit2, Loader2, User } from '@lucide/svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
type MockUser = {
|
type MockUser = {
|
||||||
image: string;
|
image: string;
|
||||||
@@ -10,22 +10,31 @@
|
|||||||
|
|
||||||
const session = authClient.useSession();
|
const session = authClient.useSession();
|
||||||
const user = $derived($session.data?.user);
|
const user = $derived($session.data?.user);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
const signIn = async () => {
|
const signIn = async () => {
|
||||||
await authClient.signIn.social({
|
loading = true;
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
provider: 'github'
|
provider: 'github'
|
||||||
});
|
});
|
||||||
|
if (error) {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-14 w-full bg-[#292e42]">
|
<div class="flex h-14 w-full items-center bg-[#292e42] sm:p-2">
|
||||||
<div class="mx-auto flex h-full max-w-[78rem] items-center justify-between">
|
<div class="mx-auto flex h-full max-w-312 flex-1 items-center justify-between">
|
||||||
<Button.Root onclick={() => goto('/')}>
|
<Button.Root onclick={() => goto('/')}>
|
||||||
<h1 class="header-title font-light">Godot Host</h1>
|
<h1 class="header-title font-light">Project Host</h1>
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
{#if !user && !$session.isPending}
|
{#if !user && !$session.isPending}
|
||||||
<Button.Root class="rounded-md p-2 transition-colors hover:bg-gray-800" onclick={signIn}>
|
<Button.Root class="rounded-md p-2 transition-colors hover:bg-gray-800" onclick={signIn}>
|
||||||
|
{#if loading}
|
||||||
|
<Loader2 class="animate-spin" />
|
||||||
|
{:else}
|
||||||
Sign In
|
Sign In
|
||||||
|
{/if}
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
{:else if user}
|
{:else if user}
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
type ImageStatus = 'loading' | 'loaded' | 'error';
|
type ImageStatus = 'loading' | 'loaded' | 'error';
|
||||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let { src, ...props }: HTMLImgAttributes = $props();
|
let { src, fallback, ...props }: HTMLImgAttributes & { fallback?: string } = $props();
|
||||||
let status: ImageStatus = $state('loading');
|
let status: ImageStatus = $state('loading');
|
||||||
|
let useFallback = $state(false);
|
||||||
|
|
||||||
|
const currentSrc = $derived(useFallback && fallback ? fallback : src);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if status === 'loading'}
|
{#if status === 'loading'}
|
||||||
@@ -11,11 +14,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<img
|
<img
|
||||||
{src}
|
src={currentSrc}
|
||||||
hidden={status === 'loading'}
|
hidden={status === 'loading'}
|
||||||
onload={() => {
|
onload={() => {
|
||||||
status = 'loaded';
|
status = 'loaded';
|
||||||
}}
|
}}
|
||||||
onerror={() => (status = 'error')}
|
onerror={() => {
|
||||||
|
if (!useFallback && fallback) {
|
||||||
|
useFallback = true;
|
||||||
|
status = 'loading';
|
||||||
|
} else {
|
||||||
|
status = 'error';
|
||||||
|
}
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,61 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Play } from '@lucide/svelte';
|
import { Play, LoaderCircle } from '@lucide/svelte';
|
||||||
import { Button } from 'bits-ui';
|
import { Button } from 'bits-ui';
|
||||||
import type { Project } from './types/project';
|
import type { Project, RepoDefinition } from './types/project';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import Image from './Image.svelte';
|
import Image from './Image.svelte';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
|
||||||
const mockProjects: Project[] = [
|
const reposQuery = createQuery<RepoDefinition[]>(() => ({
|
||||||
{
|
queryKey: ['global-repos'],
|
||||||
id: 1,
|
queryFn: async () => {
|
||||||
name: 'Uno',
|
const response = await fetch('/api/v0/repos');
|
||||||
description: 'Recreation of the classic card game',
|
if (!response.ok) {
|
||||||
thumbnail: 'https://picsum.photos/seed/uno/400/225',
|
throw new Error('Failed to fetch repositories');
|
||||||
url: '/game/index.html'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Space Invaders',
|
|
||||||
description: 'Retro arcade shooter',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/space/400/225'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Tetris',
|
|
||||||
description: 'Block stacking puzzle game',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/tetris/400/225'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Snake',
|
|
||||||
description: 'Classic snake game with power-ups',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/snake/400/225'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Pong',
|
|
||||||
description: 'Two-player paddle game',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/pong/400/225'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: 'Breakout',
|
|
||||||
description: 'Brick-breaking arcade game',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/breakout/400/225'
|
|
||||||
}
|
}
|
||||||
];
|
return response.json();
|
||||||
|
},
|
||||||
|
staleTime: 60000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projects = $derived(reposQuery.data ?? []);
|
||||||
|
const isLoading = $derived(reposQuery.isPending);
|
||||||
|
const hasError = $derived(reposQuery.isError);
|
||||||
|
const isEmpty = $derived(!isLoading && !hasError && projects.length === 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<LoaderCircle class="h-8 w-8 animate-spin text-text-muted" />
|
||||||
|
</div>
|
||||||
|
{:else if hasError}
|
||||||
|
<div class="py-12 text-center text-text-muted">
|
||||||
|
<p>Failed to load projects. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
{:else if isEmpty}
|
||||||
|
<div class="py-12 text-center text-text-muted">
|
||||||
|
<p>No projects available yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each mockProjects as project (project.id)}
|
{#each projects as project (project.id)}
|
||||||
<div
|
<div
|
||||||
class="group relative overflow-hidden rounded-lg bg-gray-900 shadow-xl ring-1 ring-gray-800 transition-transform hover:scale-[1.02]"
|
class="group relative overflow-hidden rounded-lg bg-gray-900 shadow-xl ring-1 ring-gray-800 transition-transform hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
<div class="relative aspect-video overflow-hidden">
|
<div class="relative aspect-video overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={project.thumbnail}
|
src={`/api/v0/repos/${project.id}/files/thumbnail.webp`}
|
||||||
alt={project.name}
|
fallback={`https://picsum.photos/seed/${project.id}/400/225`}
|
||||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
alt={project.full_name}
|
||||||
|
class="min-h-56.5 w-full min-w-100 object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -75,9 +67,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h3 class="text-lg font-semibold text-white">{project.name}</h3>
|
<h3 class="text-lg font-semibold text-white">{project.full_name}</h3>
|
||||||
<p class="mt-1 text-sm text-gray-400">{project.description}</p>
|
<p class="mt-1 text-sm text-gray-400">{project.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from 'bits-ui';
|
import { AlertDialog, Button } from 'bits-ui';
|
||||||
import { GitBranch, Star, Clock, Import, Search, RefreshCw, LoaderCircle } from '@lucide/svelte';
|
import { GitBranch, Star, Clock, Import, Search, RefreshCw, LoaderCircle } from '@lucide/svelte';
|
||||||
import type { Repository } from './types/repo';
|
import type { Repository } from './types/repo';
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
@@ -59,12 +59,25 @@
|
|||||||
const repos = $derived(searching ? (searchResultsQuery.data ?? []) : (query.data ?? []));
|
const repos = $derived(searching ? (searchResultsQuery.data ?? []) : (query.data ?? []));
|
||||||
const isPending = $derived(searching ? searchResultsQuery.isPending : query.isPending);
|
const isPending = $derived(searching ? searchResultsQuery.isPending : query.isPending);
|
||||||
|
|
||||||
const handleImport = async (repo: Repository) => {
|
let dialogOpen = $state(false);
|
||||||
|
let selectedRepo = $state<Repository | null>(null);
|
||||||
|
let description = $state('');
|
||||||
|
|
||||||
|
const openDialog = (repo: Repository) => {
|
||||||
if (added.includes(repo.id)) {
|
if (added.includes(repo.id)) {
|
||||||
toast.warning('Repository already imported');
|
toast.warning('Repository already imported');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
adding = repo.id;
|
selectedRepo = repo;
|
||||||
|
description = repo.description ?? '';
|
||||||
|
dialogOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!selectedRepo) return;
|
||||||
|
adding = selectedRepo.id;
|
||||||
|
dialogOpen = false;
|
||||||
|
|
||||||
let response = await fetch('/api/v0/user/repo/add', {
|
let response = await fetch('/api/v0/user/repo/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -72,8 +85,9 @@
|
|||||||
Authorization: (await apiClient.getToken()) ?? ''
|
Authorization: (await apiClient.getToken()) ?? ''
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: repo.id.toString(),
|
id: selectedRepo.id.toString(),
|
||||||
full_name: repo.full_name
|
full_name: selectedRepo.full_name,
|
||||||
|
description
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -84,8 +98,10 @@
|
|||||||
} else {
|
} else {
|
||||||
toast.success('Successfully added repository');
|
toast.success('Successfully added repository');
|
||||||
}
|
}
|
||||||
added.push(repo.id);
|
added.push(selectedRepo.id);
|
||||||
adding = null;
|
adding = null;
|
||||||
|
selectedRepo = null;
|
||||||
|
description = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const languageColors: Record<string, string> = {
|
const languageColors: Record<string, string> = {
|
||||||
@@ -99,7 +115,6 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<GitBranch class="h-6 w-6 text-icon" />
|
<GitBranch class="h-6 w-6 text-icon" />
|
||||||
<h2 class="text-xl font-semibold text-text-primary">Import Git Repository</h2>
|
<h2 class="text-xl font-semibold text-text-primary">Import Git Repository</h2>
|
||||||
<span class="text-xs text-text-muted">repo must be public</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Button.Root
|
<Button.Root
|
||||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
|
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
|
||||||
@@ -161,7 +176,7 @@
|
|||||||
? 'border-success-border bg-success'
|
? 'border-success-border bg-success'
|
||||||
: 'bg-primary'} disabled:cursor-default disabled:opacity-50"
|
: 'bg-primary'} disabled:cursor-default disabled:opacity-50"
|
||||||
disabled={adding !== null}
|
disabled={adding !== null}
|
||||||
onclick={() => handleImport(repo)}
|
onclick={() => openDialog(repo)}
|
||||||
>
|
>
|
||||||
{#if adding === repo.id}
|
{#if adding === repo.id}
|
||||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||||
@@ -189,3 +204,52 @@
|
|||||||
You must have a dist/ folder with index.html + index.wasm
|
You must have a dist/ folder with index.html + index.wasm
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog.Root bind:open={dialogOpen}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay class="fixed inset-0 z-50 bg-black/80" />
|
||||||
|
<AlertDialog.Content
|
||||||
|
class="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-surface p-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<AlertDialog.Title class="text-lg font-semibold text-text-primary">
|
||||||
|
Add Repository
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description class="text-sm text-text-secondary">
|
||||||
|
{#if selectedRepo}
|
||||||
|
You are about to add <span class="font-semibold text-text-primary"
|
||||||
|
>{selectedRepo.full_name}</span
|
||||||
|
>. Please provide a description for your project.
|
||||||
|
{/if}
|
||||||
|
</AlertDialog.Description>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label for="description" class="mb-2 block text-sm font-medium text-text-primary">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Describe your project..."
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-lg border border-border-hover bg-surface-hover px-3 py-2 text-text-primary placeholder-text-muted transition-colors outline-none focus:border-border-focus focus:ring-1 focus:ring-border-focus"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<AlertDialog.Cancel
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-primary/90"
|
||||||
|
onclick={handleImport}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { jwtClient, adminClient } from 'better-auth/client/plugins';
|
|||||||
import { createAuthClient } from 'better-auth/svelte';
|
import { createAuthClient } from 'better-auth/svelte';
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: 'http://localhost:5173',
|
baseURL: process.env.BETTER_AUTH_URL!,
|
||||||
plugins: [jwtClient(), adminClient()]
|
plugins: [jwtClient(), adminClient()]
|
||||||
});
|
});
|
||||||
|
|||||||
28
src/lib/project/resolve.ts
Normal file
28
src/lib/project/resolve.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Project, RepoDefinition } from "$lib/types/project";
|
||||||
|
|
||||||
|
export async function resolveProjectData(repo: RepoDefinition): Promise<Project> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
export type Project = {
|
export type Project = {
|
||||||
id: number;
|
id: string;
|
||||||
|
full_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
url?: string;
|
};
|
||||||
|
|
||||||
|
export type RepoDefinition = {
|
||||||
|
id: string;
|
||||||
|
full_name: string;
|
||||||
|
description: string
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,16 +6,22 @@
|
|||||||
import NProgress from 'nprogress';
|
import NProgress from 'nprogress';
|
||||||
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
|
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
import Footer from '$lib/Footer.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
NProgress.configure({ showSpinner: false });
|
NProgress.configure({ showSpinner: false });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header />
|
|
||||||
<Toaster position="top-center" theme="dark" />
|
<Toaster position="top-center" theme="dark" />
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<div class="flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<main class="flex-1">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</main>
|
||||||
<SvelteQueryDevtools />
|
<SvelteQueryDevtools />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import Projects from '$lib/Projects.svelte';
|
import Projects from '$lib/Projects.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="mt-8 flex flex-col items-center justify-center">
|
<section class="mt-8 flex flex-1 flex-col items-center justify-center">
|
||||||
<h1 class="flex justify-center text-2xl font-semibold">Projects</h1>
|
<h1 class="flex justify-center text-2xl font-semibold">Projects</h1>
|
||||||
|
|
||||||
<section class="m-4 mt-4 max-w-4xl border-t-4 border-gray-700/80 p-4 px-12 pt-6">
|
<section class="m-4 mt-4 max-w-4xl border-t-4 border-gray-700/80 p-4 px-12 pt-6">
|
||||||
<Projects />
|
<Projects />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
import Repos from '$lib/Repos.svelte';
|
import Repos from '$lib/Repos.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="mx-auto mt-8 max-w-3xl px-4">
|
<section class="mx-auto mt-8 max-w-3xl px-4">
|
||||||
<Repos />
|
<Repos />
|
||||||
</main>
|
</section>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: dark;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
/* background-color: #242424; */
|
/* background-color: #242424; */
|
||||||
|
|
||||||
@@ -151,17 +151,25 @@ button:focus-visible {
|
|||||||
transform-origin: top;
|
transform-origin: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
[data-alert-dialog-content][data-state="open"] {
|
||||||
:root {
|
animation: grow-in 150ms ease-out;
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:default:hover {
|
[data-alert-dialog-content][data-state="closed"] {
|
||||||
color: #747bff;
|
animation: grow-out 150ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:default {
|
/* @media (prefers-color-scheme: light) { */
|
||||||
background-color: #f9f9f9;
|
/* :root { */
|
||||||
}
|
/* color: #213547; */
|
||||||
}
|
/* background-color: #ffffff; */
|
||||||
|
/* } */
|
||||||
|
/**/
|
||||||
|
/* a:default:hover { */
|
||||||
|
/* color: #747bff; */
|
||||||
|
/* } */
|
||||||
|
/**/
|
||||||
|
/* button:default { */
|
||||||
|
/* background-color: #f9f9f9; */
|
||||||
|
/* } */
|
||||||
|
/* } */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { Maximize, Minimize, ArrowLeft } from '@lucide/svelte';
|
import { Maximize, Minimize, ArrowLeft } from '@lucide/svelte';
|
||||||
|
|
||||||
const id = $derived(page.params.id);
|
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 isFullscreen = $state(false);
|
||||||
let containerRef = $state<HTMLDivElement | null>(null);
|
let containerRef = $state<HTMLDivElement | null>(null);
|
||||||
@@ -16,11 +16,8 @@
|
|||||||
|
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
await containerRef.requestFullscreen();
|
await containerRef.requestFullscreen();
|
||||||
isFullscreen = true;
|
|
||||||
iframeRef?.focus();
|
|
||||||
} else {
|
} else {
|
||||||
await document.exitFullscreen();
|
await document.exitFullscreen();
|
||||||
isFullscreen = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,8 +41,8 @@
|
|||||||
<svelte:window onkeydown={minimize} />
|
<svelte:window onkeydown={minimize} />
|
||||||
<svelte:document onfullscreenchange={handleFullscreenChange} />
|
<svelte:document onfullscreenchange={handleFullscreenChange} />
|
||||||
|
|
||||||
<main class="flex min-h-screen flex-col items-center px-4 py-8">
|
<section class="flex min-h-screen flex-col items-center px-4 py-8">
|
||||||
<div class="relative mb-6 flex w-full max-w-4xl items-center justify-center">
|
<div class="relative mb-6 flex w-full max-w-6xl items-center justify-center">
|
||||||
<Button.Root
|
<Button.Root
|
||||||
class="absolute left-0 flex items-center gap-2 rounded-md px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
class="absolute left-0 flex items-center gap-2 rounded-md px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||||
onclick={() => goto('/')}
|
onclick={() => goto('/')}
|
||||||
@@ -54,17 +51,17 @@
|
|||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</Button.Root>
|
</Button.Root>
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold text-white">Project {id}</h1>
|
<h1 class="text-xl font-semibold text-white"></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={containerRef}
|
bind:this={containerRef}
|
||||||
class="relative w-full max-w-4xl overflow-hidden rounded-md bg-black shadow-2xl ring-1 ring-gray-700/80"
|
class="relative mt-2 w-full max-w-6xl overflow-hidden rounded-md bg-black shadow-2xl ring-1 ring-gray-700/80"
|
||||||
class:max-w-none={isFullscreen}
|
class:max-w-none={isFullscreen}
|
||||||
class:h-screen={isFullscreen}
|
class:h-screen={isFullscreen}
|
||||||
class:rounded-none={isFullscreen}
|
class:rounded-none={isFullscreen}
|
||||||
>
|
>
|
||||||
<div class="aspect-video" class:aspect-auto={isFullscreen} class:h-full={isFullscreen}>
|
<div class="aspect-1152/648" class:aspect-auto={isFullscreen} class:h-full={isFullscreen}>
|
||||||
<iframe
|
<iframe
|
||||||
bind:this={iframeRef}
|
bind:this={iframeRef}
|
||||||
{src}
|
{src}
|
||||||
@@ -87,4 +84,4 @@
|
|||||||
</Button.Root>
|
</Button.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { GitBranch, Search } from '@lucide/svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="mx-auto mt-8 max-w-3xl rounded-xl bg-surface p-4">
|
|
||||||
<div class="flex items-center gap-3 p-6">
|
|
||||||
<GitBranch />
|
|
||||||
<h2 class="text-xl font-semibold">Repositories</h2>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<input
|
|
||||||
class="w-full rounded-lg border border-border-hover bg-surface-hover py-2 pl-10 outline-none focus:border-border-focus focus:ring focus:ring-border-focus"
|
|
||||||
placeholder="Search for repositories..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
8
vercel.json
Normal file
8
vercel.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/api/v0/:path*",
|
||||||
|
"destination": "https://api.ghostv2.lucalise.ca/api/v0/:path*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user