commit 3ab8ff8dd759e1219ed4d22155348325aca120ee Author: nils Date: Fri Jul 17 13:04:39 2026 +0200 Initial release: self-hosted client photo gallery Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue (SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived keys and a fully private bucket, OIDC photographer login with per-request allowlist checks, client share links with argon2 passwords and lockout, cookie-based image authorization with sliding expiry, hand-rolled spec-compliant streaming ZIP downloads with exact Content-Length, React + Vite gallery frontend, single Docker image, Helm chart for external S3 + Postgres, and Gitea CI. Co-Authored-By: Claude diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0756b0e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target +frontend/node_modules +frontend/dist +deploy +.git +.env +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a250b15 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Copy to .env and `set -a; source .env; set +a` (or use direnv) for local dev. + +DATABASE_URL=postgres://photos:photos@localhost:5432/photos +BIND_ADDR=127.0.0.1:8080 +# For local dev this is the Vite dev server; OIDC redirect URI must match /api/auth/callback +PUBLIC_URL=http://localhost:5173 +SESSION_SECRET=change-me-to-a-long-random-string-min-32-chars + +S3_BUCKET=photos +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_FORCE_PATH_STYLE=true + +OIDC_ISSUER=https://auth.example.com +OIDC_CLIENT_ID=photos +OIDC_CLIENT_SECRET=change-me +ALLOWED_EMAILS=u797+claude@posteo.de + +# DEV ONLY: skip OIDC and sign straight in as this email. Never set in production. +# DEV_AUTOLOGIN_EMAIL=u797+claude@posteo.de + +WORKER_CONCURRENCY=2 +RUST_LOG=info,sqlx=warn diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..17c2fa8 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,43 @@ +name: ci + +on: + push: + branches: + - main + - staging + tags: + - '*' + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Docker Registry + uses: docker/login-action@v2 + with: + registry: ${{ vars.REGISTRY_URL }} + username: ${{ vars.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: remote + endpoint: ${{ env.BUILDKIT_ARM64_ENDPOINT }} + + # The image contains both the Rust backend (server + worker binaries) + # and the built frontend — see the multi-stage Dockerfile. + - name: Build and Push Docker Image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/arm64 + push: true + provenance: false + tags: | + ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:${{ gitea.ref_type == 'tag' && gitea.ref_name || (gitea.ref_name == 'main' && 'latest' || gitea.ref_name) }} + ${{ vars.REGISTRY_URL }}/${{ gitea.repository }}:${{ gitea.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f15cbb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +node_modules +frontend/dist +.env +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c0a5e73 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4697 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-config" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47712fde1909402600ccfbb26e47d482d2e58bb9e9e603d9f17e67cc435a6319" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.2", + "sha1 0.10.7", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.138.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa59420755799fec8aac71f562aa37ba82263f5b1926bba8b5d3465d2f7a73f" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "http-body 1.1.0", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0469f435f645ad2162cfb463b15bde37115966ee3acf2d87fb4871ee309b8401" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.105.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085faefb253f770655e162b9304321e62a1e71adf7f019ee1f4454228a377b3a" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.108.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c72b08911d8128dd360fe1b22a9fec0fa8b552dde8ec828dcf20ef5ec974e9f" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "p256", + "percent-encoding", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "md-5 0.11.0", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.15", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.10.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.42", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd22a6ba36e3f113cb8d5b3d1fe0ed31c76ee608ef63322d753bb8d2c9479e77" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.2", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "base64", + "hmac 0.12.1", + "percent-encoding", + "rand 0.8.7", + "sha2 0.10.9", + "subtle", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.1", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +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", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rustls 0.23.42", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.8", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.9", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "photos" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "aws-config", + "aws-sdk-s3", + "axum", + "axum-extra", + "chrono", + "cookie", + "crc32fast", + "futures", + "image", + "rand 0.8.7", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "tempfile", + "time", + "tokio", + "tokio-util", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.42", + "socket2 0.6.5", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.42", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper 1.10.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.42", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.42", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1 0.10.7", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5 0.10.6", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.42", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8c52e9b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "photos" +version = "0.1.0" +edition = "2021" + +[lib] +name = "photos" +path = "src/lib.rs" + +[[bin]] +name = "server" +path = "src/bin/server.rs" + +[[bin]] +name = "worker" +path = "src/bin/worker.rs" + +[dependencies] +anyhow = "1" +argon2 = "0.5" +crc32fast = "1" +aws-config = { version = "1", features = ["behavior-version-latest"] } +aws-sdk-s3 = "1" +axum = { version = "0.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie", "cookie-signed"] } +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +image = "0.25" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate"] } +tempfile = "3" +time = "0.3" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +tower-http = { version = "0.6", features = ["fs", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +urlencoding = "2" +uuid = { version = "1", features = ["v4", "serde"] } + +[dev-dependencies] +cookie = { version = "0.18", features = ["signed"] } + +[profile.release] +lto = "thin" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c75cba9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# ---- frontend ---- +FROM node:22-alpine AS frontend +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# ---- backend ---- +FROM rust:1-bookworm AS backend +WORKDIR /app +COPY Cargo.toml Cargo.lock* ./ +COPY src ./src +COPY migrations ./migrations +RUN cargo build --release --bins + +# ---- runtime (shared by api and worker) ---- +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends exiftool ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --uid 1000 photos +WORKDIR /app +COPY --from=backend /app/target/release/server /app/target/release/worker /usr/local/bin/ +COPY --from=frontend /app/dist /app/static +ENV STATIC_DIR=/app/static +USER photos +EXPOSE 8080 +# The worker deployment overrides this with: command ["worker"] +CMD ["server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..33b2444 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Photos + +Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums, +share them with clients via private (optionally password-protected) links, +collect ratings and tags, and let clients download originals. + +## Architecture + +``` + ┌─────────────┐ + browser ── ingress ──► │ api (Rust) │ ──► Postgres (data + job queue) + │ axum │ ──► S3 (originals, previews, thumbs) + └─────────────┘ + ┌─────────────┐ + │ worker(s) │ ◄── polls jobs table (SKIP LOCKED) + │ exiftool + │ ──► renders preview (2048px) + thumb + │ image crate │ (512px) JPEGs into S3 + └─────────────┘ +``` + +- **Backend**: Rust (axum, sqlx). Two binaries from one crate: `server` (API + + serves the built frontend) and `worker` (job processor). +- **Job queue**: plain Postgres table claimed with `FOR UPDATE SKIP LOCKED`, + with retries and exponential backoff. Job kinds: `process_photo`, + `delete_s3_prefix`. +- **RAW handling**: the worker extracts the camera's embedded JPEG preview via + `exiftool` (fast, matches in-camera rendering), applies EXIF orientation, + and resizes. Originals are always stored and downloadable untouched. +- **Storage layout**: `photos//original/`, + `photos//preview.jpg`, `photos//thumb.jpg`. The bucket + stays fully private; all image traffic is streamed through the API with + auth checks (no bucket CORS or public access needed). +- **Auth**: photographer signs in via any OIDC provider (authorization-code + flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions + are re-checked against the allowlist on every request, so removing an email + revokes access immediately. Clients use unguessable share tokens, optionally + gated by an argon2-hashed password (10 wrong guesses lock the link for + 15 minutes). +- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating + stars and tag chips, drag-and-drop multi-file upload with progress. + +## Local development + +Requirements: Rust (rustup — the pinned toolchain in `rust-toolchain.toml` +installs automatically), Node 20+, `exiftool`, Docker. + +```sh +docker compose up -d # Postgres + MinIO (bucket auto-created) +cp .env.example .env # then edit OIDC_* and ALLOWED_EMAILS +set -a; source .env; set +a + +cargo run --bin server # API on :8080 (runs migrations on start) +cargo run --bin worker # job worker (separate terminal, same env) + +cd frontend && npm install && npm run dev # UI on :5173, proxies /api +``` + +Register the OIDC client with redirect URI `/api/auth/callback` +(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC +provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses +discovery, so only the issuer URL is configured. + +## Configuration + +All configuration is via environment variables: + +| Variable | Required | Description | +| --- | --- | --- | +| `DATABASE_URL` | yes | Postgres connection string | +| `PUBLIC_URL` | yes | External base URL, e.g. `https://photos.example.com` | +| `SESSION_SECRET` | yes | ≥32 chars; signs session/share cookies | +| `S3_BUCKET` | yes | Bucket name | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | yes | S3 credentials | +| `S3_ENDPOINT` | no | Set for MinIO/Ceph/etc.; empty = AWS S3 | +| `S3_REGION` | no | Default `us-east-1` | +| `S3_FORCE_PATH_STYLE` | no | `true` for MinIO | +| `OIDC_ISSUER` | yes | Issuer URL (discovery is fetched from it) | +| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | yes | OIDC client credentials | +| `ALLOWED_EMAILS` | yes | Comma-separated photographer emails | +| `BIND_ADDR` | no | Default `0.0.0.0:8080` | +| `STATIC_DIR` | no | Built frontend dir (default `frontend/dist`) | +| `WORKER_CONCURRENCY` | no | Parallel jobs per worker pod (default 2) | +| `RUST_LOG` | no | e.g. `info,sqlx=warn` | +| `DEV_AUTOLOGIN_EMAIL` | no | **Dev only**: skip OIDC and sign in as this email (must also be in `ALLOWED_EMAILS`). The server refuses to start with this set when `PUBLIC_URL` is https. | + +## Deploying to Kubernetes + +Build and push the image (single image contains `server`, `worker`, and the +built frontend): + +```sh +docker build -t ghcr.io/YOU/photos:0.1.0 . +docker push ghcr.io/YOU/photos:0.1.0 +``` + +Install the chart, pointing it at your existing Postgres and S3: + +```sh +helm install photos deploy/chart \ + --set image.repository=ghcr.io/YOU/photos \ + --set image.tag=0.1.0 \ + --set publicUrl=https://photos.example.com \ + --set ingress.host=photos.example.com \ + --set config.oidcIssuer=https://auth.example.com \ + --set config.allowedEmails=you@example.com \ + --set config.s3.bucket=photos \ + --set config.s3.endpoint=https://s3.example.com \ + --set config.s3.forcePathStyle=true \ + --set secrets.databaseUrl=postgres://... \ + --set secrets.s3AccessKey=... \ + --set secrets.s3SecretKey=... \ + --set secrets.oidcClientId=photos \ + --set secrets.oidcClientSecret=... \ + --set secrets.sessionSecret=$(openssl rand -hex 32) +``` + +For production prefer a values file, or create the Secret yourself and set +`secrets.existingSecret` (keys: `DATABASE_URL`, `S3_ACCESS_KEY`, +`S3_SECRET_KEY`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_SECRET`). + +Notes: + +- The default ingress annotations disable nginx's request body limit and + request buffering so multi-GB RAW uploads stream through. Adapt for other + ingress controllers. +- Migrations run automatically on startup of either binary (they take a + Postgres advisory lock, so concurrent starts are safe). +- Scale `worker.replicas` (or `worker.concurrency`) if imports queue up; + the queue is safe for any number of workers. + +## How sharing works + +- Each album can have any number of share links (`/s/<24-char-token>`), each + with its own label (e.g. the client's name), optional password, optional + expiry, and a per-link download toggle. +- Ratings (1–5 stars) and free-form tags are stored **per link**, so create + one link per client to keep feedback separate. The album view shows all + feedback grouped by link label. +- Clients (and you) can multi-select photos and download them — or the whole + album — as a ZIP. Archives are streamed (each file spools briefly through a + temp file for its checksum, then pipelines while the next one prefetches), + stored uncompressed with real capture-date timestamps, and are fully + spec-compliant — they extract with strict streaming readers (Java + `ZipInputStream`, piped `bsdtar`) as well as Finder/Explorer/unzip/7-Zip. + Responses carry an exact `Content-Length`, so browsers show progress and + flag interrupted downloads as failed. Concurrent zip streams are capped + at 4. +- 10 wrong passwords lock a link for 15 minutes (fresh attempts after the + window). A locked link shows in the album's share list with an Unlock + button. +- Deleting a link removes its ratings/tags; deleting photos or albums cleans + up S3 objects via background jobs. + +## Known limitations / deliberate v1 cuts + +- Full RAW develop fallback for files whose embedded preview is tiny + (exceedingly rare on modern cameras; `darktable-cli` in the worker image + would cover it). +- Multiple photographer accounts with separate libraries (any allowed email + sees everything). +- No S3 orphan sweeper: a crash in the narrow window between an upload's S3 + put and its DB commit can leave an unreferenced original in the bucket + (never data loss — just unclaimed storage). diff --git a/deploy/chart/Chart.yaml b/deploy/chart/Chart.yaml new file mode 100644 index 0000000..305cfd3 --- /dev/null +++ b/deploy/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: photos +description: Self-hosted client photo gallery (Rust API + worker, React frontend) +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/deploy/chart/templates/_helpers.tpl b/deploy/chart/templates/_helpers.tpl new file mode 100644 index 0000000..129c564 --- /dev/null +++ b/deploy/chart/templates/_helpers.tpl @@ -0,0 +1,42 @@ +{{- define "photos.fullname" -}} +{{- if contains "photos" .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-photos" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "photos.labels" -}} +app.kubernetes.io/name: photos +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "photos.secretName" -}} +{{- if .Values.secrets.existingSecret -}} +{{- .Values.secrets.existingSecret -}} +{{- else -}} +{{- include "photos.fullname" . -}} +{{- end -}} +{{- end -}} + +{{- define "photos.env" -}} +- name: PUBLIC_URL + value: {{ .Values.publicUrl | quote }} +- name: S3_BUCKET + value: {{ .Values.config.s3.bucket | quote }} +- name: S3_REGION + value: {{ .Values.config.s3.region | quote }} +{{- if .Values.config.s3.endpoint }} +- name: S3_ENDPOINT + value: {{ .Values.config.s3.endpoint | quote }} +{{- end }} +- name: S3_FORCE_PATH_STYLE + value: {{ .Values.config.s3.forcePathStyle | quote }} +- name: OIDC_ISSUER + value: {{ .Values.config.oidcIssuer | quote }} +- name: ALLOWED_EMAILS + value: {{ .Values.config.allowedEmails | quote }} +- name: RUST_LOG + value: {{ .Values.config.logLevel | quote }} +{{- end -}} diff --git a/deploy/chart/templates/deployment-api.yaml b/deploy/chart/templates/deployment-api.yaml new file mode 100644 index 0000000..69548fc --- /dev/null +++ b/deploy/chart/templates/deployment-api.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "photos.fullname" . }}-api + labels: + {{- include "photos.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: photos + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: api + template: + metadata: + labels: + {{- include "photos.labels" . | nindent 8 }} + app.kubernetes.io/component: api + spec: + containers: + - name: api + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["server"] + ports: + - name: http + containerPort: 8080 + env: + {{- include "photos.env" . | nindent 12 }} + - name: BIND_ADDR + value: "0.0.0.0:8080" + envFrom: + - secretRef: + name: {{ include "photos.secretName" . }} + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 3 + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 10 + resources: + {{- toYaml .Values.api.resources | nindent 12 }} diff --git a/deploy/chart/templates/deployment-worker.yaml b/deploy/chart/templates/deployment-worker.yaml new file mode 100644 index 0000000..fbee5a7 --- /dev/null +++ b/deploy/chart/templates/deployment-worker.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "photos.fullname" . }}-worker + labels: + {{- include "photos.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: photos + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "photos.labels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + containers: + - name: worker + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["worker"] + env: + {{- include "photos.env" . | nindent 12 }} + - name: WORKER_CONCURRENCY + value: {{ .Values.worker.concurrency | quote }} + envFrom: + - secretRef: + name: {{ include "photos.secretName" . }} + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} diff --git a/deploy/chart/templates/ingress.yaml b/deploy/chart/templates/ingress.yaml new file mode 100644 index 0000000..23be4ae --- /dev/null +++ b/deploy/chart/templates/ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "photos.fullname" . }} + labels: + {{- include "photos.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "photos.fullname" . }} + port: + name: http +{{- end }} diff --git a/deploy/chart/templates/secret.yaml b/deploy/chart/templates/secret.yaml new file mode 100644 index 0000000..38b3cdf --- /dev/null +++ b/deploy/chart/templates/secret.yaml @@ -0,0 +1,15 @@ +{{- if not .Values.secrets.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "photos.fullname" . }} + labels: + {{- include "photos.labels" . | nindent 4 }} +stringData: + DATABASE_URL: {{ required "secrets.databaseUrl (or secrets.existingSecret) is required" .Values.secrets.databaseUrl | quote }} + S3_ACCESS_KEY: {{ required "secrets.s3AccessKey is required" .Values.secrets.s3AccessKey | quote }} + S3_SECRET_KEY: {{ required "secrets.s3SecretKey is required" .Values.secrets.s3SecretKey | quote }} + OIDC_CLIENT_ID: {{ required "secrets.oidcClientId is required" .Values.secrets.oidcClientId | quote }} + OIDC_CLIENT_SECRET: {{ required "secrets.oidcClientSecret is required" .Values.secrets.oidcClientSecret | quote }} + SESSION_SECRET: {{ required "secrets.sessionSecret is required" .Values.secrets.sessionSecret | quote }} +{{- end }} diff --git a/deploy/chart/templates/service.yaml b/deploy/chart/templates/service.yaml new file mode 100644 index 0000000..75267cc --- /dev/null +++ b/deploy/chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "photos.fullname" . }} + labels: + {{- include "photos.labels" . | nindent 4 }} +spec: + selector: + app.kubernetes.io/name: photos + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: api + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml new file mode 100644 index 0000000..6c26fff --- /dev/null +++ b/deploy/chart/values.yaml @@ -0,0 +1,66 @@ +image: + repository: ghcr.io/CHANGE-ME/photos + tag: latest + pullPolicy: IfNotPresent + +api: + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 1Gi + +worker: + replicas: 1 + concurrency: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + memory: 2Gi + +# External base URL of the app; the OIDC redirect URI is /api/auth/callback +publicUrl: https://photos.example.com + +config: + s3: + # Leave endpoint empty for AWS S3; set for MinIO/Ceph/etc. + endpoint: "" + region: us-east-1 + bucket: photos + forcePathStyle: false + oidcIssuer: https://auth.example.com + # Comma-separated photographer emails allowed to sign in + allowedEmails: you@example.com + logLevel: info,sqlx=warn + +# Sensitive settings. Either reference an existing Secret containing the keys +# DATABASE_URL, S3_ACCESS_KEY, S3_SECRET_KEY, OIDC_CLIENT_ID, +# OIDC_CLIENT_SECRET, SESSION_SECRET — or inline the values and the chart +# creates the Secret for you. +secrets: + existingSecret: "" + databaseUrl: "" + s3AccessKey: "" + s3SecretKey: "" + oidcClientId: "" + oidcClientSecret: "" + sessionSecret: "" + +service: + port: 80 + +ingress: + enabled: true + className: nginx + host: photos.example.com + annotations: + # Raw uploads are large; disable nginx's body size limit. + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-request-buffering: "off" + tls: + enabled: true + secretName: photos-tls diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed20d04 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +# Local development dependencies only (Postgres + MinIO). +# Run the app itself with `cargo run --bin server` / `cargo run --bin worker` +# and `npm run dev` in frontend/ (see README). +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: photos + POSTGRES_PASSWORD: photos + POSTGRES_DB: photos + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + minio: + image: minio/minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + + minio-init: + image: minio/mc + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done; + mc mb -p local/photos; + exit 0" + +volumes: + pgdata: + miniodata: diff --git a/examples/mint_session.rs b/examples/mint_session.rs new file mode 100644 index 0000000..87bd056 --- /dev/null +++ b/examples/mint_session.rs @@ -0,0 +1,29 @@ +//! Dev utility: mint a signed session cookie without going through OIDC. +//! Anyone holding SESSION_SECRET can forge sessions anyway; this just makes +//! local API testing possible before an IdP is wired up. +//! +//! Usage: SESSION_SECRET=... cargo run --example mint_session -- + +use cookie::{Cookie, CookieJar, Key}; +use sha2::{Digest, Sha512}; + +fn main() { + let mut args = std::env::args().skip(1); + let user_id = args.next().expect("usage: mint_session "); + // Sessions are only honored for lowercased emails present in ALLOWED_EMAILS. + let email = args + .next() + .expect("usage: mint_session ") + .to_lowercase(); + let secret = std::env::var("SESSION_SECRET").expect("SESSION_SECRET must be set"); + let key = Key::from(&Sha512::digest(secret.as_bytes())); + let exp = chrono::Utc::now().timestamp() + 86400; + let mut jar = CookieJar::new(); + jar.signed_mut(&key).add(Cookie::new( + "photos_session", + format!("{user_id}|{exp}|{email}"), + )); + // The plain jar now holds the signed on-wire value. + let cookie = jar.get("photos_session").unwrap(); + println!("photos_session={}", cookie.value()); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9ea23cd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Photos + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..29c1732 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1761 @@ +{ + "name": "photos-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "photos-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..28ad890 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "photos-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..b02ac33 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' +import { Link, Route, Routes } from 'react-router-dom' +import { api } from './api' +import AlbumsPage from './pages/AlbumsPage' +import AlbumPage from './pages/AlbumPage' +import SharePage from './pages/SharePage' + +function AdminLayout({ children }) { + const [me, setMe] = useState(undefined) // undefined=loading, null=logged out + + useEffect(() => { + api('/api/me') + .then(setMe) + .catch(() => setMe(null)) + }, []) + + if (me === undefined) return
Loading…
+ if (me === null) { + const authError = new URLSearchParams(window.location.search).get('auth_error') + return ( +
+
+

Photos

+

Photographer sign-in

+ + Sign in + + {authError &&

{authError}

} +
+
+ ) + } + + return ( + <> +
+ + Photos + + + {me.email} + + +
+
{children}
+ + ) +} + +export default function App() { + return ( + + } /> + + + + } + /> + + + + } + /> + Not found} /> + + ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..d935ba4 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,100 @@ +export async function api(path, opts = {}) { + const { body, ...rest } = opts + const res = await fetch(path, { + ...rest, + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + let message = res.statusText + try { + message = (await res.json()).error || message + } catch { + /* not json */ + } + const err = new Error(message) + err.status = res.status + throw err + } + if (res.status === 204) return null + return res.json() +} + +// Image URL with a cache-buster tied to the last processing run, so +// reprocessed photos bypass the long-lived immutable browser cache. +// Auth rides on cookies (session or share-access), never in the URL. +export function imgUrl(photo, size) { + const version = photo.processed_at ? `?v=${encodeURIComponent(photo.processed_at)}` : '' + return `/api/img/${photo.id}/${size}${version}` +} + +// Trigger a browser-native download from a POST endpoint (e.g. zip streams) +// via a hidden form — fetch+blob would buffer the whole file in memory. +// Targets a hidden iframe so an error response can't navigate away from the +// app (which would lose selection/rating state); errors surface as an alert. +export function postDownload(url, ids = '') { + let frame = document.getElementById('download-frame') + if (!frame) { + frame = document.createElement('iframe') + frame.id = 'download-frame' + frame.name = 'download-frame' + frame.style.display = 'none' + document.body.appendChild(frame) + } + frame.onload = () => { + // load only fires when the response rendered (i.e. an error body); + // successful attachment downloads never trigger it. + let message = 'download failed' + try { + const text = frame.contentDocument?.body?.textContent + if (!text) return + try { + message = JSON.parse(text).error || message + } catch { + /* not json */ + } + } catch { + return + } + alert(`Download failed: ${message}`) + } + const form = document.createElement('form') + form.method = 'POST' + form.action = url + form.target = 'download-frame' + form.style.display = 'none' + const input = document.createElement('input') + input.type = 'hidden' + input.name = 'ids' + input.value = ids + form.appendChild(input) + document.body.appendChild(form) + form.submit() + form.remove() +} + +export function uploadFile(url, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', url) + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText)) + } else { + let message = `upload failed (${xhr.status})` + try { + message = JSON.parse(xhr.responseText).error || message + } catch { + /* not json */ + } + reject(new Error(message)) + } + } + xhr.onerror = () => reject(new Error('network error during upload')) + xhr.send(file) + }) +} diff --git a/frontend/src/components/Gallery.jsx b/frontend/src/components/Gallery.jsx new file mode 100644 index 0000000..0118138 --- /dev/null +++ b/frontend/src/components/Gallery.jsx @@ -0,0 +1,42 @@ +import { imgUrl } from '../api' + +// Justified gallery: rows are built with flexbox, each tile's flex-grow is +// proportional to its aspect ratio so rows fill the container edge to edge. +// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark; +// selection state lives in the parent so it survives lightbox open/close. +export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) { + if (photos.length === 0) return null + const selecting = selected && selected.size > 0 + return ( +
+ {photos.map((p, i) => { + const ar = p.width && p.height ? p.width / p.height : 1.5 + const isSelected = selected ? selected.has(p.id) : false + return ( +
onOpen && onOpen(i)} + > + {p.filename} + {onToggleSelect && ( + + )} + {overlay && overlay(p)} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/components/Lightbox.jsx b/frontend/src/components/Lightbox.jsx new file mode 100644 index 0000000..63ebc84 --- /dev/null +++ b/frontend/src/components/Lightbox.jsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react' +import { imgUrl } from '../api' + +export default function Lightbox({ photos, index, onClose, onNav, footer }) { + const photo = photos[index] + + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1) + if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [index, photos.length, onClose, onNav]) + + useEffect(() => { + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = '' + } + }, []) + + if (!photo) return null + + return ( +
+
e.stopPropagation()}> + {photo.filename} + + {index + 1} / {photos.length} + + +
+ +
+ {photo.filename} e.stopPropagation()} + /> +
+ + {footer && ( +
e.stopPropagation()}> + {footer(photo)} +
+ )} +
+ ) +} diff --git a/frontend/src/components/SelectionBar.jsx b/frontend/src/components/SelectionBar.jsx new file mode 100644 index 0000000..4004dfc --- /dev/null +++ b/frontend/src/components/SelectionBar.jsx @@ -0,0 +1,52 @@ +export function fmtBytes(bytes) { + if (!bytes) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1) + const value = bytes / 2 ** (10 * i) + return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}` +} + +export default function SelectionBar({ + count, + total, + selectedBytes, + totalBytes, + onSelectAll, + onClear, + onDownload, + onDownloadAll, +}) { + if (total === 0) return null + + if (count === 0) { + return ( +
+ + {total} photo{total === 1 ? '' : 's'} · {fmtBytes(totalBytes)} + + +
+ ) + } + + return ( +
+ + {count} of {total} selected + + {count < total && ( + + )} + + +
+ ) +} diff --git a/frontend/src/components/Stars.jsx b/frontend/src/components/Stars.jsx new file mode 100644 index 0000000..4970ee9 --- /dev/null +++ b/frontend/src/components/Stars.jsx @@ -0,0 +1,30 @@ +import { useState } from 'react' + +export default function Stars({ value = 0, onChange, small }) { + const [hover, setHover] = useState(0) + const shown = hover || value || 0 + return ( + setHover(0)} + > + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + ) +} diff --git a/frontend/src/components/TagEditor.jsx b/frontend/src/components/TagEditor.jsx new file mode 100644 index 0000000..0042b4b --- /dev/null +++ b/frontend/src/components/TagEditor.jsx @@ -0,0 +1,41 @@ +import { useState } from 'react' + +export default function TagEditor({ tags, onChange }) { + const [input, setInput] = useState('') + + const add = () => { + const tag = input.trim().toLowerCase() + setInput('') + if (tag && !tags.includes(tag)) onChange([...tags, tag]) + } + + return ( +
+ {tags.map((tag) => ( + + {tag} + + + ))} + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + add() + } + }} + onBlur={add} + /> +
+ ) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..21f475c --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + , +) diff --git a/frontend/src/pages/AlbumPage.jsx b/frontend/src/pages/AlbumPage.jsx new file mode 100644 index 0000000..fee6032 --- /dev/null +++ b/frontend/src/pages/AlbumPage.jsx @@ -0,0 +1,459 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { api, postDownload, uploadFile } from '../api' +import Gallery from '../components/Gallery' +import Lightbox from '../components/Lightbox' +import SelectionBar from '../components/SelectionBar' +import Stars from '../components/Stars' +import useSelection from '../useSelection' + +const UPLOAD_CONCURRENCY = 3 + +function UploadZone({ albumId, onUploaded }) { + const [queue, setQueue] = useState([]) + const [dragging, setDragging] = useState(false) + const inputRef = useRef(null) + const running = useRef(0) + const pending = useRef([]) + const lastRefresh = useRef(0) + + // Refresh the album at most every 5s during a bulk upload (the processing + // poll keeps it fresh anyway), plus once when the queue drains. + const refresh = useCallback(() => { + const drained = running.current === 0 && pending.current.length === 0 + if (drained || Date.now() - lastRefresh.current > 5000) { + lastRefresh.current = Date.now() + onUploaded() + } + }, [onUploaded]) + + const pump = useCallback(() => { + while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) { + const item = pending.current.shift() + running.current += 1 + setQueue((q) => + q.map((x) => (x.key === item.key ? { ...x, status: 'uploading' } : x)), + ) + const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}` + uploadFile(url, item.file, (p) => + setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, progress: p } : x))), + ) + .then(() => + setQueue((q) => + q.map((x) => (x.key === item.key ? { ...x, status: 'done', progress: 1 } : x)), + ), + ) + .catch((e) => + setQueue((q) => + q.map((x) => (x.key === item.key ? { ...x, status: 'error', error: e.message } : x)), + ), + ) + .finally(() => { + running.current -= 1 + refresh() + pump() + }) + } + }, [albumId, refresh]) + + const addFiles = (files) => { + const items = [...files].map((file, i) => ({ + key: `${Date.now()}-${i}-${file.name}`, + file, + status: 'queued', + progress: 0, + })) + if (items.length === 0) return + setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items]) + pending.current.push(...items) + pump() + } + + return ( +
{ + e.preventDefault() + setDragging(true) + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault() + setDragging(false) + addFiles(e.dataTransfer.files) + }} + onClick={() => inputRef.current?.click()} + > + { + addFiles(e.target.files) + e.target.value = '' + }} + /> +

