mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-02-12 15:02:45 +00:00
separate fe and be, deploy on push to master
This commit is contained in:
38
.github/workflows/deflock-api-deploy.yml
vendored
Normal file
38
.github/workflows/deflock-api-deploy.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: DeFlock API Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install
|
||||
working-directory: ./api
|
||||
|
||||
- name: Configure SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Sync files
|
||||
run: |
|
||||
rsync -az --delete \
|
||||
--exclude node_modules \
|
||||
./api/ ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/home/nullplate/deflock/api
|
||||
|
||||
- name: Restart services
|
||||
run: |
|
||||
ssh ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} "
|
||||
sudo systemctl restart deflock-api
|
||||
"
|
||||
31
api/README.md
Normal file
31
api/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# DeFlock API
|
||||
|
||||
A Fastify-based API service for DeFlock handling non-OSM related backend logic.
|
||||
|
||||
## Endpoints
|
||||
- `/geocode?query=...` — Geocode a location
|
||||
- `/sponsors/github?username=...` — Get GitHub sponsors
|
||||
- `/healthcheck` — Health check
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
- [Bun](https://bun.sh/) installed
|
||||
|
||||
### Install dependencies
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Run locally
|
||||
```sh
|
||||
bun server.ts
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployed via GitHub Actions on push to `master`.
|
||||
|
||||
## Environment Variables
|
||||
Create a `.env` file in this directory with:
|
||||
- `GITHUB_TOKEN` — Required for GitHub Sponsors endpoint
|
||||
177
api/bun.lock
Normal file
177
api/bun.lock
Normal file
@@ -0,0 +1,177 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-fs-hash": "^3.0.0",
|
||||
"fastify": "^5.7.2",
|
||||
"pino-pretty": "^13.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cache-manager-fs-hash": "^0.0.5",
|
||||
"@types/node": "^25.2.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@cacheable/utils": ["@cacheable/utils@2.3.3", "", { "dependencies": { "hashery": "^1.3.0", "keyv": "^5.5.5" } }, "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A=="],
|
||||
|
||||
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
|
||||
|
||||
"@fastify/cors": ["@fastify/cors@10.1.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "mnemonist": "0.40.0" } }, "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ=="],
|
||||
|
||||
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||
|
||||
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
||||
|
||||
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
|
||||
|
||||
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||
|
||||
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||
|
||||
"@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
|
||||
|
||||
"@types/cache-manager": ["@types/cache-manager@3.4.3", "", {}, "sha512-71aBXoFYXZW4TnDHHH8gExw2lS28BZaWeKefgsiJI7QYZeJfUEbMKw6CQtzGjlYQcGIWwB76hcCrkVA3YHSvsw=="],
|
||||
|
||||
"@types/cache-manager-fs-hash": ["@types/cache-manager-fs-hash@0.0.5", "", { "dependencies": { "@types/cache-manager": "<4" } }, "sha512-mSqk9YisfK/NkB/R5SzGeuSIVtwHhM5m6MLB0VrrFteTphKiQ2Fyz88IRtiX+SYEX6Nw2H3kB9qtpfnVSE/mSQ=="],
|
||||
|
||||
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="],
|
||||
|
||||
"cache-manager": ["cache-manager@7.2.8", "", { "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" } }, "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ=="],
|
||||
|
||||
"cache-manager-fs-hash": ["cache-manager-fs-hash@3.0.0", "", { "dependencies": { "lockfile": "^1.0.4" } }, "sha512-uFl2EOuIdz5bLIjcRbR5cAxt9JdKQ5jQ6r7w5LXs1V+ls9I232nLVfaEHwK5h+isbV7j2z7ceaMe8lvr17BMVA=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stringify": ["fast-json-stringify@6.2.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw=="],
|
||||
|
||||
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fastify": ["fastify@5.7.2", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A=="],
|
||||
|
||||
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"find-my-way": ["find-my-way@9.4.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w=="],
|
||||
|
||||
"hashery": ["hashery@1.4.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
|
||||
|
||||
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
|
||||
|
||||
"lockfile": ["lockfile@1.0.4", "", { "dependencies": { "signal-exit": "^3.0.2" } }, "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mnemonist": ["mnemonist@0.40.0", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg=="],
|
||||
|
||||
"obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"pino": ["pino@10.3.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
}
|
||||
}
|
||||
21
api/package.json
Normal file
21
api/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "deflock-api",
|
||||
"version": "1.0.0",
|
||||
"main": "server.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-fs-hash": "^3.0.0",
|
||||
"fastify": "^5.7.2",
|
||||
"pino-pretty": "^13.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cache-manager-fs-hash": "^0.0.5",
|
||||
"@types/node": "^25.2.0"
|
||||
}
|
||||
}
|
||||
127
api/server.ts
Normal file
127
api/server.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import Fastify, { FastifyInstance, FastifyError } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { NominatimClient, NominatimResultSchema } from './services/NominatimClient';
|
||||
import { GithubClient, SponsorsResponseSchema } from './services/GithubClient';
|
||||
|
||||
const start = async () => {
|
||||
const server: FastifyInstance = Fastify({
|
||||
logger: {
|
||||
level: 'error',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
},
|
||||
trustProxy: true,
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
server.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
server.log.error({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
}, 'Request error');
|
||||
|
||||
reply.status(error.statusCode || 500).send({
|
||||
error: 'Internal Server Error',
|
||||
});
|
||||
});
|
||||
|
||||
// Coors Light Config
|
||||
await server.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173', // vite dev server
|
||||
'https://deflock.me',
|
||||
'https://www.deflock.me',
|
||||
'https://deflock.org', // will migrate
|
||||
'https://www.deflock.org', // will migrate
|
||||
];
|
||||
|
||||
if (!origin || allowedOrigins.includes(origin) || /^https:\/\/.*\.deflock\.pages\.dev$/.test(origin)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(null, false);
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'HEAD'],
|
||||
});
|
||||
|
||||
const nominatim = new NominatimClient();
|
||||
const githubClient = new GithubClient();
|
||||
|
||||
const shutdown = async () => {
|
||||
server.log.info("Shutting down");
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
server.get('/geocode', {
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
response: {
|
||||
200: NominatimResultSchema,
|
||||
500: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { query } = request.query as { query: string };
|
||||
reply.header('Cache-Control', 'public, max-age=300, s-maxage=86400');
|
||||
const result = await nominatim.geocodeSingleResult(query);
|
||||
return result;
|
||||
});
|
||||
|
||||
server.get('/sponsors/github', {
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', default: 'frillweeman' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: SponsorsResponseSchema,
|
||||
500: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { username } = request.query as { username?: string };
|
||||
reply.header('Cache-Control', 'public, max-age=60, s-maxage=600');
|
||||
const result = await githubClient.getSponsors(username || 'frillweeman');
|
||||
return result;
|
||||
});
|
||||
|
||||
server.head('/healthcheck', async (request, reply) => {
|
||||
reply.status(200).send();
|
||||
});
|
||||
|
||||
try {
|
||||
await server.listen({ host: '0.0.0.0', port: 3000 });
|
||||
console.log('Server listening on port 3000');
|
||||
} catch (err) {
|
||||
console.error('Failed to start server:', err);
|
||||
server.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
39
api/services/GithubClient.ts
Normal file
39
api/services/GithubClient.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
|
||||
const graphQLEndpoint = 'https://api.github.com/graphql';
|
||||
|
||||
export const SponsorSchema = Type.Object({
|
||||
sponsor: Type.Object({
|
||||
avatarUrl: Type.String(),
|
||||
login: Type.String(),
|
||||
name: Type.Union([Type.String(), Type.Null()]),
|
||||
url: Type.String(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type Sponsor = Static<typeof SponsorSchema>;
|
||||
|
||||
export const SponsorsResponseSchema = Type.Array(SponsorSchema);
|
||||
|
||||
export class GithubClient {
|
||||
async getSponsors(username: string): Promise<Sponsor[]> {
|
||||
const query = `query { user(login: \"${username}\") { sponsorshipsAsMaintainer(first: 100) { nodes { sponsor { login name avatarUrl url } } } } }`;
|
||||
const body = JSON.stringify({ query, variables: '' });
|
||||
const response = await fetch(graphQLEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${GITHUB_TOKEN}`,
|
||||
'User-Agent': 'Shotgun',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get sponsors: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
return json?.data?.user?.sponsorshipsAsMaintainer?.nodes || [];
|
||||
}
|
||||
}
|
||||
88
api/services/NominatimClient.ts
Normal file
88
api/services/NominatimClient.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
|
||||
import { createCache, Cache } from 'cache-manager';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
const { DiskStore } = require('cache-manager-fs-hash');
|
||||
|
||||
export const NominatimResultSchema = Type.Object({
|
||||
addresstype: Type.String(),
|
||||
boundingbox: Type.Tuple([Type.String(), Type.String(), Type.String(), Type.String()]),
|
||||
class: Type.String(),
|
||||
display_name: Type.String(),
|
||||
geojson: Type.Object({
|
||||
coordinates: Type.Any(),
|
||||
type: Type.String(),
|
||||
}),
|
||||
importance: Type.Number(),
|
||||
lat: Type.String(),
|
||||
licence: Type.String(),
|
||||
lon: Type.String(),
|
||||
name: Type.String(),
|
||||
osm_id: Type.Number(),
|
||||
osm_type: Type.String(),
|
||||
place_id: Type.Number(),
|
||||
place_rank: Type.Number(),
|
||||
type: Type.String(),
|
||||
});
|
||||
|
||||
export type NominatimResult = Static<typeof NominatimResultSchema>;
|
||||
|
||||
const cache: Cache = createCache({
|
||||
stores: [new DiskStore({
|
||||
path: '/tmp/nominatim-cache',
|
||||
ttl: 3600 * 24, // 24 hours
|
||||
maxsize: 1000 * 1000 * 100, // 100MB
|
||||
subdirs: true,
|
||||
zip: false,
|
||||
})]
|
||||
});
|
||||
|
||||
export class NominatimClient {
|
||||
baseUrl = 'https://nominatim.openstreetmap.org/search';
|
||||
|
||||
async geocodePhrase(query: string): Promise<NominatimResult[]> {
|
||||
const cacheKey = `geocode:${query}`;
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached as NominatimResult[];
|
||||
}
|
||||
const url = `${this.baseUrl}?q=${encodeURIComponent(query)}&polygon_geojson=1&format=json`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'DeFlock/1.1',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to geocode phrase: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
await cache.set(cacheKey, json);
|
||||
return json;
|
||||
}
|
||||
|
||||
async geocodeSingleResult(query: string): Promise<NominatimResult | null> {
|
||||
const results = await this.geocodePhrase(query);
|
||||
|
||||
if (!results.length) return null;
|
||||
|
||||
const cityStatePattern = /(.+),\s*(\w{2})/;
|
||||
const postalCodePattern = /\d{5}/;
|
||||
|
||||
if (cityStatePattern.test(query)) {
|
||||
const cityStateResults = results.filter((result: NominatimResult) =>
|
||||
["city", "town", "village", "hamlet", "suburb", "quarter", "neighbourhood", "borough"].includes(result.addresstype)
|
||||
);
|
||||
if (cityStateResults.length) {
|
||||
return cityStateResults[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (postalCodePattern.test(query)) {
|
||||
const postalCodeResults = results.filter((result: NominatimResult) => result.addresstype === "postcode");
|
||||
if (postalCodeResults.length) {
|
||||
return postalCodeResults[0];
|
||||
}
|
||||
}
|
||||
|
||||
return results[0];
|
||||
}
|
||||
}
|
||||
70
shotgun/.gitignore
vendored
70
shotgun/.gitignore
vendored
@@ -1,70 +0,0 @@
|
||||
#
|
||||
# Are you tempted to edit this file?
|
||||
#
|
||||
# First consider if the changes make sense for all,
|
||||
# or if they are specific to your workflow/system.
|
||||
# If it is the latter, you can augment this list with
|
||||
# entries in .git/info/excludes
|
||||
#
|
||||
# see also test/files/.gitignore
|
||||
#
|
||||
|
||||
#
|
||||
# JARs aren't checked in, they are fetched by sbt
|
||||
#
|
||||
/lib/*.jar
|
||||
/test/files/codelib/*.jar
|
||||
/test/files/lib/*.jar
|
||||
/test/files/speclib/instrumented.jar
|
||||
/tools/*.jar
|
||||
|
||||
# Developer specific properties
|
||||
/build.properties
|
||||
/buildcharacter.properties
|
||||
|
||||
# might get generated when testing Jenkins scripts locally
|
||||
/jenkins.properties
|
||||
|
||||
# target directory for build
|
||||
/build/
|
||||
|
||||
# other
|
||||
/out/
|
||||
/bin/
|
||||
/sandbox/
|
||||
|
||||
# intellij
|
||||
/src/intellij*/*.iml
|
||||
/src/intellij*/*.ipr
|
||||
/src/intellij*/*.iws
|
||||
**/.cache
|
||||
/.idea
|
||||
/.settings
|
||||
|
||||
# vscode
|
||||
/.vscode
|
||||
|
||||
# Standard symbolic link to build/quick/bin
|
||||
/qbin
|
||||
|
||||
# sbt's target directories
|
||||
/target/
|
||||
/project/**/target/
|
||||
/test/macro-annot/target/
|
||||
/test/files/target/
|
||||
/test/target/
|
||||
/build-sbt/
|
||||
local.sbt
|
||||
jitwatch.out
|
||||
|
||||
# Used by the restarr/restarrFull commands as target directories
|
||||
/build-restarr/
|
||||
/target-restarr/
|
||||
|
||||
# metals
|
||||
.metals
|
||||
.bloop
|
||||
project/**/metals.sbt
|
||||
|
||||
.bsp
|
||||
.history
|
||||
@@ -1,32 +0,0 @@
|
||||
import Dependencies._
|
||||
|
||||
ThisBuild / scalaVersion := "2.12.8"
|
||||
ThisBuild / version := "0.1.0-SNAPSHOT"
|
||||
ThisBuild / organization := "me.deflock"
|
||||
ThisBuild / organizationName := "DeFlock"
|
||||
|
||||
lazy val root = (project in file("."))
|
||||
.settings(
|
||||
name := "shotgun",
|
||||
libraryDependencies += scalaTest % Test,
|
||||
)
|
||||
|
||||
val PekkoVersion = "1.0.3"
|
||||
val PekkoHttpVersion = "1.0.1"
|
||||
libraryDependencies ++= Seq(
|
||||
"ch.qos.logback" % "logback-classic" % "1.5.6",
|
||||
"org.slf4j" % "slf4j-api" % "2.0.12",
|
||||
"org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion,
|
||||
"org.apache.pekko" %% "pekko-stream" % PekkoVersion,
|
||||
"org.apache.pekko" %% "pekko-http" % PekkoHttpVersion,
|
||||
"org.apache.pekko" %% "pekko-http-spray-json" % PekkoHttpVersion,
|
||||
"org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion,
|
||||
"org.apache.pekko" %% "pekko-slf4j" % PekkoVersion,
|
||||
)
|
||||
|
||||
assembly / assemblyMergeStrategy := {
|
||||
case PathList("module-info.class") => MergeStrategy.first
|
||||
case x =>
|
||||
val oldStrategy = (assembly / assemblyMergeStrategy).value
|
||||
oldStrategy(x)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import sbt._
|
||||
|
||||
object Dependencies {
|
||||
lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
sbt.version=1.9.1
|
||||
@@ -1 +0,0 @@
|
||||
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.1.0")
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -1,123 +0,0 @@
|
||||
package me.deflock.shotgun
|
||||
|
||||
import org.apache.pekko
|
||||
import org.apache.pekko.actor.ActorSystem
|
||||
import org.apache.pekko.event.Logging
|
||||
import org.apache.pekko.http.cors.scaladsl.CorsDirectives.cors
|
||||
import org.apache.pekko.http.cors.scaladsl.model.HttpOriginMatcher
|
||||
import org.apache.pekko.http.cors.scaladsl.settings.CorsSettings
|
||||
import org.apache.pekko.http.scaladsl.model.headers.{HttpOrigin, `Access-Control-Allow-Origin`}
|
||||
import pekko.http.scaladsl.Http
|
||||
import pekko.http.scaladsl.model._
|
||||
import pekko.http.scaladsl.server.Directives.{path, _}
|
||||
import org.apache.pekko.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
|
||||
import org.apache.pekko.http.scaladsl.server.RejectionHandler
|
||||
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import scala.concurrent.ExecutionContextExecutor
|
||||
import scala.io.StdIn
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
object ShotgunServer {
|
||||
|
||||
val logger = LoggerFactory.getLogger(getClass)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
implicit val system: ActorSystem = ActorSystem("my-system")
|
||||
implicit val executionContext: ExecutionContextExecutor = system.dispatcher
|
||||
val logging = Logging(system, getClass)
|
||||
|
||||
val nominatim = new services.NominatimClient()
|
||||
val githubClient = new services.GithubClient()
|
||||
|
||||
// CORS
|
||||
val allowedOrigins = List(
|
||||
"http://localhost:8080",
|
||||
"http://localhost:5173",
|
||||
"https://deflock.me",
|
||||
"https://www.deflock.me",
|
||||
).map(HttpOrigin(_)) // TODO: make this a config setting
|
||||
val corsSettings = CorsSettings.default
|
||||
.withAllowedOrigins(HttpOriginMatcher(allowedOrigins: _*))
|
||||
.withExposedHeaders(List(`Access-Control-Allow-Origin`.name))
|
||||
|
||||
val rejectionHandler = RejectionHandler.newBuilder()
|
||||
.handleNotFound {
|
||||
complete((StatusCodes.NotFound, "The requested resource could not be found."))
|
||||
}
|
||||
.handle {
|
||||
case corsRejection: org.apache.pekko.http.cors.scaladsl.CorsRejection =>
|
||||
complete((StatusCodes.Forbidden, "CORS rejection: Invalid origin"))
|
||||
}
|
||||
.result()
|
||||
|
||||
val apiRoutes = pathPrefix("api") {
|
||||
concat (
|
||||
path("geocode") {
|
||||
get {
|
||||
parameters("query".as[String]) { query =>
|
||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString)
|
||||
onSuccess(nominatim.geocodePhrase(encodedQuery)) { json =>
|
||||
complete(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
path("sponsors" / "github") {
|
||||
get {
|
||||
onSuccess(githubClient.getSponsors("frillweeman")) { json =>
|
||||
complete(json)
|
||||
}
|
||||
}
|
||||
},
|
||||
path("oauth2" / "callback") {
|
||||
get {
|
||||
parameters(Symbol("code").?) { (code) =>
|
||||
complete(HttpEntity(ContentTypes.`text/html(UTF-8)`, "<h1>Say hello to Pekko HTTP</h1><p><b>Code: " + code.getOrElse("None") + "</b></p>"))
|
||||
}
|
||||
}
|
||||
},
|
||||
path("healthcheck") {
|
||||
get {
|
||||
complete(HttpResponse(StatusCodes.OK, entity = "Service is healthy"))
|
||||
}
|
||||
head {
|
||||
complete(StatusCodes.OK)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val spaRoutes = pathEndOrSingleSlash {
|
||||
getFromFile("../webapp/dist/index.html")
|
||||
} ~ getFromDirectory("../webapp/dist") ~
|
||||
path(Remaining) { _ =>
|
||||
getFromFile("../webapp/dist/index.html")
|
||||
}
|
||||
|
||||
val routes = handleRejections(rejectionHandler) {
|
||||
cors(corsSettings) {
|
||||
concat(apiRoutes, spaRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
val bindingFuture = Http().newServerAt("0.0.0.0", 8080).bind(routes)
|
||||
|
||||
// Handle the binding future properly
|
||||
bindingFuture.foreach { binding =>
|
||||
println(s"Server online at http://localhost:${binding.localAddress.getPort}/")
|
||||
println("Press RETURN to stop...")
|
||||
}
|
||||
|
||||
StdIn.readLine()
|
||||
|
||||
bindingFuture
|
||||
.flatMap(_.unbind())
|
||||
.onComplete { _ =>
|
||||
println("Server shutting down...")
|
||||
system.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package services
|
||||
|
||||
import org.apache.pekko.actor.ActorSystem
|
||||
import org.apache.pekko.http.javadsl.model.headers.{Authorization, HttpCredentials, UserAgent}
|
||||
import org.apache.pekko.http.scaladsl.Http
|
||||
import org.apache.pekko.http.scaladsl.model.{HttpMethods, HttpRequest, StatusCodes}
|
||||
import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal
|
||||
import spray.json.JsValue
|
||||
import spray.json._
|
||||
import org.apache.pekko.http.scaladsl.model.ContentTypes
|
||||
import org.apache.pekko.http.scaladsl.model.HttpEntity
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
import scala.concurrent.{ExecutionContextExecutor, Future}
|
||||
|
||||
class GithubClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) {
|
||||
val logger = LoggerFactory.getLogger(getClass)
|
||||
val graphQLEndpoint = "https://api.github.com/graphql"
|
||||
private val githubApiToken = sys.env("GITHUB_TOKEN")
|
||||
|
||||
def getSponsors(username: String): Future[JsArray] = {
|
||||
|
||||
val query = s"""
|
||||
|query {
|
||||
| user(login: "$username") {
|
||||
| sponsorshipsAsMaintainer(first: 100) {
|
||||
| nodes {
|
||||
| sponsor {
|
||||
| login
|
||||
| name
|
||||
| avatarUrl
|
||||
| url
|
||||
| }
|
||||
| }
|
||||
| }
|
||||
| }
|
||||
|}
|
||||
|""".stripMargin.replace("\n", " ").replace("\"", "\\\"")
|
||||
|
||||
val jsonRequest = s"""{"query": "$query", "variables": ""}"""
|
||||
val jsonEntity = HttpEntity(ContentTypes.`application/json`, jsonRequest)
|
||||
val request = HttpRequest(
|
||||
headers = List(
|
||||
UserAgent.create("Shotgun"),
|
||||
Authorization.create(HttpCredentials.create("Bearer", githubApiToken))
|
||||
),
|
||||
method = HttpMethods.POST,
|
||||
uri = graphQLEndpoint,
|
||||
entity = jsonEntity
|
||||
)
|
||||
|
||||
Http().singleRequest(request).flatMap { response =>
|
||||
response.status match {
|
||||
case StatusCodes.OK =>
|
||||
Unmarshal(response.entity).to[String].map { jsonString =>
|
||||
jsonString.parseJson.asJsObject
|
||||
.fields("data").asJsObject
|
||||
.fields("user").asJsObject
|
||||
.fields("sponsorshipsAsMaintainer")
|
||||
.asJsObject.fields("nodes")
|
||||
.asInstanceOf[JsArray]
|
||||
}
|
||||
case _ =>
|
||||
response.discardEntityBytes()
|
||||
Future.failed(new Exception(s"Failed to get sponsors: ${response.status}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package services
|
||||
|
||||
import org.apache.pekko
|
||||
import org.apache.pekko.actor.ActorSystem
|
||||
import pekko.http.scaladsl.Http
|
||||
import pekko.http.scaladsl.model._
|
||||
import pekko.http.scaladsl.unmarshalling.Unmarshal
|
||||
import spray.json._
|
||||
|
||||
import scala.collection.mutable
|
||||
import scala.concurrent.{ExecutionContextExecutor, Future}
|
||||
|
||||
class NominatimClient(implicit val system: ActorSystem, implicit val executionContext: ExecutionContextExecutor) {
|
||||
val baseUrl = "https://nominatim.openstreetmap.org/search"
|
||||
private val cache: mutable.LinkedHashMap[String, JsValue] = new mutable.LinkedHashMap[String, JsValue]()
|
||||
private val maxCacheSize = 300
|
||||
|
||||
private def cleanUpCache(): Unit = {
|
||||
if (cache.size > maxCacheSize) {
|
||||
val oldest = cache.head
|
||||
cache.remove(oldest._1)
|
||||
}
|
||||
}
|
||||
|
||||
def geocodePhrase(query: String): Future[JsValue] = {
|
||||
cleanUpCache()
|
||||
cache.get(query) match {
|
||||
case Some(cachedResult) =>
|
||||
println(s"Cache hit for $query")
|
||||
Future.successful(cachedResult)
|
||||
case _ =>
|
||||
println(s"Cache miss for $query")
|
||||
val request = HttpRequest(
|
||||
uri = s"$baseUrl?q=$query&polygon_geojson=1&format=json",
|
||||
headers = List(headers.`User-Agent`("DeFlock/1.0"))
|
||||
)
|
||||
|
||||
Http().singleRequest(request).flatMap { response =>
|
||||
response.status match {
|
||||
case StatusCodes.OK =>
|
||||
Unmarshal(response.entity).to[String].map { jsonString =>
|
||||
val json = jsonString.parseJson
|
||||
cache.put(query, json)
|
||||
json
|
||||
}
|
||||
case _ =>
|
||||
response.discardEntityBytes()
|
||||
Future.failed(new Exception(s"Failed to geocode phrase: ${response.status}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package example
|
||||
|
||||
import org.scalatest._
|
||||
|
||||
class HelloSpec extends FlatSpec with Matchers {
|
||||
"The Hello object" should "say hello" in {
|
||||
Hello.greeting shouldEqual "hello"
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export class BoundingBox implements BoundingBoxLiteral {
|
||||
}
|
||||
|
||||
const apiService = axios.create({
|
||||
baseURL: window.location.hostname === "localhost" ? "http://localhost:8080/api" : "/api",
|
||||
baseURL: window.location.hostname === "localhost" ? "http://localhost:3000" : "https://api.deflock.org",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -82,49 +82,8 @@ export const getCities = async () => {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const geocodeQuery = async (query: string, currentLocation: any) => {
|
||||
export const geocodeQuery = async (query: string) => {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
const results = (await apiService.get(`/geocode?query=${encodedQuery}`)).data;
|
||||
|
||||
function findNearestResult(results: any, currentLocation: any) {
|
||||
let nearestResult = results[0];
|
||||
let nearestDistance = Number.MAX_VALUE;
|
||||
for (const result of results) {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(result.lat - currentLocation.lat, 2) +
|
||||
Math.pow(result.lon - currentLocation.lng, 2)
|
||||
);
|
||||
if (distance < nearestDistance) {
|
||||
nearestResult = result;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearestResult;
|
||||
}
|
||||
|
||||
if (!results.length) return null;
|
||||
|
||||
const cityStatePattern = /(.+),\s*(\w{2})/;
|
||||
const postalCodePattern = /\d{5}/;
|
||||
|
||||
if (cityStatePattern.test(query)) {
|
||||
console.debug("cityStatePattern");
|
||||
const cityStateResults = results.filter((result: any) =>
|
||||
["city", "town", "village", "hamlet", "suburb", "quarter", "neighbourhood", "borough"].includes(result.addresstype)
|
||||
);
|
||||
if (cityStateResults.length) {
|
||||
return findNearestResult(cityStateResults, currentLocation);
|
||||
}
|
||||
}
|
||||
|
||||
if (postalCodePattern.test(query)) {
|
||||
console.debug("postalCodePattern");
|
||||
const postalCodeResults = results.filter((result: any) => result.addresstype === "postcode");
|
||||
if (postalCodeResults.length) {
|
||||
return findNearestResult(postalCodeResults, currentLocation);
|
||||
}
|
||||
}
|
||||
|
||||
console.debug("defaultPattern");
|
||||
return findNearestResult(results, currentLocation);
|
||||
const result = (await apiService.get(`/geocode?query=${encodedQuery}`)).data;
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user