From f4ae81fcf722d262b5b6a33611bbde9e1ae811a6 Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Fri, 27 Mar 2026 04:06:50 +0100 Subject: [PATCH 1/4] feat: implement issues 24, 25, 34, 41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 24 — Backend Axum setup & DB pooling: - Add /api/health endpoint that pings the Postgres pool - Add /api/v1/users stub route - Restructure all routes under /api/v1 prefix Issue 25 — IPFS off-chain storage: - Add services/ipfs.rs with Pinata API integration - Add POST /api/v1/uploads multipart endpoint (10 MiB cap, MIME allowlist) - Enable axum multipart + reqwest multipart features Issue 34 — Client Escrow Funding Interface: - Add apps/web/app/jobs/[id]/fund/page.tsx - Funding summary modal with fee breakdown (2% platform fee) - Double-confirmation modal + caution alert before wallet sign - On-chain polling loop until job transitions to funded/in_progress - Frontend api.ts: updated to v1 routes + uploads.pin helper Issue 41 — CI/CD GitHub Actions: - Separate jobs for contracts-clippy, contracts-test, backend-clippy, backend-test, frontend-lint, frontend-build, e2e - Swatinem/rust-cache on every Rust job to preserve target/ - actions/cache for Playwright browsers - npm ci (deterministic) instead of npm install - TypeScript type-check step in frontend-lint job Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 74 ++++++-- Cargo.lock | 122 +++++++++++++ Cargo.toml | 5 +- apps/web/app/jobs/[id]/fund/page.tsx | 255 +++++++++++++++++++++++++++ apps/web/lib/api.ts | 31 +++- backend/Cargo.toml | 1 + backend/src/routes/health.rs | 17 ++ backend/src/routes/mod.rs | 20 ++- backend/src/routes/uploads.rs | 62 +++++++ backend/src/routes/users.rs | 12 ++ backend/src/services/ipfs.rs | 85 +++++++++ backend/src/services/mod.rs | 1 + 12 files changed, 659 insertions(+), 26 deletions(-) create mode 100644 apps/web/app/jobs/[id]/fund/page.tsx create mode 100644 backend/src/routes/health.rs create mode 100644 backend/src/routes/uploads.rs create mode 100644 backend/src/routes/users.rs create mode 100644 backend/src/services/ipfs.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab6293b..8b207cfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,8 @@ on: pull_request: jobs: - lint-contracts: + # ── Soroban Contracts ──────────────────────────────────────────────────────── + contracts-clippy: name: Contracts — Clippy runs-on: ubuntu-latest steps: @@ -15,9 +16,28 @@ jobs: with: targets: wasm32-unknown-unknown components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + key: contracts-clippy - run: cargo clippy -p escrow -p reputation -p job_registry -- -D warnings - lint-backend: + contracts-test: + name: Contracts — Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + key: contracts-test + - run: cargo test -p escrow -p reputation -p job_registry + + # ── Rust Backend ───────────────────────────────────────────────────────────── + backend-clippy: name: Backend — Clippy runs-on: ubuntu-latest steps: @@ -25,10 +45,14 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + key: backend-clippy - run: cargo clippy -p backend -- -D warnings - test-backend: - name: Backend — Tests + backend-test: + name: Backend — Integration Tests runs-on: ubuntu-latest services: postgres: @@ -46,13 +70,19 @@ jobs: - 5432:5432 env: DATABASE_URL: postgres://lance:lance@localhost:5432/lance + PINATA_JWT: test-token steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + key: backend-test - run: cargo test -p backend - lint-frontend: - name: Frontend — ESLint + # ── Next.js Frontend ───────────────────────────────────────────────────────── + frontend-lint: + name: Frontend — ESLint & TypeScript runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -61,11 +91,13 @@ jobs: node-version: 20 cache: npm cache-dependency-path: apps/web/package-lock.json - - run: npm install --prefix apps/web + - run: npm ci --prefix apps/web - run: npm run lint --prefix apps/web + - name: TypeScript type check + run: npx --prefix apps/web tsc --noEmit - build-frontend: - name: Frontend — Build & E2E + frontend-build: + name: Frontend — Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -73,8 +105,28 @@ jobs: with: node-version: 20 cache: npm - - run: npm install - - run: npm install --prefix apps/web + cache-dependency-path: apps/web/package-lock.json + - run: npm ci --prefix apps/web + - run: npm run build --prefix apps/web + + e2e: + name: Frontend — E2E (Playwright) + runs-on: ubuntu-latest + needs: frontend-build + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm ci --prefix apps/web + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: playwright-${{ runner.os }}- - run: npx playwright install --with-deps - run: npm run build --prefix apps/web - run: npm run test:e2e diff --git a/Cargo.lock b/Cargo.lock index b183c96f..14d16cb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -221,11 +222,15 @@ dependencies = [ "anyhow", "axum", "axum-test", + "base64 0.22.1", + "bytes", "chrono", "dotenvy", + "ed25519-dalek", "reqwest", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", "tokio", @@ -234,6 +239,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "wiremock", ] [[package]] @@ -591,6 +597,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -871,6 +895,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -915,6 +954,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -933,8 +983,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1082,6 +1134,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1646,6 +1704,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1755,6 +1830,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -2066,6 +2151,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2100,6 +2197,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http 1.4.0", "http-body", @@ -2111,6 +2209,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -3938,6 +4037,29 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 28fdbb36..78d239d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,14 @@ resolver = "2" soroban-sdk = { version = "21.0.0" } stellar-xdr = "21.0.0" tokio = { version = "1", features = ["full"] } -axum = { version = "0.7", features = ["macros"] } +axum = { version = "0.7", features = ["macros", "multipart"] } +bytes = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid", "chrono"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "multipart"] } tracing = "0.1" tracing-subscriber= { version = "0.3", features = ["env-filter"] } anyhow = "1" diff --git a/apps/web/app/jobs/[id]/fund/page.tsx b/apps/web/app/jobs/[id]/fund/page.tsx new file mode 100644 index 00000000..4dcfacc8 --- /dev/null +++ b/apps/web/app/jobs/[id]/fund/page.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { api, type Job } from "@/lib/api"; +import { depositEscrow } from "@/lib/contracts"; + +// Platform fee: 2% (200 bps) +const PLATFORM_FEE_BPS = 200; +// Micro-USDC per USDC (7 decimal places) +const MICRO_USDC = 10_000_000; + +function formatUsdc(micro: number): string { + return (micro / MICRO_USDC).toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + }); +} + +type FundingState = "idle" | "confirming" | "signing" | "polling" | "funded" | "error"; + +export default function EscrowFundingPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + + const [job, setJob] = useState(null); + const [loadError, setLoadError] = useState(null); + const [fundingState, setFundingState] = useState("idle"); + const [txHash, setTxHash] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [checked, setChecked] = useState(false); + + useEffect(() => { + api.jobs.get(id).then(setJob).catch((e: Error) => setLoadError(e.message)); + }, [id]); + + const platformFee = job ? Math.floor((job.budget_usdc * PLATFORM_FEE_BPS) / 10_000) : 0; + const total = job ? job.budget_usdc + platformFee : 0; + + const handleFund = useCallback(async () => { + if (!job) return; + setFundingState("signing"); + setErrorMsg(null); + try { + const hash = await depositEscrow({ + jobId: BigInt(job.on_chain_job_id ?? 0), + clientAddress: job.client_address, + freelancerAddress: job.freelancer_address ?? "", + amountUsdc: BigInt(total), + milestones: job.milestones, + }); + setTxHash(hash); + setFundingState("polling"); + + // Poll job status until it transitions to in_progress / funded + let attempts = 0; + const interval = setInterval(async () => { + attempts++; + try { + const updated = await api.jobs.get(id); + if (updated.status === "in_progress" || updated.status === "funded") { + clearInterval(interval); + setJob(updated); + setFundingState("funded"); + } + } catch { + // ignore transient errors during polling + } + if (attempts >= 30) { + clearInterval(interval); + // Even if we can't confirm status, tx was submitted + setFundingState("funded"); + } + }, 2000); + } catch (e) { + setErrorMsg(e instanceof Error ? e.message : "Unknown error"); + setFundingState("error"); + } + }, [job, total, id]); + + if (loadError) { + return ( +
+