Drop RAWs or JPGs here, or click to select

+ {queue.length > 0 && ( +
    e.stopPropagation()}> + {queue.map((item) => ( +
  • + {item.file.name} + {item.status === 'error' ? ( + {item.error} + ) : ( + + )} +
  • + ))} +
+ )} +
+ ) +} + +function SharesPanel({ albumId }) { + const [shares, setShares] = useState([]) + const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' }) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(null) + + const load = useCallback( + () => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)), + [albumId], + ) + useEffect(() => { + load() + }, [load]) + + const create = async (e) => { + e.preventDefault() + try { + await api(`/api/albums/${albumId}/shares`, { + method: 'POST', + body: { + label: form.label, + password: form.password || null, + allow_download: form.allow_download, + // End of the chosen day in the photographer's local timezone — + // date-only strings would parse as UTC midnight and expire a day early. + expires_at: form.expires_at + ? new Date(`${form.expires_at}T23:59:59`).toISOString() + : null, + }, + }) + setForm({ label: '', password: '', allow_download: true, expires_at: '' }) + setError(null) + load() + } catch (e) { + setError(e.message) + } + } + + const copy = async (share) => { + await navigator.clipboard.writeText(share.url) + setCopied(share.id) + setTimeout(() => setCopied(null), 1500) + } + + return ( +
+

Client links

+ {shares.length === 0 &&

No links yet.

} + {shares.map((s) => ( +
+
+ + {s.label || 'unnamed link'} + {s.locked && — locked (too many wrong passwords)} + + + {s.has_password ? '🔒 password' : 'no password'} + {' · '} + {s.allow_download ? 'downloads on' : 'downloads off'} + {s.expires_at + ? ` · expires ${new Date(s.expires_at).toLocaleDateString()}` + : ' · never expires'} + {' · '} + {s.rating_count} ratings, {s.tag_count} tags + +
+
+ {s.locked && ( + + )} + + +
+
+ ))} +
+ setForm({ ...form, label: e.target.value })} + /> + setForm({ ...form, password: e.target.value })} + /> + + + +
+ {error &&

{error}

} +
+ ) +} + +export default function AlbumPage() { + const { id } = useParams() + const navigate = useNavigate() + const [detail, setDetail] = useState(null) + const [error, setError] = useState(null) + // Track the open photo by id, not index — the polling refetch can reorder + // the array underneath an open lightbox. + const [lightboxId, setLightboxId] = useState(null) + + const load = useCallback( + () => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)), + [id], + ) + useEffect(() => { + load() + }, [load]) + + // Poll while any photo is still being processed by the workers. + const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing') + useEffect(() => { + if (!hasPending) return + const t = setInterval(load, 4000) + return () => clearInterval(t) + }, [hasPending, load]) + + const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready') + const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready) + const lightboxIndex = ready.findIndex((p) => p.id === lightboxId) + + // If the open photo leaves the ready list (deleted elsewhere, reprocess), + // close for good — otherwise the lightbox would pop back open when the + // photo returns to ready. + useEffect(() => { + if (lightboxId && lightboxIndex < 0) setLightboxId(null) + }, [lightboxId, lightboxIndex]) + + if (error) return

{error}

+ if (!detail) return

Loading…

+ + const { album, photos, feedback } = detail + const notReady = photos.filter((p) => p.status !== 'ready') + + const avgRating = (photoId) => { + const ratings = feedback[photoId]?.ratings || [] + if (ratings.length === 0) return null + return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length + } + + const rename = async () => { + const name = prompt('Album name', album.name) + if (name && name.trim()) { + await api(`/api/albums/${id}`, { method: 'PATCH', body: { name: name.trim() } }) + load() + } + } + + const removeAlbum = async () => { + if (!confirm(`Delete album "${album.name}" and all ${photos.length} photos? This cannot be undone.`)) return + await api(`/api/albums/${id}`, { method: 'DELETE' }) + navigate('/') + } + + const removePhoto = async (photoId) => { + if (!confirm('Delete this photo?')) return + setLightboxId(null) + await api(`/api/photos/${photoId}`, { method: 'DELETE' }) + load() + } + + return ( + <> +
+

+ + Albums / + {' '} + {album.name} +

+
+ + +
+
+ + + + {notReady.length > 0 && ( +
+

Processing

+
    + {notReady.map((p) => ( +
  • + {p.filename} + {p.status === 'error' ? ( + + {p.error || 'failed'} + + + + ) : ( + {p.status}… + )} +
  • + ))} +
+
+ )} + + setLightboxId(ready[i].id)} + selected={selected} + onToggleSelect={toggle} + overlay={(p) => { + const avg = avgRating(p.id) + const tagCount = feedback[p.id]?.tags.length || 0 + if (avg === null && tagCount === 0) return null + return ( +
+ {avg !== null && ★ {avg.toFixed(1)}} + {tagCount > 0 && # {tagCount}} +
+ ) + }} + /> + {ready.length === 0 && notReady.length === 0 && ( +

No photos yet — drop some above.

+ )} + + postDownload(`/api/albums/${id}/zip`, [...selected].join(','))} + onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)} + /> + + {lightboxIndex >= 0 && ( + setLightboxId(null)} + onNav={(i) => setLightboxId(ready[i].id)} + footer={(p) => { + const fb = feedback[p.id] || { ratings: [], tags: [] } + return ( +
+
+ {fb.ratings.length === 0 && fb.tags.length === 0 && ( + No client feedback yet + )} + {fb.ratings.map((r, i) => ( + + {r.share_label || 'client'}: + + ))} + {fb.tags.map((t, i) => ( + + {t.tag} ({t.share_label || 'client'}) + + ))} +
+
+ + + Download original + + +
+
+ ) + }} + /> + )} + + + + ) +} diff --git a/frontend/src/pages/AlbumsPage.jsx b/frontend/src/pages/AlbumsPage.jsx new file mode 100644 index 0000000..e637bd7 --- /dev/null +++ b/frontend/src/pages/AlbumsPage.jsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { api, imgUrl } from '../api' + +export default function AlbumsPage() { + const [albums, setAlbums] = useState(null) + const [name, setName] = useState('') + const [error, setError] = useState(null) + + const load = () => api('/api/albums').then(setAlbums).catch((e) => setError(e.message)) + useEffect(() => { + load() + }, []) + + const create = async (e) => { + e.preventDefault() + if (!name.trim()) return + try { + await api('/api/albums', { method: 'POST', body: { name: name.trim() } }) + setName('') + load() + } catch (e) { + setError(e.message) + } + } + + return ( + <> +
+

Albums

+
+ setName(e.target.value)} + placeholder="New album name" + /> + +
+
+ {error &&

{error}

} + {albums === null ? ( +

Loading…

+ ) : albums.length === 0 ? ( +

No albums yet — create one above.

+ ) : ( +
+ {albums.map((a) => ( + +
+ {a.cover_photo_id ? ( + + ) : ( +
+ )} +
+
+ {a.name} + + {a.photo_count} photo{a.photo_count === 1 ? '' : 's'} + +
+ + ))} +
+ )} + + ) +} diff --git a/frontend/src/pages/SharePage.jsx b/frontend/src/pages/SharePage.jsx new file mode 100644 index 0000000..6cba6d1 --- /dev/null +++ b/frontend/src/pages/SharePage.jsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { api, postDownload } from '../api' +import Gallery from '../components/Gallery' +import Lightbox from '../components/Lightbox' +import SelectionBar from '../components/SelectionBar' +import Stars from '../components/Stars' +import TagEditor from '../components/TagEditor' +import useSelection from '../useSelection' + +export default function SharePage() { + const { token } = useParams() + const [view, setView] = useState(null) + const [error, setError] = useState(null) + const [password, setPassword] = useState('') + const [unlockError, setUnlockError] = useState(null) + const [lightbox, setLightbox] = useState(-1) + const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection( + view?.photos ?? [], + ) + + const load = useCallback( + () => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)), + [token], + ) + useEffect(() => { + load() + }, [load]) + + const unlock = async (e) => { + e.preventDefault() + try { + await api(`/api/share/${token}/unlock`, { method: 'POST', body: { password } }) + setUnlockError(null) + load() + } catch (err) { + setUnlockError(err.status === 401 ? 'Wrong password' : err.message) + } + } + + const patchPhoto = (photoId, patch) => { + setView((v) => ({ + ...v, + photos: v.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)), + })) + } + + const setRating = async (photo, rating) => { + patchPhoto(photo.id, { my_rating: rating || null }) + try { + await api(`/api/share/${token}/photos/${photo.id}/rating`, { + method: 'PUT', + body: { rating }, + }) + } catch { + load() + } + } + + const setTags = async (photo, tags) => { + patchPhoto(photo.id, { my_tags: tags }) + try { + await api(`/api/share/${token}/photos/${photo.id}/tags`, { + method: 'PUT', + body: { tags }, + }) + } catch { + load() + } + } + + if (error) return
{error}
+ if (!view) return
Loading…
+ + if (view.locked) { + return ( +
+
+

{view.album_name}

+

This gallery is password protected.

+ setPassword(e.target.value)} + /> + + {unlockError &&

{unlockError}

} +
+
+ ) + } + + return ( + <> +
+

{view.album_name}

+ {view.album_description &&

{view.album_description}

} +

+ {view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view, + rate and tag +

+
+
+ + p.my_rating || p.my_tags.length > 0 ? ( +
+ {p.my_rating && ★ {p.my_rating}} + {p.my_tags.length > 0 && # {p.my_tags.length}} +
+ ) : null + } + /> + {view.photos.length === 0 && ( +

Nothing here yet — check back soon.

+ )} +
+ {view.allow_download && ( + postDownload(`/api/share/${token}/zip`, [...selected].join(','))} + onDownloadAll={() => postDownload(`/api/share/${token}/zip`)} + /> + )} + {lightbox >= 0 && ( + setLightbox(-1)} + onNav={setLightbox} + footer={(p) => ( +
+ setRating(p, r)} /> + setTags(p, tags)} /> + {view.allow_download && ( + + )} + {view.allow_download && ( + + Download original + + )} +
+ )} + /> + )} + + ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..679e02d --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,570 @@ +* { + box-sizing: border-box; +} + +:root { + --bg: #101216; + --panel: #191c22; + --panel-2: #22262e; + --text: #e8e6e1; + --muted: #9a978f; + --accent: #d9a441; + --danger: #e5645a; + --radius: 8px; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.5; +} + +h1 { + font-size: 1.4rem; + font-weight: 600; + margin: 0; +} +h1 a { + text-decoration: none; +} +h2 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.75rem; +} + +a { + color: var(--text); +} +.muted { + color: var(--muted); + font-weight: 400; +} +.error { + color: var(--danger); +} + +.page { + max-width: 1400px; + margin: 0 auto; + padding: 1rem 1.25rem 4rem; +} +.center-page { + min-height: 80vh; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); +} +.page-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin: 1rem 0 1.25rem; +} +.row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* top bar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1.25rem; + border-bottom: 1px solid var(--panel-2); +} +.brand { + font-weight: 700; + font-size: 1rem; + text-decoration: none; + color: var(--accent); +} +.topbar-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* inputs & buttons */ +input { + background: var(--panel); + border: 1px solid var(--panel-2); + color: var(--text); + border-radius: var(--radius); + padding: 0.45rem 0.7rem; + font-size: 0.95rem; +} +input:focus { + outline: 1px solid var(--accent); +} +.btn { + display: inline-block; + background: var(--panel-2); + color: var(--text); + border: none; + border-radius: var(--radius); + padding: 0.45rem 0.9rem; + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +.btn:hover { + filter: brightness(1.15); +} +.btn-primary { + background: var(--accent); + color: #1a1408; + font-weight: 600; +} +.btn-danger { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} +.btn-ghost { + background: transparent; + color: var(--muted); +} + +/* login */ +.login-card { + background: var(--panel); + border-radius: 12px; + padding: 2.5rem 3rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 0.9rem; + color: var(--text); +} + +/* album grid */ +.album-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; +} +.album-card { + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + text-decoration: none; + transition: transform 0.1s; +} +.album-card:hover { + transform: translateY(-2px); +} +.album-cover { + aspect-ratio: 3 / 2; + background: var(--panel-2); +} +.album-cover img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.album-cover-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--muted); +} +.album-meta { + padding: 0.6rem 0.8rem; + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; +} + +/* justified gallery */ +.gallery { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 1rem 0; +} +.g-item { + position: relative; + height: 240px; + flex-grow: calc(var(--ar) * 100); + flex-basis: calc(var(--ar) * 240px); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + background: var(--panel); +} +.g-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.g-spacer { + flex-grow: 1000000; + flex-basis: 0; + height: 0; +} +.g-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + gap: 0.6rem; + padding: 0.35rem 0.55rem; + font-size: 0.8rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); + color: #ffd97a; +} + +/* selection */ +.g-check { + position: absolute; + top: 8px; + left: 8px; + width: 26px; + height: 26px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.85); + background: rgba(0, 0, 0, 0.35); + color: transparent; + font-size: 0.85rem; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s; +} +.g-item:hover .g-check, +.gallery.selecting .g-check, +.g-item.selected .g-check { + opacity: 1; +} +.g-item.selected .g-check { + background: var(--accent); + border-color: var(--accent); + color: #1a1408; +} +.g-item.selected img { + outline: 3px solid var(--accent); + outline-offset: -3px; +} +.select-bar { + position: fixed; + bottom: 1.25rem; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--panel); + border: 1px solid var(--panel-2); + border-radius: 999px; + padding: 0.5rem 1rem; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); + z-index: 50; +} +.select-count { + font-size: 0.9rem; + white-space: nowrap; +} +.select-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: var(--muted); + cursor: pointer; +} +.select-toggle input { + accent-color: var(--accent); + width: 16px; + height: 16px; +} + +/* upload zone */ +.upload-zone { + border: 2px dashed var(--panel-2); + border-radius: var(--radius); + padding: 1.25rem; + text-align: center; + color: var(--muted); + cursor: pointer; + margin-bottom: 1rem; +} +.upload-zone.dragging { + border-color: var(--accent); + color: var(--accent); +} +.upload-zone p { + margin: 0; +} +.upload-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + text-align: left; + max-height: 220px; + overflow-y: auto; + cursor: default; +} +.upload-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.2rem 0; + font-size: 0.85rem; +} +.upload-item.done { + color: var(--muted); +} +.upload-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +progress { + width: 160px; + accent-color: var(--accent); +} + +/* panels */ +.panel { + background: var(--panel); + border-radius: var(--radius); + padding: 1rem 1.25rem; + margin: 1.5rem 0; +} +.pending-list { + list-style: none; + margin: 0; + padding: 0; +} +.pending-list li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.25rem 0; + font-size: 0.9rem; +} + +/* shares */ +.share-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--panel-2); +} +.share-info { + display: flex; + flex-direction: column; + min-width: 0; +} +.share-info .muted { + font-size: 0.82rem; +} +.share-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 1rem; +} +.share-form label { + font-size: 0.85rem; + color: var(--muted); +} +.field-label { + gap: 0.4rem; +} +.field-label .btn-ghost { + padding: 0.2rem 0.4rem; +} + +/* share (client) page */ +.share-head { + text-align: center; + padding: 2.5rem 1rem 0.5rem; +} +.share-head h1 { + font-size: 1.8rem; + font-weight: 300; +} +.share-head p { + margin: 0.4rem 0 0; +} + +/* lightbox */ +.lightbox { + position: fixed; + inset: 0; + background: rgba(8, 9, 11, 0.96); + z-index: 100; + display: flex; + flex-direction: column; +} +.lb-top { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 1rem; + color: var(--muted); + font-size: 0.85rem; +} +.lb-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lb-btn { + background: none; + border: none; + color: var(--text); + font-size: 1.1rem; + cursor: pointer; +} +.lb-stage { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3.5rem; +} +.lb-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + cursor: default; +} +.lb-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.06); + border: none; + color: var(--text); + font-size: 2rem; + line-height: 1; + padding: 0.6rem 0.8rem; + border-radius: 50%; + cursor: pointer; + z-index: 101; +} +.lb-nav:disabled { + opacity: 0.25; + cursor: default; +} +.lb-prev { + left: 0.75rem; +} +.lb-next { + right: 0.75rem; +} +.lb-footer { + padding: 0.7rem 1rem 1rem; +} +.client-footer, +.admin-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; + flex-wrap: wrap; +} +.feedback { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.85rem; +} +.feedback-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +/* stars */ +.stars { + display: inline-flex; +} +.star { + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + color: #4a4a45; + cursor: pointer; + padding: 0 0.1rem; +} +.star.filled { + color: var(--accent); +} +.stars-small .star { + font-size: 0.95rem; + cursor: default; +} +.stars-readonly .star { + cursor: default; +} + +/* tags */ +.tag-editor { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} +.tag-editor input { + width: 110px; + padding: 0.3rem 0.5rem; + font-size: 0.85rem; +} +.chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: var(--panel-2); + border-radius: 999px; + padding: 0.15rem 0.6rem; + font-size: 0.82rem; +} +.chip button { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 0.95rem; + padding: 0; +} + +@media (max-width: 700px) { + .g-item { + height: 160px; + flex-basis: calc(var(--ar) * 160px); + } + .lb-stage { + padding: 0 0.5rem; + } + .login-card { + padding: 2rem 1.5rem; + margin: 0 1rem; + } +} diff --git a/frontend/src/useSelection.js b/frontend/src/useSelection.js new file mode 100644 index 0000000..8e0cb34 --- /dev/null +++ b/frontend/src/useSelection.js @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +// Multi-select over a photo list. Selection lives here (not in the gallery) +// so it survives lightbox open/close, and is pruned automatically when +// photos disappear from the list (deletes, polling refreshes). +export default function useSelection(photos) { + const [selected, setSelected] = useState(() => new Set()) + + useEffect(() => { + setSelected((prev) => { + if (prev.size === 0) return prev + const valid = new Set(photos.map((p) => p.id)) + const next = new Set([...prev].filter((id) => valid.has(id))) + return next.size === prev.size ? prev : next + }) + }, [photos]) + + const toggle = (photoId) => + setSelected((prev) => { + const next = new Set(prev) + if (next.has(photoId)) next.delete(photoId) + else next.add(photoId) + return next + }) + + const selectAll = () => setSelected(new Set(photos.map((p) => p.id))) + const clear = () => setSelected(new Set()) + const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0) + const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0) + + return { selected, toggle, selectAll, clear, selectedBytes, totalBytes } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..b08b640 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, +}) diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..83efe3a --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,89 @@ +create extension if not exists pgcrypto; + +create table users ( + id uuid primary key default gen_random_uuid(), + oidc_subject text not null unique, + email text not null, + display_name text, + created_at timestamptz not null default now() +); + +create table albums ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text not null default '', + created_at timestamptz not null default now() +); + +create table photos ( + id uuid primary key default gen_random_uuid(), + album_id uuid not null references albums(id) on delete cascade, + filename text not null, + content_type text not null, + size_bytes bigint not null default 0, + status text not null default 'uploaded', -- uploaded | processing | ready | error + error text, + width int, + height int, + taken_at timestamptz, + -- Bumped on every (re)process; cache-buster for preview/thumb URLs. + processed_at timestamptz, + created_at timestamptz not null default now() +); + +create index photos_album_idx on photos (album_id); + +create table shares ( + id uuid primary key default gen_random_uuid(), + album_id uuid not null references albums(id) on delete cascade, + token text not null unique, + label text not null default '', + password_hash text, + allow_download boolean not null default true, + expires_at timestamptz, + -- Brute-force lockout state for password-protected shares. + failed_attempts int not null default 0, + locked_until timestamptz, + created_at timestamptz not null default now() +); + +create index shares_album_idx on shares (album_id); + +create table ratings ( + share_id uuid not null references shares(id) on delete cascade, + photo_id uuid not null references photos(id) on delete cascade, + rating int not null check (rating between 1 and 5), + updated_at timestamptz not null default now(), + primary key (share_id, photo_id) +); + +-- Cascaded photo deletes fire per-row FK triggers; without this each one +-- sequential-scans the table. +create index ratings_photo_idx on ratings (photo_id); + +create table tags ( + id uuid primary key default gen_random_uuid(), + share_id uuid not null references shares(id) on delete cascade, + photo_id uuid not null references photos(id) on delete cascade, + tag text not null, + created_at timestamptz not null default now(), + unique (share_id, photo_id, tag) +); + +create index tags_photo_idx on tags (photo_id); + +create table jobs ( + id uuid primary key default gen_random_uuid(), + kind text not null, + payload jsonb not null default '{}', + status text not null default 'queued', -- queued | running | done | failed + attempts int not null default 0, + max_attempts int not null default 5, + run_at timestamptz not null default now(), + locked_by text, + locked_at timestamptz, + last_error text, + created_at timestamptz not null default now() +); + +create index jobs_poll_idx on jobs (status, run_at); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..1f6753e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +# aws-sdk-s3 (and friends) currently require rustc >= 1.94.1 +channel = "1.94.1" diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..eb416ff --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,312 @@ +use axum::extract::{FromRequestParts, Query, State}; +use axum::http::request::Parts; +use axum::response::Redirect; +use axum::Json; +use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; +use chrono::Utc; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +pub const SESSION_COOKIE: &str = "photos_session"; +const STATE_COOKIE: &str = "photos_oauth_state"; +const SESSION_DAYS: i64 = 30; + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcDiscovery { + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, +} + +pub async fn discovery(state: &AppState) -> anyhow::Result { + let discovered = state + .oidc + .get_or_try_init(|| async { + let url = format!( + "{}/.well-known/openid-configuration", + state.config.oidc_issuer + ); + let resp = state.http.get(&url).send().await?.error_for_status()?; + Ok::<_, anyhow::Error>(resp.json::().await?) + }) + .await?; + Ok(discovered.clone()) +} + +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: Uuid, + pub email: String, +} + +fn parse_session(value: &str) -> Option<(Uuid, i64, String)> { + let mut parts = value.splitn(3, '|'); + let id = Uuid::parse_str(parts.next()?).ok()?; + let exp: i64 = parts.next()?.parse().ok()?; + let email = parts.next()?.to_string(); + Some((id, exp, email)) +} + +/// Sessions are only honored while the email is still in ALLOWED_EMAILS, so +/// removing an address from the allowlist revokes access immediately. +pub fn user_from_jar(state: &AppState, jar: &SignedCookieJar) -> Option { + let cookie = jar.get(SESSION_COOKIE)?; + let (id, exp, email) = parse_session(cookie.value())?; + if exp < Utc::now().timestamp() { + return None; + } + if !state.config.allowed_emails.contains(&email) { + return None; + } + Some(AuthUser { id, email }) +} + +/// A fresh session cookie when the current one has used up more than half its +/// lifetime — appended to responses by the admin middleware so an active +/// photographer's session slides instead of hard-expiring 30 days after login. +pub fn refreshed_session(state: &AppState, jar: &SignedCookieJar) -> Option> { + let cookie = jar.get(SESSION_COOKIE)?; + let (id, exp, email) = parse_session(cookie.value())?; + let remaining = exp - Utc::now().timestamp(); + if remaining > SESSION_DAYS * 86400 / 2 { + return None; + } + Some(session_cookie(state, id, &email)) +} + +impl FromRequestParts for AuthUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + _state: &AppState, + ) -> Result { + // Only valid behind the require_photographer layer, which validates + // the session and stashes the user. Routes outside the admin router + // must not use this extractor — session validation lives in the + // middleware (and user_from_jar for the dual-auth image routes). + parts + .extensions + .get::() + .cloned() + .ok_or_else(ApiError::unauthorized) + } +} + +pub fn random_token(len: usize) -> String { + use rand::Rng; + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Cookie<'static> { + Cookie::build((name, value)) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .secure(secure) + .build() +} + +fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> { + let exp = Utc::now().timestamp() + SESSION_DAYS * 86400; + let mut cookie = base_cookie( + SESSION_COOKIE, + format!("{user_id}|{exp}|{email}"), + state.config.cookie_secure(), + ); + cookie.set_max_age(time::Duration::days(SESSION_DAYS)); + cookie +} + +async fn upsert_user( + state: &AppState, + oidc_subject: &str, + email: &str, + display_name: &str, +) -> Result { + let (user_id,): (Uuid,) = sqlx::query_as( + "insert into users (oidc_subject, email, display_name) values ($1, $2, $3) + on conflict (oidc_subject) do update + set email = excluded.email, display_name = excluded.display_name + returning id", + ) + .bind(oidc_subject) + .bind(email) + .bind(display_name) + .fetch_one(&state.db) + .await?; + Ok(user_id) +} + +/// Login/callback are top-level browser navigations — errors must land the +/// user back on the SPA login card, never on a raw JSON body. +fn error_redirect(error: &ApiError) -> Redirect { + let message = if error.0.is_server_error() { + "sign-in failed — please try again" + } else { + error.1.as_str() + }; + Redirect::to(&format!("/?auth_error={}", urlencoding::encode(message))) +} + +pub async fn login( + State(state): State, + jar: SignedCookieJar, +) -> (SignedCookieJar, Redirect) { + match login_inner(&state, jar.clone()).await { + Ok(ok) => ok, + Err(e) => { + tracing::warn!("login failed: {} {}", e.0, e.1); + (jar, error_redirect(&e)) + } + } +} + +async fn login_inner( + state: &AppState, + jar: SignedCookieJar, +) -> ApiResult<(SignedCookieJar, Redirect)> { + if let Some(email) = state.config.dev_autologin_email.clone() { + tracing::warn!("DEV_AUTOLOGIN_EMAIL is set — signing in {email} without OIDC"); + let email = email.to_lowercase(); + if !state.config.allowed_emails.contains(&email) { + return Err(ApiError::forbidden( + "DEV_AUTOLOGIN_EMAIL must also be in ALLOWED_EMAILS", + )); + } + let user_id = upsert_user(state, &format!("dev:{email}"), &email, &email).await?; + let cookie = session_cookie(state, user_id, &email); + return Ok((jar.add(cookie), Redirect::to("/"))); + } + let discovered = discovery(state).await?; + let oauth_state = random_token(24); + let redirect_uri = format!("{}/api/auth/callback", state.config.public_url); + let separator = if discovered.authorization_endpoint.contains('?') { + '&' + } else { + '?' + }; + let url = format!( + "{}{}response_type=code&client_id={}&redirect_uri={}&scope=openid%20email%20profile&state={}", + discovered.authorization_endpoint, + separator, + urlencoding::encode(&state.config.oidc_client_id), + urlencoding::encode(&redirect_uri), + oauth_state + ); + let mut cookie = base_cookie(STATE_COOKIE, oauth_state, state.config.cookie_secure()); + cookie.set_max_age(time::Duration::minutes(10)); + Ok((jar.add(cookie), Redirect::to(&url))) +} + +#[derive(Deserialize)] +pub struct CallbackQuery { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct UserInfo { + sub: String, + email: Option, + name: Option, + preferred_username: Option, +} + +pub async fn callback( + State(state): State, + jar: SignedCookieJar, + Query(query): Query, +) -> (SignedCookieJar, Redirect) { + match callback_inner(&state, jar.clone(), query).await { + Ok(ok) => ok, + Err(e) => { + tracing::warn!("oidc callback failed: {} {}", e.0, e.1); + (jar, error_redirect(&e)) + } + } +} + +async fn callback_inner( + state: &AppState, + jar: SignedCookieJar, + query: CallbackQuery, +) -> ApiResult<(SignedCookieJar, Redirect)> { + if let Some(err) = query.error { + let detail = query.error_description.unwrap_or_default(); + return Err(ApiError::bad_request(format!("oidc error: {err} {detail}"))); + } + let code = query + .code + .ok_or_else(|| ApiError::bad_request("missing code"))?; + let returned_state = query.state.unwrap_or_default(); + let cookie_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string()); + if returned_state.is_empty() || cookie_state.as_deref() != Some(returned_state.as_str()) { + return Err(ApiError::bad_request("oauth state mismatch")); + } + let jar = jar.remove(Cookie::build((STATE_COOKIE, "")).path("/").build()); + + let discovered = discovery(state).await?; + let redirect_uri = format!("{}/api/auth/callback", state.config.public_url); + let token: TokenResponse = state + .http + .post(&discovered.token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ("client_id", state.config.oidc_client_id.as_str()), + ("client_secret", state.config.oidc_client_secret.as_str()), + ]) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let info: UserInfo = state + .http + .get(&discovered.userinfo_endpoint) + .bearer_auth(&token.access_token) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let email = info.email.clone().unwrap_or_default().to_lowercase(); + if email.is_empty() || !state.config.allowed_emails.contains(&email) { + return Err(ApiError::forbidden("this account is not allowed to sign in")); + } + let display_name = info + .name + .or(info.preferred_username) + .unwrap_or_else(|| email.clone()); + + let user_id = upsert_user(state, &info.sub, &email, &display_name).await?; + let cookie = session_cookie(state, user_id, &email); + Ok((jar.add(cookie), Redirect::to("/"))) +} + +pub async fn logout(jar: SignedCookieJar) -> (SignedCookieJar, Json) { + let jar = jar.remove(Cookie::build((SESSION_COOKIE, "")).path("/").build()); + (jar, Json(serde_json::json!({ "ok": true }))) +} + +pub async fn me(user: AuthUser) -> Json { + Json(serde_json::json!({ "email": user.email })) +} diff --git a/src/bin/server.rs b/src/bin/server.rs new file mode 100644 index 0000000..92442b3 --- /dev/null +++ b/src/bin/server.rs @@ -0,0 +1,33 @@ +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::EnvFilter; + +use photos::config::Config; +use photos::state::AppState; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()), + ) + .init(); + + let config = Config::from_env()?; + let state = AppState::new(config).await?; + + let static_dir = state.config.static_dir.clone(); + let index = std::path::Path::new(&static_dir).join("index.html"); + // .fallback (not .not_found_service) so SPA routes get index.html with a 200 + let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index)); + + let app = photos::routes::router(&state) + .fallback_service(spa) + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let listener = tokio::net::TcpListener::bind(&state.config.bind_addr).await?; + tracing::info!("listening on http://{}", state.config.bind_addr); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/src/bin/worker.rs b/src/bin/worker.rs new file mode 100644 index 0000000..b0b4ccc --- /dev/null +++ b/src/bin/worker.rs @@ -0,0 +1,19 @@ +use tracing_subscriber::EnvFilter; + +use photos::config::Config; +use photos::jobs; +use photos::state::AppState; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()), + ) + .init(); + + let config = Config::from_env()?; + let state = AppState::new(config).await?; + jobs::run_worker(state).await; + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..33f42f3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,81 @@ +use anyhow::Context; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub bind_addr: String, + /// External base URL of the app, e.g. https://photos.example.com (no trailing slash). + pub public_url: String, + pub session_secret: String, + pub s3_bucket: String, + pub s3_endpoint: Option, + pub s3_region: String, + pub s3_access_key: String, + pub s3_secret_key: String, + pub s3_force_path_style: bool, + pub oidc_issuer: String, + pub oidc_client_id: String, + pub oidc_client_secret: String, + /// Lowercased email addresses allowed to sign in as photographer. + pub allowed_emails: Vec, + pub static_dir: String, + pub worker_concurrency: usize, + /// DEV ONLY: if set, /api/auth/login skips OIDC entirely and signs in as + /// this email. Never set in production. + pub dev_autologin_email: Option, +} + +fn required(name: &str) -> anyhow::Result { + std::env::var(name).with_context(|| format!("missing required env var {name}")) +} + +fn optional(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + +impl Config { + pub fn from_env() -> anyhow::Result { + let session_secret = required("SESSION_SECRET")?; + anyhow::ensure!( + session_secret.len() >= 32, + "SESSION_SECRET must be at least 32 characters" + ); + let public_url = required("PUBLIC_URL")?.trim_end_matches('/').to_string(); + let dev_autologin_email = optional("DEV_AUTOLOGIN_EMAIL"); + anyhow::ensure!( + dev_autologin_email.is_none() || !public_url.starts_with("https://"), + "DEV_AUTOLOGIN_EMAIL must not be set when PUBLIC_URL is https:// — it disables login" + ); + Ok(Self { + database_url: required("DATABASE_URL")?, + bind_addr: optional("BIND_ADDR").unwrap_or_else(|| "0.0.0.0:8080".into()), + public_url, + session_secret, + s3_bucket: required("S3_BUCKET")?, + s3_endpoint: optional("S3_ENDPOINT"), + s3_region: optional("S3_REGION").unwrap_or_else(|| "us-east-1".into()), + s3_access_key: required("S3_ACCESS_KEY")?, + s3_secret_key: required("S3_SECRET_KEY")?, + s3_force_path_style: optional("S3_FORCE_PATH_STYLE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false), + oidc_issuer: required("OIDC_ISSUER")?.trim_end_matches('/').to_string(), + oidc_client_id: required("OIDC_CLIENT_ID")?, + oidc_client_secret: required("OIDC_CLIENT_SECRET")?, + allowed_emails: required("ALLOWED_EMAILS")? + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(), + static_dir: optional("STATIC_DIR").unwrap_or_else(|| "frontend/dist".into()), + worker_concurrency: optional("WORKER_CONCURRENCY") + .and_then(|v| v.parse().ok()) + .unwrap_or(2), + dev_autologin_email, + }) + } + + pub fn cookie_secure(&self) -> bool { + self.public_url.starts_with("https://") + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..117ceab --- /dev/null +++ b/src/error.rs @@ -0,0 +1,55 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; + +pub struct ApiError(pub StatusCode, pub String); + +pub type ApiResult = Result; + +impl ApiError { + pub fn bad_request(msg: impl Into) -> Self { + Self(StatusCode::BAD_REQUEST, msg.into()) + } + pub fn unauthorized() -> Self { + Self(StatusCode::UNAUTHORIZED, "authentication required".into()) + } + pub fn forbidden(msg: impl Into) -> Self { + Self(StatusCode::FORBIDDEN, msg.into()) + } + pub fn not_found() -> Self { + Self(StatusCode::NOT_FOUND, "not found".into()) + } + pub fn gone(msg: impl Into) -> Self { + Self(StatusCode::GONE, msg.into()) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(serde_json::json!({ "error": self.1 }))).into_response() + } +} + +impl From for ApiError { + fn from(e: sqlx::Error) -> Self { + if matches!(e, sqlx::Error::RowNotFound) { + return Self::not_found(); + } + tracing::error!("database error: {e}"); + Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) + } +} + +impl From for ApiError { + fn from(e: anyhow::Error) -> Self { + tracing::error!("internal error: {e:#}"); + Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) + } +} + +impl From for ApiError { + fn from(e: reqwest::Error) -> Self { + tracing::error!("upstream http error: {e}"); + Self(StatusCode::BAD_GATEWAY, "upstream error".into()) + } +} diff --git a/src/imaging.rs b/src/imaging.rs new file mode 100644 index 0000000..d55c1fb --- /dev/null +++ b/src/imaging.rs @@ -0,0 +1,253 @@ +use std::io::Cursor; +use std::path::Path; + +use anyhow::Context; +use chrono::{DateTime, NaiveDateTime, Utc}; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use image::DynamicImage; +use serde::Deserialize; +use uuid::Uuid; + +use crate::models::{Photo, PhotoStatus}; +use crate::s3; +use crate::state::AppState; + +const PREVIEW_EDGE: u32 = 2048; +const THUMB_EDGE: u32 = 512; + +pub const RAW_EXTENSIONS: &[&str] = &[ + "3fr", "arw", "cr2", "cr3", "dng", "erf", "iiq", "kdc", "mef", "mos", "nef", "nrw", "orf", + "pef", "raf", "raw", "rw2", "rwl", "srw", "x3f", +]; + +pub fn is_raw_filename(filename: &str) -> bool { + Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .map(|e| RAW_EXTENSIONS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) +} + +#[derive(Deserialize)] +struct ProcessPayload { + photo_id: Uuid, +} + +pub async fn process_photo_job(state: &AppState, payload: &serde_json::Value) -> anyhow::Result<()> { + let payload: ProcessPayload = serde_json::from_value(payload.clone())?; + process_photo(state, payload.photo_id).await +} + +pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<()> { + let photo: Option = sqlx::query_as("select * from photos where id = $1") + .bind(photo_id) + .fetch_optional(&state.db) + .await?; + let Some(photo) = photo else { + tracing::warn!("photo {photo_id} no longer exists, skipping"); + return Ok(()); + }; + + sqlx::query("update photos set status = $2, error = null where id = $1") + .bind(photo_id) + .bind(PhotoStatus::Processing.as_str()) + .execute(&state.db) + .await?; + + let dir = tempfile::tempdir().context("creating temp dir")?; + let extension = Path::new(&photo.filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin") + .to_lowercase(); + let src_path = dir.path().join(format!("original.{extension}")); + download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?; + + let meta = exif_metadata(&src_path).await?; + + let render_input = if RAW_EXTENSIONS.contains(&extension.as_str()) { + extract_embedded_jpeg(&src_path).await? + } else { + tokio::fs::read(&src_path).await.context("reading original")? + }; + + let orientation = meta.orientation; + let (preview, thumb, width, height) = + tokio::task::spawn_blocking(move || render(&render_input, orientation)) + .await + .context("render task panicked")??; + + s3::put_bytes(state, &s3::preview_key(photo_id), preview, "image/jpeg").await?; + s3::put_bytes(state, &s3::thumb_key(photo_id), thumb, "image/jpeg").await?; + + let updated = sqlx::query( + "update photos + set status = $5, error = null, width = $2, height = $3, + taken_at = coalesce($4, taken_at), processed_at = now() + where id = $1", + ) + .bind(photo_id) + .bind(width as i32) + .bind(height as i32) + .bind(meta.taken_at) + .bind(PhotoStatus::Ready.as_str()) + .execute(&state.db) + .await?; + if updated.rows_affected() == 0 { + // Photo was deleted while we were processing; its delete_s3_prefix job + // may already have run, so remove the derivatives we just re-created. + tracing::warn!("photo {photo_id} deleted during processing; cleaning up derivatives"); + s3::delete_prefix(state, &s3::photo_prefix(photo_id)).await?; + } + Ok(()) +} + +async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()> { + let object = state + .s3 + .get_object() + .bucket(&state.config.s3_bucket) + .key(key) + .send() + .await + .with_context(|| format!("fetching s3://{}/{key}", state.config.s3_bucket))?; + let mut reader = object.body.into_async_read(); + let mut file = tokio::fs::File::create(path).await?; + tokio::io::copy(&mut reader, &mut file).await?; + Ok(()) +} + +#[derive(Default)] +struct ExifMeta { + orientation: u32, + taken_at: Option>, +} + +async fn exif_metadata(path: &Path) -> anyhow::Result { + let output = tokio::process::Command::new("exiftool") + .args([ + "-j", + "-d", + "%Y-%m-%dT%H:%M:%S", + "-Orientation#", + "-DateTimeOriginal", + "-CreateDate", + "-OffsetTimeOriginal", + "-OffsetTime", + ]) + .arg(path) + .output() + .await; + let output = match output { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("exiftool is not installed or not on PATH") + } + other => other.context("running exiftool")?, + }; + if !output.status.success() { + tracing::warn!( + "exiftool metadata read failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return Ok(ExifMeta::default()); + } + let parsed: Vec = + serde_json::from_slice(&output.stdout).context("parsing exiftool json")?; + let entry = parsed.first().cloned().unwrap_or_default(); + let orientation = entry + .get("Orientation") + .and_then(|v| v.as_u64()) + .map(|v| v as u32) + .unwrap_or(1); + let offset = ["OffsetTimeOriginal", "OffsetTime"] + .iter() + .find_map(|field| entry.get(*field).and_then(|v| v.as_str())); + let taken_at = ["DateTimeOriginal", "CreateDate"] + .iter() + .filter_map(|field| entry.get(*field).and_then(|v| v.as_str())) + .find_map(|s| parse_exif_datetime(s, offset)); + Ok(ExifMeta { + orientation, + taken_at, + }) +} + +/// EXIF datetimes are camera-local wall-clock time; apply the EXIF offset tag +/// when the camera recorded one, otherwise fall back to treating it as UTC. +fn parse_exif_datetime(s: &str, offset: Option<&str>) -> Option> { + if let Some(offset) = offset { + if let Ok(dt) = DateTime::parse_from_str(&format!("{s}{offset}"), "%Y-%m-%dT%H:%M:%S%:z") { + return Some(dt.with_timezone(&Utc)); + } + } + NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") + .ok() + .map(|naive| naive.and_utc()) +} + +/// Extract the largest embedded JPEG preview from a raw file using exiftool. +async fn extract_embedded_jpeg(path: &Path) -> anyhow::Result> { + let mut best: Vec = Vec::new(); + for tag in ["-JpgFromRaw", "-PreviewImage", "-OtherImage", "-ThumbnailImage"] { + let output = tokio::process::Command::new("exiftool") + .args(["-b", tag]) + .arg(path) + .output() + .await + .context("running exiftool")?; + if output.status.success() && output.stdout.len() > best.len() { + best = output.stdout; + } + // A full-size embedded preview is comfortably above this; stop early. + if best.len() > 200_000 { + break; + } + } + anyhow::ensure!( + best.len() > 1_000, + "no usable embedded preview found in raw file" + ); + Ok(best) +} + +fn render(bytes: &[u8], orientation: u32) -> anyhow::Result<(Vec, Vec, u32, u32)> { + let img = image::load_from_memory(bytes).context("decoding image")?; + let img = apply_orientation(img, orientation); + let (width, height) = (img.width(), img.height()); + + let preview = if width.max(height) > PREVIEW_EDGE { + img.resize(PREVIEW_EDGE, PREVIEW_EDGE, FilterType::Triangle) + } else { + img + }; + let thumb = preview.resize(THUMB_EDGE, THUMB_EDGE, FilterType::Lanczos3); + + Ok(( + encode_jpeg(&preview, 86)?, + encode_jpeg(&thumb, 82)?, + width, + height, + )) +} + +fn encode_jpeg(img: &DynamicImage, quality: u8) -> anyhow::Result> { + let rgb = img.to_rgb8(); + let mut buf = Cursor::new(Vec::new()); + let encoder = JpegEncoder::new_with_quality(&mut buf, quality); + rgb.write_with_encoder(encoder).context("encoding jpeg")?; + Ok(buf.into_inner()) +} + +fn apply_orientation(img: DynamicImage, orientation: u32) -> DynamicImage { + match orientation { + 2 => img.fliph(), + 3 => img.rotate180(), + 4 => img.flipv(), + 5 => img.rotate90().fliph(), + 6 => img.rotate90(), + 7 => img.rotate270().fliph(), + 8 => img.rotate270(), + _ => img, + } +} diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 0000000..3513fc0 --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,329 @@ +use std::time::Duration; + +use uuid::Uuid; + +use crate::models::{JobKind, JobStatus, PhotoStatus}; +use crate::state::AppState; + +/// Hard ceiling on a single job run; the sole bound for a live-but-hung worker +/// (a hung S3 read or exiftool child), since the heartbeat keeps the reaper away. +const JOB_TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// How often a running job refreshes its lock. Must stay well below the +/// reaper's staleness threshold. +const HEARTBEAT_EVERY: Duration = Duration::from_secs(300); +/// A 'running' job whose lock is older than this had its worker die. +const STALE_AFTER: &str = "15 minutes"; + +#[derive(Debug, sqlx::FromRow)] +pub struct Job { + pub id: Uuid, + pub kind: String, + pub payload: serde_json::Value, + pub attempts: i32, + pub max_attempts: i32, +} + +pub async fn enqueue<'e, E>( + executor: E, + kind: JobKind, + payload: serde_json::Value, +) -> Result<(), sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query("insert into jobs (kind, payload) values ($1, $2)") + .bind(kind.as_str()) + .bind(payload) + .execute(executor) + .await?; + Ok(()) +} + +/// Make sure a process_photo job will (re)run for this photo: bump a queued +/// one to run now with fresh attempts; leave a running one alone (resetting +/// its attempts wouldn't reach the in-flight worker, which decides exhaustion +/// from its claim-time copy — it will finish or fail on its own and the photo +/// can be retried again); enqueue fresh otherwise. +pub async fn ensure_process_photo( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + photo_id: Uuid, +) -> Result<(), sqlx::Error> { + let requeued = sqlx::query( + "update jobs set run_at = now(), attempts = 0 + where kind = $2 and status = $3 and payload->>'photo_id' = $1", + ) + .bind(photo_id.to_string()) + .bind(JobKind::ProcessPhoto.as_str()) + .bind(JobStatus::Queued.as_str()) + .execute(&mut **tx) + .await?; + if requeued.rows_affected() > 0 { + return Ok(()); + } + let running: Option<(Uuid,)> = sqlx::query_as( + "select id from jobs + where kind = $2 and status = $3 and payload->>'photo_id' = $1", + ) + .bind(photo_id.to_string()) + .bind(JobKind::ProcessPhoto.as_str()) + .bind(JobStatus::Running.as_str()) + .fetch_optional(&mut **tx) + .await?; + if running.is_some() { + return Ok(()); + } + enqueue( + &mut **tx, + JobKind::ProcessPhoto, + serde_json::json!({ "photo_id": photo_id }), + ) + .await +} + +pub async fn run_worker(state: AppState) { + let concurrency = state.config.worker_concurrency.max(1); + // Unique per process so locked_by distinguishes workers across replicas. + let instance = crate::auth::random_token(6); + tracing::info!("starting worker {instance} with concurrency {concurrency}"); + let mut handles = Vec::new(); + handles.push(tokio::spawn(reaper_loop(state.clone()))); + for i in 0..concurrency { + let state = state.clone(); + handles.push(tokio::spawn(worker_loop(state, format!("worker-{instance}-{i}")))); + } + for handle in handles { + let _ = handle.await; + } +} + +/// Requeue stale jobs whose worker died mid-run — but only while they have +/// attempts left; exhausted stale jobs are failed outright so a job that +/// crashes its worker (e.g. OOM during decode) cannot crash-loop forever. +async fn reaper_loop(state: AppState) { + loop { + let failed: Result, sqlx::Error> = sqlx::query_as(&format!( + "update jobs set status = $1, locked_by = null, + last_error = coalesce(last_error, 'worker lost repeatedly (crash loop?)') + where (status = $2 and locked_at < now() - interval '{STALE_AFTER}' + or status = $3) + and attempts >= max_attempts + returning kind, payload" + )) + .bind(JobStatus::Failed.as_str()) + .bind(JobStatus::Running.as_str()) + .bind(JobStatus::Queued.as_str()) + .fetch_all(&state.db) + .await; + match failed { + Ok(jobs) => { + for (kind, payload) in jobs { + tracing::error!(kind, "reaper failed exhausted job"); + mark_photo_error(&state, &kind, &payload, "processing failed repeatedly").await; + } + } + Err(e) => tracing::error!("job reaper (fail pass) errored: {e}"), + } + + let requeued = sqlx::query(&format!( + "update jobs set status = $1, locked_by = null, locked_at = null + where status = $2 and locked_at < now() - interval '{STALE_AFTER}' + and attempts < max_attempts" + )) + .bind(JobStatus::Queued.as_str()) + .bind(JobStatus::Running.as_str()) + .execute(&state.db) + .await; + match requeued { + Ok(r) if r.rows_affected() > 0 => { + tracing::warn!("requeued {} stale running job(s)", r.rows_affected()) + } + Ok(_) => {} + Err(e) => tracing::error!("job reaper (requeue pass) errored: {e}"), + } + tokio::time::sleep(Duration::from_secs(60)).await; + } +} + +/// Terminal-failure side effect for process_photo jobs: surface the error on +/// the photo, but never overwrite a photo a newer job already finished. +async fn mark_photo_error(state: &AppState, kind: &str, payload: &serde_json::Value, message: &str) { + if kind != JobKind::ProcessPhoto.as_str() { + return; + } + let Some(photo_id) = payload + .get("photo_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + else { + return; + }; + // A job can die before its first status write, so rescue photos stuck in + // 'uploaded' as well as 'processing' — but never overwrite 'ready'. + let _ = sqlx::query( + "update photos set status = $3, error = $2 + where id = $1 and status in ($4, $5)", + ) + .bind(photo_id) + .bind(message) + .bind(PhotoStatus::Error.as_str()) + .bind(PhotoStatus::Processing.as_str()) + .bind(PhotoStatus::Uploaded.as_str()) + .execute(&state.db) + .await; +} + +async fn worker_loop(state: AppState, name: String) { + loop { + match claim(&state, &name).await { + Ok(Some(job)) => execute(&state, job, &name).await, + Ok(None) => tokio::time::sleep(Duration::from_secs(2)).await, + Err(e) => { + tracing::error!("failed to claim job: {e}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } +} + +async fn claim(state: &AppState, name: &str) -> Result, sqlx::Error> { + sqlx::query_as( + "update jobs + set status = $2, locked_by = $1, locked_at = now(), attempts = attempts + 1 + where id = ( + select id from jobs + where status = $3 and run_at <= now() and attempts < max_attempts + order by created_at + limit 1 + for update skip locked + ) + returning id, kind, payload, attempts, max_attempts", + ) + .bind(name) + .bind(JobStatus::Running.as_str()) + .bind(JobStatus::Queued.as_str()) + .fetch_optional(&state.db) + .await +} + +/// Keep locked_at fresh while a job runs so the reaper never requeues a job +/// whose worker is alive. Never completes; raced against the job in select!. +async fn heartbeat(state: &AppState, job_id: Uuid, name: &str) { + loop { + tokio::time::sleep(HEARTBEAT_EVERY).await; + let _ = sqlx::query( + "update jobs set locked_at = now() + where id = $1 and locked_by = $2 and status = $3", + ) + .bind(job_id) + .bind(name) + .bind(JobStatus::Running.as_str()) + .execute(&state.db) + .await; + } +} + +async fn execute(state: &AppState, job: Job, name: &str) { + tracing::info!(job_id = %job.id, kind = %job.kind, attempt = job.attempts, "job started"); + // Parse the kind here (not at claim decode) so an unknown kind — e.g. + // enqueued by a newer deploy — fails THIS job normally instead of + // poisoning the claim loop. + let run = async { + match job.kind.parse::() { + Ok(JobKind::ProcessPhoto) => { + crate::imaging::process_photo_job(state, &job.payload).await + } + Ok(JobKind::DeleteS3Prefix) => delete_s3_prefix_job(state, &job.payload).await, + Err(e) => Err(anyhow::anyhow!(e)), + } + }; + let result = tokio::select! { + result = tokio::time::timeout(JOB_TIMEOUT, run) => match result { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!( + "job timed out after {}s", + JOB_TIMEOUT.as_secs() + )), + }, + _ = heartbeat(state, job.id, name) => unreachable!("heartbeat never completes"), + }; + // Finalization is guarded on locked_by so a worker whose job was reclaimed + // (reaper) cannot overwrite the state written by the new owner. + match result { + Ok(()) => { + let updated = sqlx::query( + "update jobs set status = $3, locked_by = null, last_error = null + where id = $1 and locked_by = $2 and status = $4", + ) + .bind(job.id) + .bind(name) + .bind(JobStatus::Done.as_str()) + .bind(JobStatus::Running.as_str()) + .execute(&state.db) + .await; + match updated { + Ok(r) if r.rows_affected() == 0 => { + tracing::warn!(job_id = %job.id, "job was reclaimed by another worker; result discarded") + } + Ok(_) => tracing::info!(job_id = %job.id, kind = %job.kind, "job done"), + Err(e) => tracing::error!(job_id = %job.id, "failed to finalize job: {e}"), + } + } + Err(e) => { + let message = format!("{e:#}"); + let exhausted = job.attempts >= job.max_attempts; + tracing::error!(job_id = %job.id, kind = %job.kind, exhausted, "job failed: {message}"); + // Two self-contained query+bind branches — the placeholder lists + // and bind chains must never be shared across branches. + let finalize = if exhausted { + sqlx::query( + "update jobs set status = $4, locked_by = null, last_error = $2 + where id = $1 and locked_by = $3 and status = $5", + ) + .bind(job.id) + .bind(&message) + .bind(name) + .bind(JobStatus::Failed.as_str()) + .bind(JobStatus::Running.as_str()) + } else { + let backoff = 30.0 * f64::from(job.attempts * job.attempts); + sqlx::query( + "update jobs + set status = $4, locked_by = null, last_error = $2, + run_at = now() + make_interval(secs => $6) + where id = $1 and locked_by = $3 and status = $5", + ) + .bind(job.id) + .bind(&message) + .bind(name) + .bind(JobStatus::Queued.as_str()) + .bind(JobStatus::Running.as_str()) + .bind(backoff) + }; + let owned = match finalize.execute(&state.db).await { + Ok(r) => r.rows_affected() > 0, + Err(e) => { + tracing::error!(job_id = %job.id, "failed to finalize job: {e}"); + false + } + }; + if owned && exhausted { + mark_photo_error(state, &job.kind, &job.payload, &message).await; + } + } + } +} + +async fn delete_s3_prefix_job( + state: &AppState, + payload: &serde_json::Value, +) -> anyhow::Result<()> { + let prefix = payload + .get("prefix") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("missing prefix in payload"))?; + anyhow::ensure!( + prefix.starts_with("photos/") && prefix.ends_with('/'), + "refusing to delete suspicious prefix {prefix:?}" + ); + crate::s3::delete_prefix(state, prefix).await +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cf9abd6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod config; +pub mod error; +pub mod imaging; +pub mod jobs; +pub mod models; +pub mod routes; +pub mod s3; +pub mod state; diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..25fe538 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,136 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a +/// loud decode error instead of a silently misbehaving string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PhotoStatus { + Uploaded, + Processing, + Ready, + Error, +} + +impl PhotoStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Uploaded => "uploaded", + Self::Processing => "processing", + Self::Ready => "ready", + Self::Error => "error", + } + } +} + +impl std::str::FromStr for PhotoStatus { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "uploaded" => Ok(Self::Uploaded), + "processing" => Ok(Self::Processing), + "ready" => Ok(Self::Ready), + "error" => Ok(Self::Error), + other => Err(format!("unknown photo status: {other}")), + } + } +} + +// #[sqlx(try_from = "String")] needs TryFrom; delegate to FromStr. +impl TryFrom for PhotoStatus { + type Error = String; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobKind { + ProcessPhoto, + DeleteS3Prefix, +} + +impl JobKind { + pub fn as_str(self) -> &'static str { + match self { + Self::ProcessPhoto => "process_photo", + Self::DeleteS3Prefix => "delete_s3_prefix", + } + } +} + +impl std::str::FromStr for JobKind { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "process_photo" => Ok(Self::ProcessPhoto), + "delete_s3_prefix" => Ok(Self::DeleteS3Prefix), + other => Err(format!("unknown job kind: {other}")), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobStatus { + Queued, + Running, + Done, + Failed, +} + +impl JobStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Running => "running", + Self::Done => "done", + Self::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Album { + pub id: Uuid, + pub name: String, + pub description: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Photo { + pub id: Uuid, + pub album_id: Uuid, + pub filename: String, + pub content_type: String, + pub size_bytes: i64, + #[sqlx(try_from = "String")] + pub status: PhotoStatus, + pub error: Option, + pub width: Option, + pub height: Option, + pub taken_at: Option>, + pub processed_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Share { + pub id: Uuid, + pub album_id: Uuid, + pub token: String, + pub label: String, + #[serde(skip_serializing)] + pub password_hash: Option, + pub allow_download: bool, + pub expires_at: Option>, + #[serde(skip_serializing)] + pub failed_attempts: i32, + #[serde(skip_serializing)] + pub locked_until: Option>, + pub created_at: DateTime, +} diff --git a/src/routes/albums.rs b/src/routes/albums.rs new file mode 100644 index 0000000..36062d1 --- /dev/null +++ b/src/routes/albums.rs @@ -0,0 +1,216 @@ +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::Json; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Album, JobKind, Photo, PhotoStatus}; +use crate::state::AppState; + +#[derive(Serialize, sqlx::FromRow)] +pub struct AlbumListItem { + pub id: Uuid, + pub name: String, + pub description: String, + pub created_at: DateTime, + pub photo_count: i64, + pub cover_photo_id: Option, + pub cover_processed_at: Option>, +} + +pub async fn list( + State(state): State, +) -> ApiResult>> { + let albums: Vec = sqlx::query_as( + "select a.id, a.name, a.description, a.created_at, + (select count(*) from photos p where p.album_id = a.id) as photo_count, + c.id as cover_photo_id, c.processed_at as cover_processed_at + from albums a + left join lateral ( + select p.id, p.processed_at from photos p + where p.album_id = a.id and p.status = $1 + order by coalesce(p.taken_at, p.created_at), p.filename + limit 1 + ) c on true + order by a.created_at desc", + ) + .bind(PhotoStatus::Ready.as_str()) + .fetch_all(&state.db) + .await?; + Ok(Json(albums)) +} + +#[derive(Deserialize)] +pub struct CreateAlbum { + name: String, + #[serde(default)] + description: String, +} + +pub async fn create( + State(state): State, + Json(body): Json, +) -> ApiResult> { + let name = body.name.trim(); + if name.is_empty() { + return Err(ApiError::bad_request("album name is required")); + } + let album: Album = + sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *") + .bind(name) + .bind(body.description.trim()) + .fetch_one(&state.db) + .await?; + Ok(Json(album)) +} + +#[derive(Serialize)] +pub struct ShareRating { + pub share_label: String, + pub rating: i32, +} + +#[derive(Serialize)] +pub struct ShareTag { + pub share_label: String, + pub tag: String, +} + +#[derive(Serialize, Default)] +pub struct PhotoFeedback { + pub ratings: Vec, + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct AlbumDetail { + pub album: Album, + pub photos: Vec, + pub feedback: HashMap, +} + +pub async fn get_one( + State(state): State, + Path(album_id): Path, +) -> ApiResult> { + let album: Album = sqlx::query_as("select * from albums where id = $1") + .bind(album_id) + .fetch_one(&state.db) + .await?; + let photos: Vec = sqlx::query_as( + "select * from photos where album_id = $1 + order by coalesce(taken_at, created_at), filename", + ) + .bind(album_id) + .fetch_all(&state.db) + .await?; + + let mut feedback: HashMap = HashMap::new(); + let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as( + "select r.photo_id, s.label, r.rating + from ratings r join shares s on s.id = r.share_id + where s.album_id = $1", + ) + .bind(album_id) + .fetch_all(&state.db) + .await?; + for (photo_id, share_label, rating) in ratings { + feedback + .entry(photo_id) + .or_default() + .ratings + .push(ShareRating { + share_label, + rating, + }); + } + let tags: Vec<(Uuid, String, String)> = sqlx::query_as( + "select t.photo_id, s.label, t.tag + from tags t join shares s on s.id = t.share_id + where s.album_id = $1 + order by t.created_at", + ) + .bind(album_id) + .fetch_all(&state.db) + .await?; + for (photo_id, share_label, tag) in tags { + feedback + .entry(photo_id) + .or_default() + .tags + .push(ShareTag { share_label, tag }); + } + + Ok(Json(AlbumDetail { + album, + photos, + feedback, + })) +} + +#[derive(Deserialize)] +pub struct UpdateAlbum { + name: Option, + description: Option, +} + +pub async fn update( + State(state): State, + Path(album_id): Path, + Json(body): Json, +) -> ApiResult> { + if let Some(name) = &body.name { + if name.trim().is_empty() { + return Err(ApiError::bad_request("album name cannot be empty")); + } + } + let album: Album = sqlx::query_as( + "update albums + set name = coalesce($2, name), description = coalesce($3, description) + where id = $1 + returning *", + ) + .bind(album_id) + .bind(body.name.as_deref().map(str::trim)) + .bind(body.description.as_deref().map(str::trim)) + .fetch_one(&state.db) + .await?; + Ok(Json(album)) +} + +pub async fn delete( + State(state): State, + Path(album_id): Path, +) -> ApiResult> { + let mut tx = state.db.begin().await?; + // Lock the album row: concurrent uploads block on their FK check against + // it, then fail once it's gone and clean up their own S3 objects — so no + // photo can slip in between the cleanup enqueue and the cascade delete. + let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update") + .bind(album_id) + .fetch_optional(&mut *tx) + .await?; + if locked.is_none() { + return Err(ApiError::not_found()); + } + // Delete photos and enqueue their S3 cleanup atomically, in one statement. + sqlx::query( + "with deleted as (delete from photos where album_id = $1 returning id) + insert into jobs (kind, payload) + select $2, jsonb_build_object('prefix', 'photos/' || id || '/') + from deleted", + ) + .bind(album_id) + .bind(JobKind::DeleteS3Prefix.as_str()) + .execute(&mut *tx) + .await?; + sqlx::query("delete from albums where id = $1") + .bind(album_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/src/routes/client.rs b/src/routes/client.rs new file mode 100644 index 0000000..7342345 --- /dev/null +++ b/src/routes/client.rs @@ -0,0 +1,398 @@ +use std::collections::HashMap; + +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use axum_extra::extract::cookie::SignedCookieJar; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{PhotoStatus, Share}; +use crate::state::AppState; + +pub async fn load_share(state: &AppState, token: &str) -> Result { + let share: Option = sqlx::query_as("select * from shares where token = $1") + .bind(token) + .fetch_optional(&state.db) + .await?; + let share = share.ok_or_else(ApiError::not_found)?; + if share.expires_at.map(|e| e < Utc::now()).unwrap_or(false) { + return Err(ApiError::gone("this link has expired")); + } + Ok(share) +} + +/// One signed cookie lists every share id this browser has been granted +/// (by viewing a passwordless share or unlocking a protected one). Image and +/// download requests are authorized from it, so URLs carry no token. +const SHARE_ACCESS_COOKIE: &str = "photos_shares"; +const MAX_REMEMBERED_SHARES: usize = 20; + +pub fn share_ids_from_jar(jar: &SignedCookieJar) -> Vec { + jar.get(SHARE_ACCESS_COOKIE) + .map(|c| { + c.value() + .split(',') + .filter_map(|s| Uuid::parse_str(s).ok()) + .collect() + }) + .unwrap_or_default() +} + +/// Add a share to the browser's access cookie (most recent first). Always +/// re-issues the cookie so the 30-day expiry slides on every visit instead of +/// being fixed at the first one. +fn grant_access(state: &AppState, jar: SignedCookieJar, share_id: Uuid) -> SignedCookieJar { + let mut ids = share_ids_from_jar(&jar); + ids.retain(|id| *id != share_id); + ids.insert(0, share_id); + ids.truncate(MAX_REMEMBERED_SHARES); + let value = ids + .iter() + .map(Uuid::to_string) + .collect::>() + .join(","); + let mut cookie = + crate::auth::base_cookie(SHARE_ACCESS_COOKIE, value, state.config.cookie_secure()); + cookie.set_max_age(time::Duration::days(30)); + jar.add(cookie) +} + +pub fn is_unlocked(jar: &SignedCookieJar, share: &Share) -> bool { + share.password_hash.is_none() || share_ids_from_jar(jar).contains(&share.id) +} + +/// Cookie-based authorization for image/download requests, which carry no +/// share token: any remembered, still-valid share covering the album grants +/// access (and must allow downloads when `need_download`). +pub async fn authorize_album_via_cookie( + state: &AppState, + jar: &SignedCookieJar, + album_id: Uuid, + need_download: bool, +) -> Result<(), ApiError> { + let ids = share_ids_from_jar(jar); + if ids.is_empty() { + return Err(ApiError::unauthorized()); + } + let shares: Vec = + sqlx::query_as("select * from shares where album_id = $1 and id = any($2)") + .bind(album_id) + .bind(&ids) + .fetch_all(&state.db) + .await?; + let now = Utc::now(); + let valid: Vec<&Share> = shares + .iter() + .filter(|s| s.expires_at.map(|e| e > now).unwrap_or(true)) + .collect(); + if valid.is_empty() { + return Err(ApiError::unauthorized()); + } + if need_download && !valid.iter().any(|s| s.allow_download) { + return Err(ApiError::forbidden("downloads are disabled for this link")); + } + Ok(()) +} + +/// The one place client-share access policy lives: token valid + not expired, +/// unlocked, and (for download endpoints) downloads enabled. +pub async fn authorize_share( + state: &AppState, + jar: &SignedCookieJar, + token: &str, + need_download: bool, +) -> Result { + let share = load_share(state, token).await?; + if !is_unlocked(jar, &share) { + return Err(ApiError::unauthorized()); + } + if need_download && !share.allow_download { + return Err(ApiError::forbidden("downloads are disabled for this link")); + } + Ok(share) +} + +fn require_unlocked(jar: &SignedCookieJar, share: &Share) -> Result<(), ApiError> { + if is_unlocked(jar, share) { + Ok(()) + } else { + Err(ApiError( + StatusCode::UNAUTHORIZED, + "password required".into(), + )) + } +} + +#[derive(Serialize, sqlx::FromRow)] +struct ClientPhotoRow { + id: Uuid, + filename: String, + size_bytes: i64, + width: Option, + height: Option, + taken_at: Option>, + processed_at: Option>, + my_rating: Option, +} + +#[derive(Serialize)] +struct ClientPhoto { + #[serde(flatten)] + row: ClientPhotoRow, + my_tags: Vec, +} + +#[derive(Serialize)] +pub struct ShareView { + label: String, + album_name: String, + album_description: String, + locked: bool, + allow_download: bool, + photos: Vec, +} + +pub async fn get_share( + State(state): State, + Path(token): Path, + jar: SignedCookieJar, +) -> ApiResult<(SignedCookieJar, Json)> { + let share = load_share(&state, &token).await?; + let (album_name, album_description): (String, String) = + sqlx::query_as("select name, description from albums where id = $1") + .bind(share.album_id) + .fetch_one(&state.db) + .await?; + + if !is_unlocked(&jar, &share) { + return Ok(( + jar, + Json(ShareView { + label: share.label, + album_name, + album_description, + locked: true, + allow_download: share.allow_download, + photos: vec![], + }), + )); + } + // Grant this browser image/download access for the share's album. + let jar = grant_access(&state, jar, share.id); + + let rows: Vec = sqlx::query_as( + "select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating + from photos p + left join ratings r on r.photo_id = p.id and r.share_id = $2 + where p.album_id = $1 and p.status = $3 + order by coalesce(p.taken_at, p.created_at), p.filename", + ) + .bind(share.album_id) + .bind(share.id) + .bind(PhotoStatus::Ready.as_str()) + .fetch_all(&state.db) + .await?; + + let tag_rows: Vec<(Uuid, String)> = + sqlx::query_as("select photo_id, tag from tags where share_id = $1 order by created_at") + .bind(share.id) + .fetch_all(&state.db) + .await?; + let mut tag_map: HashMap> = HashMap::new(); + for (photo_id, tag) in tag_rows { + tag_map.entry(photo_id).or_default().push(tag); + } + + let photos = rows + .into_iter() + .map(|row| { + let my_tags = tag_map.remove(&row.id).unwrap_or_default(); + ClientPhoto { row, my_tags } + }) + .collect(); + + Ok(( + jar, + Json(ShareView { + label: share.label, + album_name, + album_description, + locked: false, + allow_download: share.allow_download, + photos, + }), + )) +} + +#[derive(Deserialize)] +pub struct UnlockBody { + password: String, +} + +const MAX_UNLOCK_ATTEMPTS: i32 = 10; + +pub async fn unlock( + State(state): State, + Path(token): Path, + jar: SignedCookieJar, + Json(body): Json, +) -> ApiResult<(SignedCookieJar, StatusCode)> { + let share = load_share(&state, &token).await?; + let Some(hash) = share.password_hash.clone() else { + return Ok((jar, StatusCode::NO_CONTENT)); + }; + if let Some(locked_until) = share.locked_until { + if locked_until > Utc::now() { + return Err(ApiError( + StatusCode::TOO_MANY_REQUESTS, + "too many attempts — try again in a few minutes".into(), + )); + } + // The lock window has passed: grant a fresh set of attempts, so a + // legitimate client isn't re-locked by their next single typo. + sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") + .bind(share.id) + .execute(&state.db) + .await?; + } + // Argon2 is deliberately slow; keep it off the async runtime threads. + let password = body.password; + let verified = tokio::task::spawn_blocking(move || { + let parsed = PasswordHash::new(&hash).map_err(|e| anyhow::anyhow!("bad hash: {e}"))?; + Ok::<_, anyhow::Error>( + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok(), + ) + }) + .await + .map_err(|e| anyhow::anyhow!("verify task failed: {e}"))??; + + if !verified { + sqlx::query( + "update shares + set failed_attempts = failed_attempts + 1, + locked_until = case when failed_attempts + 1 >= $2 + then now() + interval '15 minutes' + else locked_until end + where id = $1", + ) + .bind(share.id) + .bind(MAX_UNLOCK_ATTEMPTS) + .execute(&state.db) + .await?; + return Err(ApiError(StatusCode::UNAUTHORIZED, "wrong password".into())); + } + sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") + .bind(share.id) + .execute(&state.db) + .await?; + Ok((grant_access(&state, jar, share.id), StatusCode::NO_CONTENT)) +} + +async fn share_photo( + state: &AppState, + jar: &SignedCookieJar, + token: &str, + photo_id: Uuid, +) -> Result { + let share = load_share(state, token).await?; + require_unlocked(jar, &share)?; + let exists: Option<(Uuid,)> = sqlx::query_as( + "select id from photos where id = $1 and album_id = $2 and status = $3", + ) + .bind(photo_id) + .bind(share.album_id) + .bind(PhotoStatus::Ready.as_str()) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(ApiError::not_found()); + } + Ok(share) +} + +#[derive(Deserialize)] +pub struct RatingBody { + rating: i32, +} + +pub async fn set_rating( + State(state): State, + Path((token, photo_id)): Path<(String, Uuid)>, + jar: SignedCookieJar, + Json(body): Json, +) -> ApiResult> { + if !(0..=5).contains(&body.rating) { + return Err(ApiError::bad_request("rating must be between 0 and 5")); + } + let share = share_photo(&state, &jar, &token, photo_id).await?; + if body.rating == 0 { + sqlx::query("delete from ratings where share_id = $1 and photo_id = $2") + .bind(share.id) + .bind(photo_id) + .execute(&state.db) + .await?; + } else { + sqlx::query( + "insert into ratings (share_id, photo_id, rating) values ($1, $2, $3) + on conflict (share_id, photo_id) + do update set rating = excluded.rating, updated_at = now()", + ) + .bind(share.id) + .bind(photo_id) + .bind(body.rating) + .execute(&state.db) + .await?; + } + Ok(Json(serde_json::json!({ "ok": true }))) +} + +#[derive(Deserialize)] +pub struct TagsBody { + tags: Vec, +} + +pub async fn set_tags( + State(state): State, + Path((token, photo_id)): Path<(String, Uuid)>, + jar: SignedCookieJar, + Json(body): Json, +) -> ApiResult> { + let share = share_photo(&state, &jar, &token, photo_id).await?; + + let mut tags: Vec = Vec::new(); + for tag in body.tags { + let tag = tag.trim().to_lowercase(); + if tag.is_empty() || tag.chars().count() > 40 { + continue; + } + if !tags.contains(&tag) { + tags.push(tag); + } + if tags.len() >= 20 { + break; + } + } + + let mut tx = state.db.begin().await?; + sqlx::query("delete from tags where share_id = $1 and photo_id = $2") + .bind(share.id) + .bind(photo_id) + .execute(&mut *tx) + .await?; + if !tags.is_empty() { + sqlx::query("insert into tags (share_id, photo_id, tag) select $1, $2, unnest($3::text[])") + .bind(share.id) + .bind(photo_id) + .bind(&tags) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Json(serde_json::json!({ "ok": true, "tags": tags }))) +} diff --git a/src/routes/images.rs b/src/routes/images.rs new file mode 100644 index 0000000..e2bacea --- /dev/null +++ b/src/routes/images.rs @@ -0,0 +1,105 @@ +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::Response; +use axum_extra::extract::cookie::SignedCookieJar; +use tokio_util::io::ReaderStream; +use uuid::Uuid; + +use crate::auth::user_from_jar; +use crate::error::{ApiError, ApiResult}; +use crate::models::{Photo, PhotoStatus}; +use crate::routes::client::authorize_album_via_cookie; +use crate::s3; +use crate::state::AppState; + +/// Allow access if the requester is the signed-in photographer, or holds a +/// share-access cookie (set when viewing/unlocking a share) covering the +/// photo's album. +async fn authorize_photo( + state: &AppState, + jar: &SignedCookieJar, + photo_id: Uuid, + need_download: bool, +) -> Result { + let photo: Option = sqlx::query_as("select * from photos where id = $1") + .bind(photo_id) + .fetch_optional(&state.db) + .await?; + let photo = photo.ok_or_else(ApiError::not_found)?; + + if user_from_jar(state, jar).is_some() { + return Ok(photo); + } + authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?; + // Clients may only reach photos the share listing exposes. + if photo.status != PhotoStatus::Ready { + return Err(ApiError::not_found()); + } + Ok(photo) +} + +async fn stream_object( + state: &AppState, + key: &str, + content_type: &str, + attachment_name: Option<&str>, +) -> Result { + let object = state + .s3 + .get_object() + .bucket(&state.config.s3_bucket) + .key(key) + .send() + .await + .map_err(|e| { + tracing::warn!("s3 get {key} failed: {e}"); + ApiError::not_found() + })?; + + let mut builder = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header( + header::CACHE_CONTROL, + "private, max-age=31536000, immutable", + ); + if let Some(length) = object.content_length() { + builder = builder.header(header::CONTENT_LENGTH, length); + } + if let Some(name) = attachment_name { + let safe = name.replace(['"', '\\'], "_"); + builder = builder.header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{safe}\""), + ); + } + let stream = ReaderStream::new(object.body.into_async_read()); + builder + .body(Body::from_stream(stream)) + .map_err(|e| anyhow::anyhow!("building response: {e}").into()) +} + +pub async fn serve( + State(state): State, + Path((photo_id, size)): Path<(Uuid, String)>, + jar: SignedCookieJar, +) -> ApiResult { + let key = match size.as_str() { + "thumb" => s3::thumb_key(photo_id), + "preview" => s3::preview_key(photo_id), + _ => return Err(ApiError::bad_request("size must be thumb or preview")), + }; + authorize_photo(&state, &jar, photo_id, false).await?; + stream_object(&state, &key, "image/jpeg", None).await +} + +pub async fn original( + State(state): State, + Path(photo_id): Path, + jar: SignedCookieJar, +) -> ApiResult { + let photo = authorize_photo(&state, &jar, photo_id, true).await?; + let key = s3::original_key(photo_id, &photo.filename); + stream_object(&state, &key, &photo.content_type, Some(&photo.filename)).await +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..ba184c8 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,114 @@ +pub mod albums; +pub mod client; +pub mod images; +pub mod photos; +pub mod shares; +pub mod zip; + +use axum::extract::{DefaultBodyLimit, Request, State}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, delete, get, post, put}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::SignedCookieJar; + +use crate::auth; +use crate::error::ApiError; +use crate::state::AppState; + +const MAX_UPLOAD_BYTES: usize = 4 * 1024 * 1024 * 1024; + +async fn health() -> Json { + Json(serde_json::json!({ "ok": true })) +} + +/// Unknown /api paths must 404 as JSON, not fall through to the SPA's index.html. +async fn api_not_found() -> ApiError { + ApiError::not_found() +} + +/// Wrong method on a known route gets a JSON 405 (not an empty body). +async fn method_not_allowed() -> ApiError { + ApiError( + axum::http::StatusCode::METHOD_NOT_ALLOWED, + "method not allowed".into(), + ) +} + +/// Every route in the admin router passes through this layer, so photographer +/// auth is structural — a new admin endpoint cannot be forgotten open. The +/// authenticated user is stored in request extensions for handlers that need +/// the identity. +async fn require_photographer( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let jar = SignedCookieJar::from_headers(request.headers(), state.cookie_key.clone()); + let user = auth::user_from_jar(&state, &jar).ok_or_else(ApiError::unauthorized)?; + request.extensions_mut().insert(user); + // Slide the session: past half-life, responses carry a fresh cookie. + let refreshed = auth::refreshed_session(&state, &jar); + let response = next.run(request).await; + Ok(match refreshed { + Some(cookie) => (jar.add(cookie), response).into_response(), + None => response, + }) +} + +pub fn router(state: &AppState) -> Router { + // Photographer-only surface. Add new admin endpoints HERE — the auth + // layer covers them automatically. + let admin = Router::new() + .route("/api/me", get(auth::me)) + .route("/api/albums", get(albums::list).post(albums::create)) + .route( + "/api/albums/{id}", + get(albums::get_one) + .patch(albums::update) + .delete(albums::delete), + ) + .route( + "/api/albums/{id}/photos", + post(photos::upload).layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)), + ) + .route( + "/api/albums/{id}/shares", + get(shares::list).post(shares::create), + ) + .route("/api/albums/{id}/zip", post(zip::album_zip)) + .route("/api/photos/{id}", delete(photos::delete)) + .route("/api/photos/{id}/reprocess", post(photos::reprocess)) + .route("/api/shares/{id}", delete(shares::delete)) + .route("/api/shares/{id}/reset-lock", post(shares::reset_lock)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_photographer, + )); + + // Public surface: health, the auth flow itself, and client-share + // endpoints (self-authorizing via token or share-access cookie). + Router::new() + .route("/api/health", get(health)) + .route("/api/auth/login", get(auth::login)) + .route("/api/auth/callback", get(auth::callback)) + .route("/api/auth/logout", post(auth::logout)) + .route("/api/share/{token}", get(client::get_share)) + .route("/api/share/{token}/unlock", post(client::unlock)) + .route( + "/api/share/{token}/photos/{photo_id}/rating", + put(client::set_rating), + ) + .route( + "/api/share/{token}/photos/{photo_id}/tags", + put(client::set_tags), + ) + .route("/api/share/{token}/zip", post(zip::share_zip)) + // Dual-auth (photographer session OR share cookie), checked in-handler: + .route("/api/img/{id}/{size}", get(images::serve)) + .route("/api/photos/{id}/original", get(images::original)) + .merge(admin) + .route("/api", any(api_not_found)) + .route("/api/{*path}", any(api_not_found)) + .method_not_allowed_fallback(method_not_allowed) +} diff --git a/src/routes/photos.rs b/src/routes/photos.rs new file mode 100644 index 0000000..e3a0cfd --- /dev/null +++ b/src/routes/photos.rs @@ -0,0 +1,174 @@ +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::header::CONTENT_TYPE; +use axum::http::HeaderMap; +use axum::Json; +use futures::StreamExt; +use serde::Deserialize; +use tokio::io::AsyncWriteExt; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::jobs; +use crate::models::{JobKind, Photo, PhotoStatus}; +use crate::s3; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct UploadQuery { + filename: String, +} + +fn sanitize_filename(raw: &str) -> Result { + let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw); + let cleaned: String = base + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ' | '(' | ')') { + c + } else { + '_' + } + }) + .collect(); + let cleaned = cleaned.trim().trim_start_matches('.').to_string(); + if cleaned.is_empty() { + return Err(ApiError::bad_request("invalid filename")); + } + Ok(cleaned.chars().take(150).collect()) +} + +pub async fn upload( + State(state): State, + Path(album_id): Path, + Query(query): Query, + headers: HeaderMap, + body: Body, +) -> ApiResult> { + let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1") + .bind(album_id) + .fetch_optional(&state.db) + .await?; + if album_exists.is_none() { + return Err(ApiError::not_found()); + } + + let filename = sanitize_filename(&query.filename)?; + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + // Stream the request body to a temp file so large raws never sit in memory. + let dir = tempfile::tempdir().map_err(anyhow::Error::from)?; + let path = dir.path().join("upload.bin"); + let mut file = tokio::fs::File::create(&path) + .await + .map_err(anyhow::Error::from)?; + let mut stream = body.into_data_stream(); + let mut size: i64 = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| ApiError::bad_request(format!("upload aborted: {e}")))?; + size += chunk.len() as i64; + file.write_all(&chunk).await.map_err(anyhow::Error::from)?; + } + file.flush().await.map_err(anyhow::Error::from)?; + drop(file); + if size == 0 { + return Err(ApiError::bad_request("empty upload")); + } + + // Upload to S3 first, then create the row and enqueue processing in one + // transaction — a photo row can never exist without its job, and a failed + // transaction (e.g. album deleted mid-upload) cleans up the S3 object. + let photo_id = Uuid::new_v4(); + let key = s3::original_key(photo_id, &filename); + s3::put_file(&state, &key, &path, &content_type).await?; + + let result: ApiResult<(sqlx::Transaction<'static, sqlx::Postgres>, Photo)> = async { + let mut tx = state.db.begin().await?; + let photo: Photo = sqlx::query_as( + "insert into photos (id, album_id, filename, content_type, size_bytes, status) + values ($1, $2, $3, $4, $5, $6) + returning *", + ) + .bind(photo_id) + .bind(album_id) + .bind(&filename) + .bind(&content_type) + .bind(size) + .bind(PhotoStatus::Uploaded.as_str()) + .fetch_one(&mut *tx) + .await?; + jobs::enqueue( + &mut *tx, + JobKind::ProcessPhoto, + serde_json::json!({ "photo_id": photo_id }), + ) + .await?; + Ok((tx, photo)) + } + .await; + + let (tx, photo) = match result { + Ok(pair) => pair, + Err(e) => { + // Nothing committed — safe to remove the freshly stored original. + if let Err(cleanup) = s3::delete_prefix(&state, &s3::photo_prefix(photo_id)).await { + tracing::error!("failed to clean up s3 after aborted upload: {cleanup:#}"); + } + return Err(e); + } + }; + if let Err(e) = tx.commit().await { + // A failed COMMIT is ambiguous (it may have been applied); deleting + // the S3 object here could destroy a committed photo's original, so + // leave it — an orphaned object beats data loss. + tracing::error!( + "commit failed after upload of photo {photo_id}; leaving s3 object in place: {e}" + ); + return Err(anyhow::Error::from(e).context("saving upload").into()); + } + Ok(Json(photo)) +} + +pub async fn delete( + State(state): State, + Path(photo_id): Path, +) -> ApiResult> { + let mut tx = state.db.begin().await?; + let deleted = sqlx::query("delete from photos where id = $1") + .bind(photo_id) + .execute(&mut *tx) + .await?; + if deleted.rows_affected() == 0 { + return Err(ApiError::not_found()); + } + jobs::enqueue( + &mut *tx, + JobKind::DeleteS3Prefix, + serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }), + ) + .await?; + tx.commit().await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn reprocess( + State(state): State, + Path(photo_id): Path, +) -> ApiResult> { + let mut tx = state.db.begin().await?; + let updated = sqlx::query("update photos set status = $2, error = null where id = $1") + .bind(photo_id) + .bind(PhotoStatus::Uploaded.as_str()) + .execute(&mut *tx) + .await?; + if updated.rows_affected() == 0 { + return Err(ApiError::not_found()); + } + jobs::ensure_process_photo(&mut tx, photo_id).await?; + tx.commit().await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/src/routes/shares.rs b/src/routes/shares.rs new file mode 100644 index 0000000..1e35ffc --- /dev/null +++ b/src/routes/shares.rs @@ -0,0 +1,159 @@ +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHasher}; +use axum::extract::{Path, State}; +use axum::Json; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::random_token; +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +#[derive(sqlx::FromRow)] +struct ShareAdminRow { + id: Uuid, + token: String, + label: String, + password_hash: Option, + allow_download: bool, + expires_at: Option>, + locked_until: Option>, + created_at: DateTime, + rating_count: i64, + tag_count: i64, +} + +fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value { + serde_json::json!({ + "id": row.id, + "token": row.token, + "url": format!("{}/s/{}", state.config.public_url, row.token), + "label": row.label, + "has_password": row.password_hash.is_some(), + "allow_download": row.allow_download, + "expires_at": row.expires_at, + "locked": row.locked_until.map(|t| t > Utc::now()).unwrap_or(false), + "created_at": row.created_at, + "rating_count": row.rating_count, + "tag_count": row.tag_count, + }) +} + +const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download, + s.expires_at, s.locked_until, s.created_at, + (select count(*) from ratings r where r.share_id = s.id) as rating_count, + (select count(*) from tags t where t.share_id = s.id) as tag_count"; + +pub async fn list( + State(state): State, + Path(album_id): Path, +) -> ApiResult>> { + let rows: Vec = sqlx::query_as(&format!( + "select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc" + )) + .bind(album_id) + .fetch_all(&state.db) + .await?; + Ok(Json(rows.iter().map(|r| share_json(&state, r)).collect())) +} + +#[derive(Deserialize)] +pub struct CreateShare { + #[serde(default)] + label: String, + password: Option, + #[serde(default = "default_true")] + allow_download: bool, + expires_at: Option>, +} + +fn default_true() -> bool { + true +} + +pub async fn create( + State(state): State, + Path(album_id): Path, + Json(body): Json, +) -> ApiResult> { + let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1") + .bind(album_id) + .fetch_optional(&state.db) + .await?; + if album_exists.is_none() { + return Err(ApiError::not_found()); + } + + let password_hash = match body.password.as_deref().map(str::trim) { + Some(pw) if !pw.is_empty() => { + // Argon2 is deliberately slow; keep it off the async runtime threads. + let pw = pw.to_string(); + let hash = tokio::task::spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(pw.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| anyhow::anyhow!("hashing password: {e}")) + }) + .await + .map_err(|e| anyhow::anyhow!("hash task failed: {e}"))??; + Some(hash) + } + _ => None, + }; + + let token = random_token(24); + let (share_id,): (Uuid,) = sqlx::query_as( + "insert into shares (album_id, token, label, password_hash, allow_download, expires_at) + values ($1, $2, $3, $4, $5, $6) + returning id", + ) + .bind(album_id) + .bind(&token) + .bind(body.label.trim()) + .bind(&password_hash) + .bind(body.allow_download) + .bind(body.expires_at) + .fetch_one(&state.db) + .await?; + + let row: ShareAdminRow = + sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1")) + .bind(share_id) + .fetch_one(&state.db) + .await?; + Ok(Json(share_json(&state, &row))) +} + +/// Clear a share's password-lockout state (e.g. after a client fat-fingered +/// their way into the 15-minute lock). +pub async fn reset_lock( + State(state): State, + Path(share_id): Path, +) -> ApiResult> { + let updated = + sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") + .bind(share_id) + .execute(&state.db) + .await?; + if updated.rows_affected() == 0 { + return Err(ApiError::not_found()); + } + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn delete( + State(state): State, + Path(share_id): Path, +) -> ApiResult> { + let deleted = sqlx::query("delete from shares where id = $1") + .bind(share_id) + .execute(&state.db) + .await?; + if deleted.rows_affected() == 0 { + return Err(ApiError::not_found()); + } + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/src/routes/zip.rs b/src/routes/zip.rs new file mode 100644 index 0000000..cca5a41 --- /dev/null +++ b/src/routes/zip.rs @@ -0,0 +1,423 @@ +//! Streaming ZIP downloads, hand-written for spec compliance. +//! +//! Every entry is Stored (raws/jpegs don't compress) with its exact size and +//! CRC-32 in the local file header — no data descriptors — so the archives +//! work with strict *streaming* extractors (Java ZipInputStream, bsdtar from +//! a pipe), not just central-directory readers. Sizes are known up front, +//! which also makes the total byte length deterministic: the response carries +//! a real Content-Length, so browsers show progress and flag truncated +//! downloads as failed. +//! +//! Each file is spooled from S3 to an anonymous temp file to compute its CRC +//! before its header is written; the next file spools while the current one +//! streams out, so S3 latency doesn't stall the download. + +use std::collections::HashSet; + +use axum::body::{Body, Bytes}; +use axum::extract::{Form, Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::Response; +use axum_extra::extract::cookie::SignedCookieJar; +use chrono::{DateTime, Datelike, Timelike, Utc}; +use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::task::JoinHandle; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Photo, PhotoStatus}; +use crate::routes::client::authorize_share; +use crate::s3; +use crate::state::AppState; + +const U32_SENTINEL: u64 = 0xFFFF_FFFF; + +/// Sent as a plain form POST (not fetch) so the browser streams the download +/// natively; `ids` is a comma-separated list, empty = every ready photo. +#[derive(Deserialize)] +pub struct ZipRequest { + #[serde(default)] + ids: String, +} + +/// Strict: a present-but-malformed id is a 400, never silently reinterpreted +/// as the download-everything sentinel. +fn parse_ids(raw: &str) -> Result, ApiError> { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + Uuid::parse_str(s).map_err(|_| ApiError::bad_request(format!("invalid photo id: {s}"))) + }) + .collect() +} + +async fn ready_photos( + state: &AppState, + album_id: Uuid, + ids: &[Uuid], +) -> Result, sqlx::Error> { + sqlx::query_as( + "select * from photos + where album_id = $1 and status = $3 + and (cardinality($2::uuid[]) = 0 or id = any($2)) + order by coalesce(taken_at, created_at), filename", + ) + .bind(album_id) + .bind(ids) + .bind(PhotoStatus::Ready.as_str()) + .fetch_all(&state.db) + .await +} + +async fn album_name(state: &AppState, album_id: Uuid) -> Result { + let name: Option<(String,)> = sqlx::query_as("select name from albums where id = $1") + .bind(album_id) + .fetch_optional(&state.db) + .await?; + Ok(name.ok_or_else(ApiError::not_found)?.0) +} + +pub async fn album_zip( + State(state): State, + Path(album_id): Path, + Form(request): Form, +) -> ApiResult { + let name = album_name(&state, album_id).await?; + let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?; + stream_zip(state, photos, &name) +} + +pub async fn share_zip( + State(state): State, + Path(token): Path, + jar: SignedCookieJar, + Form(request): Form, +) -> ApiResult { + let share = authorize_share(&state, &jar, &token, true).await?; + let name = album_name(&state, share.album_id).await?; + let photos = ready_photos(&state, share.album_id, &parse_ids(&request.ids)?).await?; + stream_zip(state, photos, &name) +} + +/// Dedupe case-insensitively — archives are extracted onto case-insensitive +/// filesystems (macOS/Windows), where "DSC1.JPG" and "dsc1.jpg" would collide. +fn unique_entry_name(used: &mut HashSet, filename: &str) -> String { + if used.insert(filename.to_lowercase()) { + return filename.to_string(); + } + let (stem, ext) = match filename.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")), + _ => (filename, String::new()), + }; + for n in 2.. { + let candidate = format!("{stem} ({n}){ext}"); + if used.insert(candidate.to_lowercase()) { + return candidate; + } + } + unreachable!() +} + +/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only — +/// callers clamp earlier dates). +fn dos_datetime(t: DateTime) -> (u16, u16) { + let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2); + let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16); + (time, date) +} + +struct Entry { + photo_id: Uuid, + s3_key: String, + name: Vec, + size: u64, + offset: u64, + dos_time: u16, + dos_date: u16, +} + +struct ZipPlan { + entries: Vec, + cd_offset: u64, + cd_size: u64, + zip64_eocd: bool, + total_len: u64, +} + +fn plan_zip(photos: &[Photo]) -> Result { + let mut used_names = HashSet::new(); + let mut entries = Vec::with_capacity(photos.len()); + let mut offset: u64 = 0; + for photo in photos { + let size = photo.size_bytes as u64; + if size >= U32_SENTINEL { + return Err(ApiError::bad_request(format!( + "{} is too large for a zip download", + photo.filename + ))); + } + let name = unique_entry_name(&mut used_names, &photo.filename).into_bytes(); + // DOS timestamps can't represent pre-1980 dates (cameras with unset + // clocks); fall back to the upload time. + let timestamp = photo + .taken_at + .filter(|t| t.year() >= 1980) + .unwrap_or(photo.created_at); + let (dos_time, dos_date) = dos_datetime(timestamp); + let entry_offset = offset; + offset += 30 + name.len() as u64 + size; + entries.push(Entry { + photo_id: photo.id, + s3_key: s3::original_key(photo.id, &photo.filename), + name, + size, + offset: entry_offset, + dos_time, + dos_date, + }); + } + let cd_offset = offset; + let cd_size: u64 = entries + .iter() + .map(|e| 46 + e.name.len() as u64 + if e.offset >= U32_SENTINEL { 12 } else { 0 }) + .sum(); + let zip64_eocd = entries.len() >= 0xFFFF + || cd_size >= U32_SENTINEL + || cd_offset >= U32_SENTINEL; + let total_len = cd_offset + cd_size + 22 + if zip64_eocd { 56 + 20 } else { 0 }; + Ok(ZipPlan { + entries, + cd_offset, + cd_size, + zip64_eocd, + total_len, + }) +} + +fn stream_zip(state: AppState, photos: Vec, album_name: &str) -> ApiResult { + if photos.is_empty() { + return Err(ApiError::bad_request("no downloadable photos selected")); + } + let plan = plan_zip(&photos)?; + let permit = state.zip_permits.clone().try_acquire_owned().map_err(|_| { + ApiError( + StatusCode::SERVICE_UNAVAILABLE, + "too many downloads in progress — try again in a moment".into(), + ) + })?; + + let zip_name: String = album_name + .trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }) + .take(80) + .collect(); + let zip_name = if zip_name.is_empty() { "photos".to_string() } else { zip_name }; + let total_len = plan.total_len; + + let (writer, reader) = tokio::io::duplex(256 * 1024); + let write_task = tokio::spawn(async move { + let result = write_zip(&state, plan, writer).await; + drop(permit); + result + }); + + // Forward bytes to the response; when the writer finishes, surface any + // zip error as a stream error so hyper ABORTS the connection — combined + // with the exact Content-Length, browsers report truncation as a failed + // download instead of keeping a silently corrupt file. + struct Pump { + reader: tokio::io::DuplexStream, + task: Option>>, + } + let pump = Pump { + reader, + task: Some(write_task), + }; + let stream = futures::stream::unfold(pump, |mut pump| async move { + pump.task.as_ref()?; // stream is over after a terminal item + let mut buf = vec![0u8; 64 * 1024]; + match pump.reader.read(&mut buf).await { + Ok(0) => { + let task = pump.task.take()?; + match task.await { + Ok(Ok(())) => None, + Ok(Err(e)) => Some(( + Err(std::io::Error::other(format!("zip stream failed: {e:#}"))), + pump, + )), + Err(e) => Some(( + Err(std::io::Error::other(format!("zip task panicked: {e}"))), + pump, + )), + } + } + Ok(n) => { + buf.truncate(n); + Some((Ok(Bytes::from(buf)), pump)) + } + Err(e) => { + pump.task = None; + Some((Err(e), pump)) + } + } + }); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/zip") + .header(header::CONTENT_LENGTH, total_len) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{zip_name}.zip\""), + ) + .body(Body::from_stream(stream)) + .map_err(|e| anyhow::anyhow!("building response: {e}").into()) +} + +/// Download an original into an anonymous temp file, computing its CRC-32 and +/// verifying the byte count matches what the zip plan promised. +fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle> { + let state = state.clone(); + tokio::spawn(async move { + let object = state + .s3 + .get_object() + .bucket(&state.config.s3_bucket) + .key(&key) + .send() + .await + .map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?; + let mut file = tokio::fs::File::from_std(tempfile::tempfile()?); + let mut reader = object.body.into_async_read(); + let mut hasher = crc32fast::Hasher::new(); + let mut written: u64 = 0; + let mut buf = vec![0u8; 128 * 1024]; + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + file.write_all(&buf[..n]).await?; + written += n as u64; + } + anyhow::ensure!( + written == expected_size, + "{key} is {written} bytes in s3 but {expected_size} in the database" + ); + file.flush().await?; + file.seek(std::io::SeekFrom::Start(0)).await?; + Ok((file, hasher.finalize())) + }) +} + +async fn write_zip( + state: &AppState, + plan: ZipPlan, + mut out: tokio::io::DuplexStream, +) -> anyhow::Result<()> { + // UTF-8 filename flag; no data descriptors (bit 3 unset). + const FLAGS: u16 = 0x0800; + let mut crcs = Vec::with_capacity(plan.entries.len()); + + // Prefetch: spool the next object from S3 while streaming the current one. + let mut pending: Option>> = None; + for (i, entry) in plan.entries.iter().enumerate() { + let current = match pending.take() { + Some(handle) => handle, + None => spool(state, entry.s3_key.clone(), entry.size), + }; + if let Some(next) = plan.entries.get(i + 1) { + pending = Some(spool(state, next.s3_key.clone(), next.size)); + } + let (mut file, crc) = current + .await + .map_err(|e| anyhow::anyhow!("spool task failed: {e}"))? + .map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?; + crcs.push(crc); + + let mut lfh = Vec::with_capacity(30 + entry.name.len()); + lfh.extend_from_slice(&0x04034b50u32.to_le_bytes()); + lfh.extend_from_slice(&20u16.to_le_bytes()); // version needed + lfh.extend_from_slice(&FLAGS.to_le_bytes()); + lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored + lfh.extend_from_slice(&entry.dos_time.to_le_bytes()); + lfh.extend_from_slice(&entry.dos_date.to_le_bytes()); + lfh.extend_from_slice(&crc.to_le_bytes()); + lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed + lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed + lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes()); + lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len + lfh.extend_from_slice(&entry.name); + out.write_all(&lfh).await?; + tokio::io::copy(&mut file, &mut out).await?; + } + + // Central directory. + for (entry, crc) in plan.entries.iter().zip(&crcs) { + let zip64_offset = entry.offset >= U32_SENTINEL; + let mut cdh = Vec::with_capacity(46 + entry.name.len() + 12); + cdh.extend_from_slice(&0x02014b50u32.to_le_bytes()); + cdh.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by: unix, 3.0 + cdh.extend_from_slice(&(if zip64_offset { 45u16 } else { 20u16 }).to_le_bytes()); + cdh.extend_from_slice(&FLAGS.to_le_bytes()); + cdh.extend_from_slice(&0u16.to_le_bytes()); // method: stored + cdh.extend_from_slice(&entry.dos_time.to_le_bytes()); + cdh.extend_from_slice(&entry.dos_date.to_le_bytes()); + cdh.extend_from_slice(&crc.to_le_bytes()); + cdh.extend_from_slice(&(entry.size as u32).to_le_bytes()); + cdh.extend_from_slice(&(entry.size as u32).to_le_bytes()); + cdh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes()); + cdh.extend_from_slice(&(if zip64_offset { 12u16 } else { 0u16 }).to_le_bytes()); // extra len + cdh.extend_from_slice(&0u16.to_le_bytes()); // comment len + cdh.extend_from_slice(&0u16.to_le_bytes()); // disk number + cdh.extend_from_slice(&0u16.to_le_bytes()); // internal attrs + cdh.extend_from_slice(&(0o100644u32 << 16).to_le_bytes()); // unix -rw-r--r-- + let offset32 = if zip64_offset { U32_SENTINEL as u32 } else { entry.offset as u32 }; + cdh.extend_from_slice(&offset32.to_le_bytes()); + cdh.extend_from_slice(&entry.name); + if zip64_offset { + cdh.extend_from_slice(&0x0001u16.to_le_bytes()); // zip64 extra field + cdh.extend_from_slice(&8u16.to_le_bytes()); + cdh.extend_from_slice(&entry.offset.to_le_bytes()); + } + out.write_all(&cdh).await?; + } + + // End of central directory (zip64 variants only when values overflow). + let mut tail = Vec::with_capacity(98); + if plan.zip64_eocd { + let entries = plan.entries.len() as u64; + tail.extend_from_slice(&0x06064b50u32.to_le_bytes()); + tail.extend_from_slice(&44u64.to_le_bytes()); // record size + tail.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by + tail.extend_from_slice(&45u16.to_le_bytes()); // version needed + tail.extend_from_slice(&0u32.to_le_bytes()); // this disk + tail.extend_from_slice(&0u32.to_le_bytes()); // cd disk + tail.extend_from_slice(&entries.to_le_bytes()); + tail.extend_from_slice(&entries.to_le_bytes()); + tail.extend_from_slice(&plan.cd_size.to_le_bytes()); + tail.extend_from_slice(&plan.cd_offset.to_le_bytes()); + // zip64 EOCD locator + tail.extend_from_slice(&0x07064b50u32.to_le_bytes()); + tail.extend_from_slice(&0u32.to_le_bytes()); + tail.extend_from_slice(&(plan.cd_offset + plan.cd_size).to_le_bytes()); + tail.extend_from_slice(&1u32.to_le_bytes()); + } + let clamp16 = |v: u64| -> u16 { v.min(0xFFFF) as u16 }; + let clamp32 = |v: u64| -> u32 { v.min(U32_SENTINEL) as u32 }; + tail.extend_from_slice(&0x06054b50u32.to_le_bytes()); + tail.extend_from_slice(&0u16.to_le_bytes()); // this disk + tail.extend_from_slice(&0u16.to_le_bytes()); // cd disk + tail.extend_from_slice(&clamp16(plan.entries.len() as u64).to_le_bytes()); + tail.extend_from_slice(&clamp16(plan.entries.len() as u64).to_le_bytes()); + tail.extend_from_slice(&clamp32(plan.cd_size).to_le_bytes()); + tail.extend_from_slice(&clamp32(plan.cd_offset).to_le_bytes()); + tail.extend_from_slice(&0u16.to_le_bytes()); // comment len + out.write_all(&tail).await?; + out.shutdown().await?; + Ok(()) +} diff --git a/src/s3.rs b/src/s3.rs new file mode 100644 index 0000000..8717c6a --- /dev/null +++ b/src/s3.rs @@ -0,0 +1,113 @@ +use std::path::Path; + +use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region}; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::{Delete, ObjectIdentifier}; +use aws_sdk_s3::Client; +use uuid::Uuid; + +use crate::config::Config; +use crate::state::AppState; + +pub fn client(config: &Config) -> Client { + let credentials = Credentials::new( + &config.s3_access_key, + &config.s3_secret_key, + None, + None, + "photos-config", + ); + let mut builder = aws_sdk_s3::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .credentials_provider(credentials) + .region(Region::new(config.s3_region.clone())) + .force_path_style(config.s3_force_path_style); + if let Some(endpoint) = &config.s3_endpoint { + builder = builder.endpoint_url(endpoint); + } + Client::from_conf(builder.build()) +} + +pub fn original_key(photo_id: Uuid, filename: &str) -> String { + format!("photos/{photo_id}/original/{filename}") +} + +pub fn preview_key(photo_id: Uuid) -> String { + format!("photos/{photo_id}/preview.jpg") +} + +pub fn thumb_key(photo_id: Uuid) -> String { + format!("photos/{photo_id}/thumb.jpg") +} + +pub fn photo_prefix(photo_id: Uuid) -> String { + format!("photos/{photo_id}/") +} + +pub async fn put_file( + state: &AppState, + key: &str, + path: &Path, + content_type: &str, +) -> anyhow::Result<()> { + let body = ByteStream::from_path(path).await?; + state + .s3 + .put_object() + .bucket(&state.config.s3_bucket) + .key(key) + .content_type(content_type) + .body(body) + .send() + .await?; + Ok(()) +} + +pub async fn put_bytes( + state: &AppState, + key: &str, + bytes: Vec, + content_type: &str, +) -> anyhow::Result<()> { + state + .s3 + .put_object() + .bucket(&state.config.s3_bucket) + .key(key) + .content_type(content_type) + .body(ByteStream::from(bytes)) + .send() + .await?; + Ok(()) +} + +pub async fn delete_prefix(state: &AppState, prefix: &str) -> anyhow::Result<()> { + loop { + let list = state + .s3 + .list_objects_v2() + .bucket(&state.config.s3_bucket) + .prefix(prefix) + .send() + .await?; + let objects: Vec = list + .contents() + .iter() + .filter_map(|o| o.key()) + .map(|k| ObjectIdentifier::builder().key(k).build()) + .collect::>()?; + if objects.is_empty() { + return Ok(()); + } + state + .s3 + .delete_objects() + .bucket(&state.config.s3_bucket) + .delete(Delete::builder().set_objects(Some(objects)).build()?) + .send() + .await?; + if !list.is_truncated().unwrap_or(false) { + return Ok(()); + } + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..d07ae3c --- /dev/null +++ b/src/state.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use axum::extract::FromRef; +use axum_extra::extract::cookie::Key; +use sha2::{Digest, Sha512}; +use sqlx::PgPool; +use tokio::sync::OnceCell; + +use crate::auth::OidcDiscovery; +use crate::config::Config; + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, + pub s3: aws_sdk_s3::Client, + pub http: reqwest::Client, + pub config: Arc, + pub cookie_key: Key, + pub oidc: Arc>, + /// Caps concurrent zip streams — each holds S3 connections for its + /// duration, and slow readers would otherwise pin them indefinitely. + pub zip_permits: Arc, +} + +impl FromRef for Key { + fn from_ref(state: &AppState) -> Key { + state.cookie_key.clone() + } +} + +impl AppState { + pub async fn new(config: Config) -> anyhow::Result { + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(10) + .connect(&config.database_url) + .await?; + sqlx::migrate!("./migrations").run(&db).await?; + let s3 = crate::s3::client(&config); + // SHA-512 digest is exactly the 64 bytes Key::from requires. + let cookie_key = Key::from(&Sha512::digest(config.session_secret.as_bytes())); + Ok(Self { + db, + s3, + http: reqwest::Client::new(), + config: Arc::new(config), + cookie_key, + oidc: Arc::new(OnceCell::new()), + zip_permits: Arc::new(tokio::sync::Semaphore::new(4)), + }) + } +}