diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ __pycache__/ .DS_Store *.xcuserstate -.idea \ No newline at end of file +.idea +node_modules/ +worker-donation/wrangler.toml diff --git a/site/README.md b/site/README.md --- a/site/README.md +++ b/site/README.md @@ -8,5 +8,31 @@ - `https://derakkuma.com/oauth/client-metadata.json` - `https://derakkuma.com/.well-known/atproto-did` +- `https://derakkuma.com/donation/?session_id={CHECKOUT_SESSION_ID}` Stripe success redirect back into the app + +Partner app icon donation verification uses the standalone Cloudflare Worker in +`../worker-donation`: + +- `GET /api/donation/verify?session_id=...&nonce=...` +- `GET /api/donation/restore?nonce=...` + +The app calls: + +```text +https://derakkuma-donation.overtake.workers.dev/api/donation/* +``` + +Configure the worker with: + +- `DERAKKUMA_STRIPE_SECRET_KEY` secret +- `DERAKKUMA_DONATIONS` KV binding + +Stripe Checkout/Payment Link should set the success URL to: + +```text +https://derakkuma.com/donation/?session_id={CHECKOUT_SESSION_ID} +``` + +The app appends `client_reference_id=` to the Payment Link. The HappyView AppView/dashboard remains hosted at `https://milk.derakkuma.com`. diff --git a/worker-donation/README.md b/worker-donation/README.md new file mode 100644 --- /dev/null +++ b/worker-donation/README.md @@ -0,0 +1,57 @@ +# Derakkuma donation worker + +Standalone Cloudflare Worker for verifying Stripe donations and restoring partner +app icon unlocks. + +## Endpoints + +- `GET /api/donation/verify?session_id=...&nonce=...` +- `GET /api/donation/restore?nonce=...` + +`nonce` is `sha256(friend_code_digits_only)`, generated client-side. + +## Setup + +```bash +cd worker-donation +bun install +wrangler kv namespace create DERAKKUMA_DONATIONS +wrangler kv namespace create DERAKKUMA_DONATIONS --preview +``` + +Then set the Stripesecret key: + +```bash +wrangler secret put STRIPE_SECRET_KEY +``` + +Your `wrangler.toml` should look like this: + +```toml +name = "derakkuma-donation" +main = "src/index.ts" +compatibility_date = "2026-05-24" + +[[kv_namespaces]] +binding = "DERAKKUMA_DONATIONS" +id = "" +preview_id = "" +``` + +Deploy: + +```bash +wrangler deploy +``` + +The app calls the deployed worker directly at: + +```text +https://derakkuma-donation.overtake.workers.dev/api/donation/* +``` + +Stripe Checkout/Payment Link success URL: + +```text +https://derakkuma.com/donation/?session_id={CHECKOUT_SESSION_ID} +``` diff --git a/worker-donation/bun.lock b/worker-donation/bun.lock new file mode 100644 --- /dev/null +++ b/worker-donation/bun.lock @@ -0,0 +1,205 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "derakkuma-donation-worker", + "devDependencies": { + "@cloudflare/workers-types": "latest", + "typescript": "latest", + "wrangler": "latest", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260521.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260521.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260521.1", "", { "os": "linux", "cpu": "x64" }, "sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260521.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260521.1", "", { "os": "win32", "cpu": "x64" }, "sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260524.1", "", {}, "sha512-9o939wce6hAlfNAIG4W58bySW7twgkqgPkxmSNrk/DV0eEexjTPyqChGdsQeKZEXJAjxSUFklaebMYJWg1GM0g=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "miniflare": ["miniflare@4.20260521.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260521.1", "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "rosie-skills": ["rosie-skills@0.6.4", "", { "optionalDependencies": { "rosie-skills-darwin-arm64": "0.6.4", "rosie-skills-freebsd-x64": "0.6.4", "rosie-skills-linux-x64": "0.6.4" }, "bin": { "rosie-skills": "dist/bin.js" } }, "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA=="], + + "rosie-skills-darwin-arm64": ["rosie-skills-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg=="], + + "rosie-skills-freebsd-x64": ["rosie-skills-freebsd-x64@0.6.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng=="], + + "rosie-skills-linux-x64": ["rosie-skills-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "workerd": ["workerd@1.20260521.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260521.1", "@cloudflare/workerd-darwin-arm64": "1.20260521.1", "@cloudflare/workerd-linux-64": "1.20260521.1", "@cloudflare/workerd-linux-arm64": "1.20260521.1", "@cloudflare/workerd-windows-64": "1.20260521.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ=="], + + "wrangler": ["wrangler@4.94.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260521.0", "path-to-regexp": "6.3.0", "rosie-skills": "^0.6.3", "unenv": "2.0.0-rc.24", "workerd": "1.20260521.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260521.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew=="], + + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/worker-donation/package.json b/worker-donation/package.json new file mode 100644 --- /dev/null +++ b/worker-donation/package.json @@ -0,0 +1,15 @@ +{ + "name": "derakkuma-donation-worker", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "latest", + "typescript": "latest", + "wrangler": "latest" + } +} diff --git a/worker-donation/tsconfig.json b/worker-donation/tsconfig.json new file mode 100644 --- /dev/null +++ b/worker-donation/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/site/donation/index.html b/site/donation/index.html new file mode 100644 --- /dev/null +++ b/site/donation/index.html @@ -0,0 +1,43 @@ + + + + + + Returning to Derakkuma… + + + + +
+