Failed to load job: {loadError}

+
+ ); + } + + if (!job) { + return ( +
+

Loading job details…

+
+ ); + } + + if (fundingState === "funded") { + return ( +
+
+

Escrow Funded!

+

+ {formatUsdc(total)} is now locked on-chain. +

+ {txHash && ( +

+ Transaction: {txHash} +

+ )} +

+ Both you and the freelancer can now see the job as "Actively Funded". +

+ +
+
+ ); + } + + return ( +
+

Fund Escrow

+

+ Review the breakdown carefully before authorising the transfer. +

+ + {/* Summary card */} +
+

Escrow Funding Summary

+ +
+
+ Job + {job.title} +
+
+ Milestones + {job.milestones} +
+
+ Contract value + {formatUsdc(job.budget_usdc)} +
+
+ Platform fee (2%) + {formatUsdc(platformFee)} +
+
+ Total to deposit + {formatUsdc(total)} +
+
+ + {/* Freelancer address */} + {job.freelancer_address && ( +

+ Freelancer: {job.freelancer_address} +

+ )} +
+ + {/* Caution banner */} +
+ Caution: Once funds are deposited into the smart-contract escrow they can + only be released by milestone approval or a dispute verdict. This action cannot be undone. +
+ + {/* Confirmation checkbox */} + + + {/* Error display */} + {fundingState === "error" && errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* CTA button */} + + + {/* Final confirmation modal */} + {fundingState === "confirming" && ( +
+
+

Final Confirmation

+

+ You are about to transfer{" "} + {formatUsdc(total)} (including 2% + platform fee) into the escrow smart contract for: +

+

{job.title}

+

+ This is a blockchain transaction. Make sure your wallet is connected and you have + sufficient USDC balance. +

+
+ + +
+
+
+ )} + + {/* Signing / polling overlay */} + {(fundingState === "signing" || fundingState === "polling") && ( +
+
+
+

+ {fundingState === "signing" + ? "Waiting for wallet signature…" + : "Broadcasting transaction… confirming on-chain"} +

+ {txHash && ( +

tx: {txHash}

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 673baa7b..361ab9b7 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -11,33 +11,45 @@ async function request(path: string, init?: RequestInit): Promise { export const api = { jobs: { - list: () => request("/jobs"), - get: (id: string) => request(`/jobs/${id}`), + list: () => request("/v1/jobs"), + get: (id: string) => request(`/v1/jobs/${id}`), create: (body: CreateJobBody) => - request("/jobs", { method: "POST", body: JSON.stringify(body) }), + request("/v1/jobs", { method: "POST", body: JSON.stringify(body) }), }, bids: { - list: (jobId: string) => request(`/jobs/${jobId}/bids`), + list: (jobId: string) => request(`/v1/jobs/${jobId}/bids`), create: (jobId: string, body: CreateBidBody) => - request(`/jobs/${jobId}/bids`, { + request(`/v1/jobs/${jobId}/bids`, { method: "POST", body: JSON.stringify(body), }), }, disputes: { open: (jobId: string, body: { opened_by: string }) => - request(`/jobs/${jobId}/dispute`, { + request(`/v1/jobs/${jobId}/dispute`, { method: "POST", body: JSON.stringify(body), }), - get: (id: string) => request(`/disputes/${id}`), - verdict: (id: string) => request(`/disputes/${id}/verdict`), + get: (id: string) => request(`/v1/disputes/${id}`), + verdict: (id: string) => request(`/v1/disputes/${id}/verdict`), submitEvidence: (id: string, body: EvidenceBody) => - request(`/disputes/${id}/evidence`, { + request(`/v1/disputes/${id}/evidence`, { method: "POST", body: JSON.stringify(body), }), }, + uploads: { + pin: (file: File): Promise<{ cid: string; filename: string }> => { + const form = new FormData(); + form.append("file", file); + return fetch(`${API}/api/v1/uploads`, { method: "POST", body: form }).then( + async (res) => { + if (!res.ok) throw new Error(await res.text()); + return res.json(); + } + ); + }, + }, }; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -52,6 +64,7 @@ export interface Job { freelancer_address?: string; status: string; metadata_hash?: string; + on_chain_job_id?: number; created_at: string; updated_at: string; } diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 31288f8b..dd8eb094 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -23,6 +23,7 @@ thiserror = { workspace = true } dotenvy = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } +bytes = { workspace = true } base64 = "0.22" sha2 = "0.10" ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs new file mode 100644 index 00000000..15b922b0 --- /dev/null +++ b/backend/src/routes/health.rs @@ -0,0 +1,17 @@ +use axum::{extract::State, http::StatusCode, Json}; +use serde_json::{json, Value}; + +use crate::db::AppState; + +pub async fn health(State(state): State) -> (StatusCode, Json) { + match sqlx::query("SELECT 1").execute(&state.pool).await { + Ok(_) => ( + StatusCode::OK, + Json(json!({ "status": "ok", "db": "connected" })), + ), + Err(e) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "status": "degraded", "db": e.to_string() })), + ), + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 54440610..26a203cd 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,16 +2,28 @@ pub mod appeals; pub mod bids; pub mod disputes; pub mod evidence; +pub mod health; pub mod jobs; pub mod milestones; +pub mod uploads; +pub mod users; pub mod verdicts; -use axum::Router; +use axum::{routing::get, Router}; use crate::db::AppState; pub fn api_router() -> Router { Router::new() - .nest("/jobs", jobs::router()) - .nest("/disputes", disputes::router()) - .nest("/appeals", appeals::router()) + // health check — outside versioned prefix so load balancers can reach it + .route("/health", get(health::health)) + // v1 API routes + .nest( + "/v1", + Router::new() + .nest("/jobs", jobs::router()) + .nest("/disputes", disputes::router()) + .nest("/appeals", appeals::router()) + .nest("/users", users::router()) + .nest("/uploads", uploads::router()), + ) } diff --git a/backend/src/routes/uploads.rs b/backend/src/routes/uploads.rs new file mode 100644 index 00000000..e26e4e65 --- /dev/null +++ b/backend/src/routes/uploads.rs @@ -0,0 +1,62 @@ +//! POST /api/v1/uploads — multipart file upload → IPFS pin → return CID. + +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + routing::post, + Json, Router, +}; +use reqwest::Client; +use serde_json::{json, Value}; + +use crate::{db::AppState, error::AppError, services::ipfs}; + +pub fn router() -> Router { + Router::new().route("/", post(upload_file)) +} + +async fn upload_file( + State(_state): State, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), AppError> { + let client = Client::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(e.to_string()))? + { + let filename = field + .file_name() + .unwrap_or("upload") + .to_owned(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_owned(); + + let data: Vec = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(e.to_string()))? + .to_vec(); + + if data.len() > ipfs::MAX_UPLOAD_BYTES { + return Err(AppError::BadRequest(format!( + "file exceeds {} MiB limit", + ipfs::MAX_UPLOAD_BYTES / 1024 / 1024 + ))); + } + + let cid = ipfs::pin_to_ipfs(&client, data, &filename, &content_type) + .await + .map_err(|e| AppError::BadRequest(e.to_string()))?; + + return Ok(( + StatusCode::CREATED, + Json(json!({ "cid": cid, "filename": filename })), + )); + } + + Err(AppError::BadRequest("no file field found in multipart body".into())) +} diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs new file mode 100644 index 00000000..9ef7df2f --- /dev/null +++ b/backend/src/routes/users.rs @@ -0,0 +1,12 @@ +use axum::{routing::get, Router}; + +use crate::db::AppState; + +pub fn router() -> Router { + Router::new().route("/", get(list_users)) +} + +/// GET /api/v1/users — stub; returns empty list until auth/profile system is built. +async fn list_users() -> axum::Json> { + axum::Json(vec![]) +} diff --git a/backend/src/services/ipfs.rs b/backend/src/services/ipfs.rs new file mode 100644 index 00000000..ce15bc4f --- /dev/null +++ b/backend/src/services/ipfs.rs @@ -0,0 +1,85 @@ +//! IPFS pinning service via Pinata REST API. +//! +//! Set `PINATA_JWT` to your Pinata JWT bearer token. +//! Uploads are capped at `MAX_UPLOAD_BYTES` (10 MiB) and MIME-type checked +//! against an allowlist before being sent to Pinata. + +use anyhow::{bail, Context, Result}; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde::Deserialize; + +/// 10 MiB hard cap on incoming uploads. +pub const MAX_UPLOAD_BYTES: usize = 10 * 1024 * 1024; + +/// Allowed MIME types for uploaded files. +const ALLOWED_MIME_TYPES: &[&str] = &[ + "application/pdf", + "application/zip", + "application/json", + "text/plain", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]; + +#[derive(Deserialize, Debug)] +struct PinataResponse { + #[serde(rename = "IpfsHash")] + ipfs_hash: String, +} + +/// Pin `data` to IPFS via Pinata and return the resulting CID. +/// +/// `filename` — original filename (used as the Pinata metadata name). +/// `mime_type` — content-type declared by the uploader; validated against the allowlist. +pub async fn pin_to_ipfs( + client: &Client, + data: Vec, + filename: &str, + mime_type: &str, +) -> Result { + // 1. Size guard + if data.len() > MAX_UPLOAD_BYTES { + bail!( + "upload too large: {} bytes (max {} bytes)", + data.len(), + MAX_UPLOAD_BYTES + ); + } + + // 2. MIME allowlist + let base_mime = mime_type.split(';').next().unwrap_or("").trim(); + if !ALLOWED_MIME_TYPES.contains(&base_mime) { + bail!("file type '{}' is not permitted", base_mime); + } + + let jwt = std::env::var("PINATA_JWT") + .context("PINATA_JWT environment variable not set")?; + + // 3. Build multipart body for Pinata + let file_part = Part::bytes(data) + .file_name(filename.to_owned()) + .mime_str(mime_type)?; + + let form = Form::new().part("file", file_part); + + // 4. POST to Pinata pinFileToIPFS + let res = client + .post("https://api.pinata.cloud/pinning/pinFileToIPFS") + .bearer_auth(jwt) + .multipart(form) + .send() + .await + .context("failed to reach Pinata API")?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + bail!("Pinata returned {status}: {body}"); + } + + let pinata: PinataResponse = res.json().await.context("failed to parse Pinata response")?; + Ok(pinata.ipfs_hash) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 4f7641d9..f99224cc 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,2 +1,3 @@ +pub mod ipfs; pub mod judge; pub mod stellar; From 3585961ab1b41dafa8eadf348172db7ad8c5a7a3 Mon Sep 17 00:00:00 2001 From: Ekezie Uchechukwu Date: Fri, 27 Mar 2026 23:44:26 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20resolve=20CI=20failures=20=E2=80=94?= =?UTF-8?q?=20Clippy=20never=5Floop=20and=20stale=20package-lock.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/routes/uploads.rs: replace `while let` with `if let` to fix clippy::never_loop (the loop always returned on first iteration) - apps/web/package-lock.json: regenerate to sync with package.json (was missing typescript and utf-8-validate entries) Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package-lock.json | 108 ++++++++++++++++++++++++++++++++++ backend/src/routes/uploads.rs | 2 +- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 1a2b4060..5dddffc9 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -906,6 +906,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -922,6 +925,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -938,6 +944,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -954,6 +963,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -970,6 +982,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -986,6 +1001,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1002,6 +1020,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1018,6 +1039,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1034,6 +1058,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1056,6 +1083,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1078,6 +1108,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1100,6 +1133,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1122,6 +1158,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1144,6 +1183,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1166,6 +1208,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1188,6 +1233,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1769,6 +1817,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1785,6 +1836,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1801,6 +1855,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1817,6 +1874,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3361,6 +3421,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3378,6 +3441,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3395,6 +3461,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3412,6 +3481,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4794,6 +4866,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4808,6 +4883,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4822,6 +4900,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4836,6 +4917,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4850,6 +4934,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4864,6 +4951,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4878,6 +4968,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4892,6 +4985,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -9524,6 +9620,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9545,6 +9644,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9566,6 +9668,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9587,6 +9692,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/backend/src/routes/uploads.rs b/backend/src/routes/uploads.rs index e26e4e65..987d8a1d 100644 --- a/backend/src/routes/uploads.rs +++ b/backend/src/routes/uploads.rs @@ -21,7 +21,7 @@ async fn upload_file( ) -> Result<(StatusCode, Json), AppError> { let client = Client::new(); - while let Some(field) = multipart + if let Some(field) = multipart .next_field() .await .map_err(|e| AppError::BadRequest(e.to_string()))? From 099c290ebbdfb4356614ee9198cdd136a08186d3 Mon Sep 17 00:00:00 2001 From: boys-cyberhub Date: Sat, 28 Mar 2026 08:30:36 +0100 Subject: [PATCH 3/4] fix: bump frontend CI jobs to Node 24 to match lock file package-lock.json was generated with npm 11 (Node 24). CI was using Node 20/npm 10 which rejected the lock file as out-of-sync. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b207cfd..42a2795d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm cache-dependency-path: apps/web/package-lock.json - run: npm ci --prefix apps/web @@ -103,7 +103,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm cache-dependency-path: apps/web/package-lock.json - run: npm ci --prefix apps/web @@ -117,7 +117,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - run: npm ci - run: npm ci --prefix apps/web From 6acbcf6881a4924c35269185cf996bb525524a91 Mon Sep 17 00:00:00 2001 From: boys-cyberhub Date: Sat, 28 Mar 2026 08:41:13 +0100 Subject: [PATCH 4/4] fix: point tsc to apps/web/tsconfig.json to avoid root tsconfig collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit root tsconfig.json has types:[node] but @types/node is only installed in apps/web — tsc was picking up the wrong config from the repo root --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42a2795d..baeea53d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: - run: npm ci --prefix apps/web - run: npm run lint --prefix apps/web - name: TypeScript type check - run: npx --prefix apps/web tsc --noEmit + run: npx --prefix apps/web tsc -p apps/web/tsconfig.json --noEmit frontend-build: name: Frontend — Build