diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab6293b..baeea53d 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,35 +70,63 @@ 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 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 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 -p apps/web/tsconfig.json --noEmit - build-frontend: - name: Frontend — Build & E2E + frontend-build: + name: Frontend — Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 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: 24 + 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 835b93ab..4a364bac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -222,6 +223,7 @@ dependencies = [ "axum", "axum-test", "base64 0.22.1", + "bytes", "chrono", "dotenvy", "ed25519-dalek", @@ -347,9 +349,9 @@ checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -1491,9 +1493,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -1510,9 +1512,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "job_registry" @@ -1523,10 +1525,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1581,9 +1585,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags", "libc", @@ -1693,15 +1697,32 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "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" @@ -1766,9 +1787,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -2178,6 +2199,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http 1.4.0", "http-body", @@ -2189,6 +2211,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2329,9 +2352,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -3533,9 +3556,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -3581,9 +3604,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3650,9 +3673,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -3663,23 +3686,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3687,9 +3706,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -3700,9 +3719,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -3780,9 +3799,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", @@ -4164,18 +4183,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", 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/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/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..987d8a1d --- /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(); + + if 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;