Thanks for supporting Derakkuma!

+

Sending you back to the app to unlock partner icons…

+

Open Derakkuma

+
+ + + diff --git a/worker-donation/src/index.ts b/worker-donation/src/index.ts new file mode 100644 --- /dev/null +++ b/worker-donation/src/index.ts @@ -0,0 +1,85 @@ +interface Env { + DERAKKUMA_STRIPE_SECRET_KEY: string; + DERAKKUMA_DONATIONS: KVNamespace; +} + +const DERAKKUMA_ICON_PRODUCT = "prod_UZYIAHMTzfOzao"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, OPTIONS", + "access-control-allow-headers": "content-type", + }, + }); + +const validNonce = (nonce: string) => /^[a-f0-9]{64}$/i.test(nonce); + +const donationKey = (nonce: string) => `partner-icons:${nonce}`; + +const verifyDonation = async (request: Request, env: Env) => { + const url = new URL(request.url); + const sessionId = url.searchParams.get("session_id")?.trim() ?? ""; + const nonce = url.searchParams.get("nonce")?.trim().toLowerCase() ?? ""; + + if (!sessionId || !validNonce(nonce)) return json({ ok: false, reason: "bad_request" }, 400); + if (!env.DERAKKUMA_STRIPE_SECRET_KEY) return json({ ok: false, reason: "stripe_not_configured" }, 500); + + const sessionRes = await fetch(`https://api.stripe.com/v1/checkout/sessions/${encodeURIComponent(sessionId)}`, { + headers: { authorization: `Bearer ${env.DERAKKUMA_STRIPE_SECRET_KEY}` }, + }); + if (!sessionRes.ok) return json({ ok: false, reason: "session_not_found" }, 404); + + const session = await sessionRes.json() as { + id: string; + payment_status?: string; + amount_total?: number; + currency?: string; + client_reference_id?: string; + }; + + if (session.client_reference_id?.toLowerCase() !== nonce) return json({ ok: false, reason: "nonce_mismatch" }, 403); + if (session.payment_status !== "paid") return json({ ok: false, reason: "not_paid" }, 402); + if ((session.amount_total ?? 0) < 100 || session.currency !== "usd") return json({ ok: false, reason: "amount_too_low" }, 402); + + const lineItemsRes = await fetch( + `https://api.stripe.com/v1/checkout/sessions/${encodeURIComponent(sessionId)}/line_items?expand[]=data.price.product&limit=10`, + { headers: { authorization: `Bearer ${env.DERAKKUMA_STRIPE_SECRET_KEY}` } }, + ); + if (!lineItemsRes.ok) return json({ ok: false, reason: "line_items_unavailable" }, 502); + + const lineItems = await lineItemsRes.json() as { data?: Array<{ price?: { product?: string | { id?: string } } }> }; + const hasDerakkumaProduct = (lineItems.data ?? []).some((item) => { + const product = item.price?.product; + return product === DERAKKUMA_ICON_PRODUCT || (typeof product === "object" && product.id === DERAKKUMA_ICON_PRODUCT); + }); + if (!hasDerakkumaProduct) return json({ ok: false, reason: "wrong_product" }, 403); + + await env.DERAKKUMA_DONATIONS.put(donationKey(nonce), JSON.stringify({ sessionId: session.id, verifiedAt: new Date().toISOString() })); + return json({ ok: true }); +}; + +const restoreDonation = async (request: Request, env: Env) => { + const url = new URL(request.url); + const nonce = url.searchParams.get("nonce")?.trim().toLowerCase() ?? ""; + if (!validNonce(nonce)) return json({ ok: false, reason: "bad_request" }, 400); + + const donation = await env.DERAKKUMA_DONATIONS.get(donationKey(nonce)); + return json({ ok: donation != null }); +}; + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method === "OPTIONS") return json({ ok: true }); + if (request.method !== "GET") return json({ ok: false, reason: "method_not_allowed" }, 405); + + const url = new URL(request.url); + if (url.pathname === "/api/donation/verify") return verifyDonation(request, env); + if (url.pathname === "/api/donation/restore") return restoreDonation(request, env); + + return json({ ok: false, reason: "not_found" }, 404); + }, +}; diff --git a/composeApp/src/androidMain/kotlin/com/derakkuma/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/derakkuma/MainActivity.kt --- a/composeApp/src/androidMain/kotlin/com/derakkuma/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/derakkuma/MainActivity.kt @@ -14,6 +14,8 @@ import com.derakkuma.data.CacheStore import com.derakkuma.data.DriverFactory import com.derakkuma.data.PlayStore +import com.derakkuma.donation.DonationUnlock +import com.derakkuma.donation.donationSuccessSessionId import com.derakkuma.kamaitachi.KamaitachiClient import com.derakkuma.ui.images.initCoil import io.github.samuolis.posthog.PostHog @@ -82,6 +84,21 @@ Log.d("MainActivity", "Kamaitachi login completed") } catch (e: Exception) { Log.e("MainActivity", "Kamaitachi login failed", e) + } + } + } else if (uri.scheme == "com.derakkuma" && uri.path == "/donation-success") { + val sessionId = donationSuccessSessionId(uri.toString()) ?: return + Log.d("MainActivity", "Donation success redirect received") + CoroutineScope(Dispatchers.Main).launch { + try { + val cacheStore = CacheStore(DriverFactory(this@MainActivity)) + if (DonationUnlock.verifySession(cacheStore, sessionId)) { + Log.d("MainActivity", "Donation verified; partner app icons unlocked") + } else { + Log.w("MainActivity", "Donation verification failed") + } + } catch (e: Exception) { + Log.e("MainActivity", "Donation verification error", e) } } } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/donation/DonationUnlock.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/donation/DonationUnlock.kt new file mode 100644 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/donation/DonationUnlock.kt @@ -0,0 +1,83 @@ +package com.derakkuma.donation + +import com.derakkuma.atproto.vendor.oauth.provider +import com.derakkuma.data.CacheStore +import dev.whyoleg.cryptography.algorithms.SHA256 +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.encodeURLParameter +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private const val PARTNER_ICONS_UNLOCKED_KEY = "partnerAppIconsUnlocked" +private const val PARTNER_ICONS_DONATION_SESSION_KEY = "partnerAppIconsDonationSession" +private const val DONATION_VERIFY_URL = "https://derakkuma-donation.overtake.workers.dev/api/donation/verify" +private const val DONATION_RESTORE_URL = "https://derakkuma-donation.overtake.workers.dev/api/donation/restore" + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class DonationVerifyResponse( + val ok: Boolean = false, + val reason: String = "", +) + +object DonationUnlock { + fun isUnlocked(cacheStore: CacheStore): Boolean = cacheStore.getSetting(PARTNER_ICONS_UNLOCKED_KEY) == "true" + + fun setUnlocked(cacheStore: CacheStore, sessionId: String? = null) { + cacheStore.putSetting(PARTNER_ICONS_UNLOCKED_KEY, "true") + if (!sessionId.isNullOrBlank()) cacheStore.putSetting(PARTNER_ICONS_DONATION_SESSION_KEY, sessionId) + } + + suspend fun friendCodeNonce(cacheStore: CacheStore): String? { + val friendCode = cacheStore.getFriendCode()?.code + ?.filter { it.isDigit() } + ?.takeIf { it.isNotBlank() } + ?: return null + val digest = provider().get(SHA256) + .hasher() + .hash(friendCode.encodeToByteArray()) + return digest.joinToString("") { byte -> byte.toUByte().toString(16).padStart(2, '0') } + } + + suspend fun verifySession(cacheStore: CacheStore, sessionId: String, httpClient: HttpClient = HttpClient()): Boolean { + val nonce = friendCodeNonce(cacheStore) ?: return false + val response = httpClient.get( + "$DONATION_VERIFY_URL?session_id=${sessionId.encodeURLParameter()}&nonce=${nonce.encodeURLParameter()}", + ).bodyAsText() + val result = runCatching { json.decodeFromString(response) }.getOrNull() + if (result?.ok == true) { + setUnlocked(cacheStore, sessionId) + return true + } + return false + } + + suspend fun restore(cacheStore: CacheStore, httpClient: HttpClient = HttpClient()): Boolean { + val nonce = friendCodeNonce(cacheStore) ?: return false + val response = httpClient.get( + "$DONATION_RESTORE_URL?nonce=${nonce.encodeURLParameter()}", + ).bodyAsText() + val result = runCatching { json.decodeFromString(response) }.getOrNull() + if (result?.ok == true) { + setUnlocked(cacheStore) + return true + } + return false + } +} + +fun donationSuccessSessionId(url: String): String? { + val query = url.substringAfter('?', missingDelimiterValue = "") + if (query.isBlank()) return null + return query.split('&') + .mapNotNull { + val key = it.substringBefore('=') + val value = it.substringAfter('=', missingDelimiterValue = "") + if (key == "session_id") value else null + } + .firstOrNull() + ?.takeIf { it.isNotBlank() } +} diff --git a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/OpenOAuthUrl.ios.kt b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/OpenOAuthUrl.ios.kt --- a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/OpenOAuthUrl.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/OpenOAuthUrl.ios.kt @@ -1,6 +1,10 @@ package com.derakkuma.atproto import com.derakkuma.kamaitachi.IosKamaitachiRegistry +import com.derakkuma.data.CacheStore +import com.derakkuma.data.DriverFactory +import com.derakkuma.donation.DonationUnlock +import com.derakkuma.donation.donationSuccessSessionId import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.MainScope @@ -33,6 +37,11 @@ /** Stable Swift-callable object for native iOS URL callback glue. */ object AtProtoRedirect { fun handleOAuthRedirect(url: String) { + if (url.startsWith("com.derakkuma:/donation-success")) { + val sessionId = donationSuccessSessionId(url) ?: return + MainScope().launch { DonationUnlock.verifySession(CacheStore(DriverFactory()), sessionId) } + return + } if (url.startsWith("com.derakkuma:/kamai")) { val client = IosKamaitachiRegistry.client ?: return MainScope().launch { client.completeLogin(url) } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AppIconTab.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AppIconTab.kt --- a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AppIconTab.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AppIconTab.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.derakkuma.atproto.openOAuthUrl import com.derakkuma.appicon.AppIconOption import com.derakkuma.appicon.AppIconOptions import com.derakkuma.appicon.platformMonochromeAppIconColors @@ -27,11 +28,15 @@ import com.derakkuma.appicon.platformUsesIosAppIconPreviews import com.derakkuma.appicon.setAppIcon import com.derakkuma.data.CacheStore +import com.derakkuma.donation.DonationUnlock import com.derakkuma.ui.components.SplitButtonRow import com.derakkuma.ui.theme.* import derakkuma.composeapp.generated.resources.Res +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.decodeToImageBitmap + +private const val DERAKKUMA_DONATION_URL = "https://buy.stripe.com/3cI4gz4KfgnEeCzev2gnK00" @OptIn(ExperimentalResourceApi::class) @Composable @@ -39,6 +44,21 @@ var selected by remember { mutableStateOf(cacheStore.getSetting("appIcon") ?: "default") } var previewMode by remember { mutableStateOf("default") } var snackMsg by remember { mutableStateOf(null) } + var partnerIconsUnlocked by remember { mutableStateOf(DonationUnlock.isUnlocked(cacheStore)) } + var pendingPartnerIcon by remember { mutableStateOf(null) } + var checkingRestore by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + fun applyIcon(option: AppIconOption) { + val ok = setAppIcon(option) + if (ok) { + selected = option.id + cacheStore.putSetting("appIcon", option.id) + snackMsg = "App icon changed to ${option.label}" + } else { + snackMsg = "Alternate app icons are not supported on this device" + } + } Scaffold( contentWindowInsets = WindowInsets(0.dp), @@ -86,19 +106,67 @@ selected = selected == option.id, alternatePreview = previewMode == "alternate", onClick = { - val ok = setAppIcon(option) - if (ok) { - selected = option.id - cacheStore.putSetting("appIcon", option.id) - snackMsg = "App icon changed to ${option.label}" + if (option.id == "default" || partnerIconsUnlocked) { + applyIcon(option) } else { - snackMsg = "Alternate app icons are not supported on this device" + pendingPartnerIcon = option } }, ) } } } + } + + pendingPartnerIcon?.let { option -> + AlertDialog( + onDismissRequest = { pendingPartnerIcon = null }, + title = { Text("Support Derakkuma") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Derakkuma is, and will always be, a free and open source project made for the maimai community out of love.") + Text("If you like it, you can donate $2 (the price of just one maimai play!) or more to support development and unlock partner app icons!") + } + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + val nonce = DonationUnlock.friendCodeNonce(cacheStore) + openOAuthUrl(if (nonce != null) "$DERAKKUMA_DONATION_URL?client_reference_id=$nonce" else DERAKKUMA_DONATION_URL) + } + }, + ) { + Text("Unlock App Icons") + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { pendingPartnerIcon = null }) { + Text("Not now") + } + TextButton( + onClick = { + scope.launch { + checkingRestore = true + val restored = DonationUnlock.restore(cacheStore) + checkingRestore = false + if (restored) { + partnerIconsUnlocked = true + pendingPartnerIcon = null + applyIcon(option) + } else { + snackMsg = "Donation not found yet. If you just donated, try again in a moment." + } + } + }, + enabled = !checkingRestore, + ) { + Text(if (checkingRestore) "Checking..." else "I donated") + } + } + }, + ) } } diff --git a/composeApp/src/androidMain/kotlin/com/derakkuma/atproto/vendor/oauth/CryptoProvider.android.kt b/composeApp/src/androidMain/kotlin/com/derakkuma/atproto/vendor/oauth/CryptoProvider.android.kt --- a/composeApp/src/androidMain/kotlin/com/derakkuma/atproto/vendor/oauth/CryptoProvider.android.kt +++ b/composeApp/src/androidMain/kotlin/com/derakkuma/atproto/vendor/oauth/CryptoProvider.android.kt @@ -3,4 +3,4 @@ import dev.whyoleg.cryptography.CryptographyProvider import dev.whyoleg.cryptography.providers.jdk.JDK -internal actual fun provider(): CryptographyProvider = CryptographyProvider.Companion.JDK +actual fun provider(): CryptographyProvider = CryptographyProvider.Companion.JDK diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/vendor/oauth/DpopKeyPair.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/vendor/oauth/DpopKeyPair.kt --- a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/vendor/oauth/DpopKeyPair.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/vendor/oauth/DpopKeyPair.kt @@ -77,4 +77,4 @@ internal fun ByteArray.encodeBase64Url(): String = Base64.UrlSafe.encode(this).trimEnd('=') /** Resolves the platform-appropriate [CryptographyProvider]. */ -internal expect fun provider(): CryptographyProvider +expect fun provider(): CryptographyProvider diff --git a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/vendor/oauth/CryptoProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/vendor/oauth/CryptoProvider.ios.kt --- a/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/vendor/oauth/CryptoProvider.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/derrakuma/atproto/vendor/oauth/CryptoProvider.ios.kt @@ -3,4 +3,4 @@ import dev.whyoleg.cryptography.CryptographyProvider import dev.whyoleg.cryptography.providers.apple.Apple -internal actual fun provider(): CryptographyProvider = CryptographyProvider.Companion.Apple +actual fun provider(): CryptographyProvider = CryptographyProvider.Companion.Apple