From 70516441f206c5c3e0120b1eecd8031fa119ad10 Mon Sep 17 00:00:00 2001 From: Cfp Date: Fri, 20 Jun 2025 16:37:16 +0200 Subject: [PATCH] chore: init --- .gitignore | 34 +++++++++ README.md | 15 ++++ bun.lock | 178 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 21 ++++++ src/ai.ts | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/auth.ts | 15 ++++ src/db.ts | 5 ++ src/main.ts | 135 +++++++++++++++++++++++++++++++++++ tsconfig.json | 28 ++++++++ 9 files changed, 625 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/ai.ts create mode 100644 src/auth.ts create mode 100644 src/db.ts create mode 100644 src/main.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..470ce4c --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# librenav-server + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.2.10. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9e6f374 --- /dev/null +++ b/bun.lock @@ -0,0 +1,178 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "librenav-server", + "dependencies": { + "@ai-sdk/google": "^1.2.19", + "@types/pg": "^8.15.1", + "ai": "^4.3.16", + "better-auth": "^1.2.7", + "hono": "^4.7.9", + "hono-rate-limiter": "^0.4.2", + "pg": "^8.16.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@ai-sdk/google": ["@ai-sdk/google@1.2.19", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-Xgl6eftIRQ4srUdCzxM112JuewVMij5q4JLcNmHcB68Bxn9dpr3MVUSPlJwmameuiQuISIA8lMB+iRiRbFsaqA=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + + "@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="], + + "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + + "@better-auth/utils": ["@better-auth/utils@0.2.4", "", { "dependencies": { "typescript": "^5.8.2", "uncrypto": "^0.1.3" } }, "sha512-ayiX87Xd5sCHEplAdeMgwkA0FgnXsEZBgDn890XHHwSWNqqRZDYOq3uj2Ei2leTv1I2KbG5HHn60Ah1i2JWZjQ=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="], + + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.3.16", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-a1viIv3bIahXNssrOIkXZIlI2ePpZaNmR30d4aBL99mu2rO+mT9D6zBsp7H6eROWGtmwv0Ionp5olJurIo09dw=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.3.15", "", { "dependencies": { "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg=="], + + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.1.0", "", {}, "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.1.1", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8" } }, "sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA=="], + + "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], + + "@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="], + + "@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="], + + "@types/pg": ["@types/pg@8.15.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-YKHrkGWBX5+ivzvOQ66I0fdqsQTsvxqM0AGP2i0XrVZ9DP5VA/deEbTf7VuLPGpY7fJB9uGbkZ6KjVhuHcrTkQ=="], + + "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], + + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], + + "better-auth": ["better-auth@1.2.7", "", { "dependencies": { "@better-auth/utils": "0.2.4", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.8", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.27.6", "nanostores": "^0.11.3", "zod": "^3.24.1" } }, "sha512-2hCB263GSrgetsMUZw8vv9O1e4S4AlYJW3P4e8bX9u3Q3idv4u9BzDFCblpTLuL4YjYovghMCN0vurAsctXOAQ=="], + + "better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="], + + "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], + + "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + + "hono": ["hono@4.7.9", "", {}, "sha512-/EsCoR5h7N4yu01TDu9GMCCJa6ZLk5ZJIWFFGNawAXmd1Tp53+Wir4xm0D2X19bbykWUlzQG0+BvPAji6p9E8Q=="], + + "hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="], + + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], + + "kysely": ["kysely@0.27.6", "", {}, "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="], + + "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], + + "pg": ["pg@8.16.0", "", { "dependencies": { "pg-connection-string": "^2.9.0", "pg-pool": "^3.10.0", "pg-protocol": "^1.10.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg=="], + + "pg-cloudflare": ["pg-cloudflare@1.2.5", "", {}, "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg=="], + + "pg-connection-string": ["pg-connection-string@2.9.0", "", {}, "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], + + "pg-pool": ["pg-pool@3.10.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA=="], + + "pg-protocol": ["pg-protocol@1.10.0", "", {}, "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q=="], + + "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], + + "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], + + "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], + + "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="], + + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], + + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], + + "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..def6e29 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "librenav-server", + "module": "src/main.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@ai-sdk/google": "^1.2.19", + "@types/pg": "^8.15.1", + "ai": "^4.3.16", + "better-auth": "^1.2.7", + "hono": "^4.7.9", + "hono-rate-limiter": "^0.4.2", + "pg": "^8.16.0" + } +} diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..004fcda --- /dev/null +++ b/src/ai.ts @@ -0,0 +1,194 @@ +import { Hono, type Context } from "hono"; +import { streamText, tool } from "ai"; +import { google } from "@ai-sdk/google"; +import { stream } from "hono/streaming"; +import z from "zod"; + +const app = new Hono(); + +export type OverpassResult = { + elements: OverpassElement[]; +}; + +export type OverpassElement = { + type: "node" | "way" | "relation"; + id: number; + tags: Record; + lat?: number; // Only for nodes + lon?: number; // Only for nodes + nodes?: number[]; // Only for ways + center?: { + lat: number; // Only for relations + lon: number; // Only for relations + }; +}; + +const OVERPASS_SERVER = "https://overpass-api.de/api/interpreter"; + +export async function fetchPOI( + lat: number, + lon: number, + radius: number, +) { + return await fetch(OVERPASS_SERVER, { + method: "POST", + body: `[out:json]; +( + node(around:${radius}, ${lat}, ${lon})["amenity"]["name"]; + way(around:${radius}, ${lat}, ${lon})["amenity"]["name"]; + relation(around:${radius}, ${lat}, ${lon})["amenity"]["name"]; + node(around:${radius}, ${lat}, ${lon})["shop"]["name"]; + way(around:${radius}, ${lat}, ${lon})["shop"]["name"]; + relation(around:${radius}, ${lat}, ${lon})["shop"]["name"]; + node(around:${radius}, ${lat}, ${lon})["building"]["building"!="garage"]; + way(around:${radius}, ${lat}, ${lon})["building"]["building"!="garage"]; + node(around:${radius}, ${lat}, ${lon})["amenity"="parking"]; + way(around:${radius}, ${lat}, ${lon})["amenity"="parking"]; +); +out center tags;` + }).then(res => res.json() as Promise); +} + +function getDistance(aLat: number, aLon: number, lat: number, lon: number): number { + const R = 6371e3; // Earth radius in meters + const φ1 = lat * Math.PI / 180; + const φ2 = aLat * Math.PI / 180; + const Δφ = (aLat - lat) * Math.PI / 180; + const Δλ = (aLon - lon) * Math.PI / 180; + + const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +function sortByDistance(elements: OverpassElement[], lat: number, lng: number): OverpassElement[] { + return elements.sort((a: OverpassElement, b: OverpassElement) => { + const aLoc = a.center || a; + const bLoc = b.center || b; + return getDistance(aLoc.lat!, aLoc.lon!, lat, lng) - getDistance(bLoc.lat!, bLoc.lon!, lat, lng); + }); +} + +export async function post(c: Context) { + + const body = await c.req.json(); + const text = body.text ? body.text.trim() : ""; + const coords = body.coords; + let tags: Record | undefined = undefined; + if(coords && coords.lat && coords.lon) { + // fetch tags from OpenStreetMap using Overpass API + console.log("Fetching POI for coordinates:", coords.lat, coords.lon); + const res = await fetchPOI(coords.lat, coords.lon, 100); + const poi = sortByDistance(res.elements, coords.lat, coords.lon); + if(poi.length > 0) { + tags = poi[0]?.tags ?? {}; // Use the first element's tags + coords.lat = poi[0]?.lat ?? coords.lat; // Use the first element's lat if available + coords.lon = poi[0]?.lon ?? coords.lon; // Use the first element's lon if available + } + } + console.log("Received request with text:", text); + const prompt = JSON.stringify({ + coords, + tags, + text + }, null, 2) + console.log("Generated prompt:", prompt); + const result = streamText({ + onError: (error) => { + console.error("Error in AI response:", error); + }, + model: google("gemini-2.0-flash", { + // useSearchGrounding: true, + }), // key is in GOOGLE_GENERATIVE_AI_API_KEY env variable + system: `You are a guide for a user who is trying to find places to visit. +You may be given OSM tags of a place and your task is to describe the place in a way that is useful for the user. +If not, the user might provide you with a description of a place they are looking for. Fetch the tags of the place using overpass in that case. +Do not guess the tags of the place, always fetch them using Overpass API. Note that places might be a node, way or relation. You should handle all of them correctly (by not fetching for just nodes). +You might get questions at the end of the tags by the user for you to answer. +In that case, focus on the question only. Do not describe the place if you get a question. +If there is no question from the user, describe the place based on the tags provided. +Do not guess an answer if the tags do not provide enough information. Instead, fetch the website of the place to get more information. +If the user asks for something extremely unlikely this place would have, you can tell them that it is unlikely to have that feature. +Do not mention the tags to the user, just answer the question. +If the user asks in a language other than English, answer in that language. +Use the provided tools to query OpenStreetMap data if necessary/the user asks for information outside the provided place like asking what is around it. +For using the overpass tool, make sure to query for ways and relations as well, not just nodes. +DO NOT guess node, way or relation IDs when using the overpass tool, always use coordinates or names provided by the user. +IF THE USER DOES NOT PROVIDE COORDINATES, DO NOT GUESS THEM, INSTEAD USE THE PROVIDED TEXT TO QUERY OSM DATA. USE CITY NAMES FOR EXAMPLE. +Location of the place is given to help with querying OSM data. Note that there might be multiple ways to tag something you search for. +Example: amenity=kiosk and shop=kiosk are both valid ways to tag a kiosk. +DO NOT tell the user to use a mapping software or website, use the tools to query OSM data to answer the question. +If you would need to visit a website to answer the question, use the fetchWebsite tool to get the content of the website. + +When describing a place, skip explaining the following tags as they are already displayed to the user: opening_hours, website, phone, email, any address tags +Focus on all other tags like wheelchair, amenity, healthcare:speciality, cuisine, etc. + +When using tools, do not ask the user for confirmation, just use them directly. + +The local date and time is ${new Date().toLocaleString("de-DE", { timeZone: "Europe/Berlin" })}. The users language is German.`, + prompt, + maxSteps: 5, + tools: { + overpass: tool({ + description: "Query OpenStreetMap data using Overpass API with the given Overpass QL query.", + parameters: z.object({ + query: z.string().describe("The Overpass QL query to execute."), + }), + execute: async ({ query }) => { + console.log("Executing Overpass API query:", query); + const response = await fetch("https://overpass-api.de/api/interpreter", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: query, + }); + if (!response.ok) { + throw new Error(`Overpass API request failed: ${response.status} ${response.statusText}`); + } + const data = await response.text(); + return data; + } + }), + fetchWebsite: tool({ + description: "Fetch the raw HTML content of a website.", + parameters: z.object({ + url: z.string().describe("The full URL to fetch (from the OSM tags)"), + }), + execute: async ({ url }) => { + const res = await fetch(url, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (compatible; GeminiBot/1.0)", + }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch site: ${res.status} ${res.statusText}`); + } + + const text = await res.text(); + + function stripHTML(html: string): string { + // Remove script/style/head tags and their content + html = html.replace(/[\s\S]*?<\/script>/gi, ''); + html = html.replace(/[\s\S]*?<\/style>/gi, ''); + html = html.replace(/[\s\S]*?<\/head>/gi, ''); + + // Strip all remaining HTML tags + const text = html.replace(/<\/?[^>]+(>|$)/g, ''); + + return text.replace(/\s+/g, ' ').trim().slice(0, 4000); + } + + return stripHTML(text).slice(0, 5000); // avoid hitting token limit + }, + }) + } + }) + + // Mark the response as a v1 data stream: + c.header('X-Vercel-AI-Data-Stream', 'v1'); + c.header('Content-Type', 'text/plain; charset=utf-8'); + + return stream(c, stream => stream.pipe(result.toDataStream())); +} \ No newline at end of file diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..cdee6f6 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,15 @@ +import { betterAuth } from "better-auth"; +import { username } from "better-auth/plugins"; +import { pool } from "./db"; + +export const auth = betterAuth({ + database: pool, + emailAndPassword: { + enabled: true + }, + plugins: [ + username({ + minUsernameLength: 3 + }) + ] +}); \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..c8c5bd4 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,5 @@ +import { Pool } from "pg"; + +export const pool = new Pool({ + connectionString: process.env.DATABASE_URL +}) \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..60e0b8c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,135 @@ +import { Hono } from "hono"; +import { auth } from "./auth"; +import { cors } from "hono/cors"; +import { pool } from "./db"; +import { post } from "./ai"; +import { rateLimiter } from "hono-rate-limiter"; + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null + } +}>(); + +async function setupDB() { + await pool.query(` + CREATE TABLE IF NOT EXISTS reviews ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + latitude FLOAT NOT NULL, + longitude FLOAT NOT NULL, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES "user"(id) + ); + `); +} + +await setupDB(); + +app.use("*", async (c, next) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + + if (!session) { + c.set("user", null); + c.set("session", null); + return next(); + } + + c.set("user", session.user); + c.set("session", session.session); + return next(); +}); + +app.use( + "/api/*", // or replace with "*" to enable cors for all routes + cors({ + origin: "*", + allowHeaders: ["Content-Type", "Authorization"], + allowMethods: ["POST", "GET", "OPTIONS"], + exposeHeaders: ["Content-Length"], + maxAge: 600, + credentials: true, + }), +); + +app.get("/api/config", (c) => { + const capabilities: string[] = ["auth", "reviews"]; + + if(process.env.GOOGLE_GENERATIVE_AI_API_KEY) { + capabilities.push("ai"); + } + + return c.json({ + name: "TrafficCue Server", + version: "0", + capabilities + }) +}) + +app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw)); + +app.get("/api/reviews", async (c) => { + let {lat, lon} = c.req.query(); + if (!lat || !lon) { + return c.json({ error: "Latitude and longitude are required" }, 400); + } + // Remove unnecessary precision from lat/lon + lat = parseFloat(lat).toFixed(6); + lon = parseFloat(lon).toFixed(6); + console.log(`Fetching reviews for lat: ${lat}, lon: ${lon}`); + const res = await pool.query( + "SELECT * FROM reviews WHERE latitude = $1 AND longitude = $2", + [lat, lon], + ); + return c.json(await Promise.all(res.rows.map(async (row) => { + return { + id: row.id, + user_id: row.user_id, + rating: row.rating, + comment: row.comment, + created_at: row.created_at, + username: await pool.query( + "SELECT username FROM \"user\" WHERE id = $1", + [row.user_id], + ).then(res => res.rows[0]?.username || "Unknown"), + }; + }))); +}); + +app.post("/api/review", async (c) => { + const { rating, comment, lat, lon } = await c.req.json(); + if (!rating || !lat || !lon) { + return c.json({ error: "Rating, latitude, and longitude are required" }, 400); + } + + const user = c.get("user"); + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + const res = await pool.query( + "INSERT INTO reviews (user_id, latitude, longitude, rating, comment) VALUES ($1, $2, $3, $4, $5) RETURNING *", + [user.id, lat, lon, rating, comment], + ); + + return c.json(res.rows[0]); +}) + +if(process.env.GOOGLE_GENERATIVE_AI_API_KEY) { + app.use("/api/ai", rateLimiter({ + windowMs: 60 * 1000, // 1 minute + limit: 50, // 10 requests per minute + standardHeaders: "draft-6", + keyGenerator: (c) => "global" + })) + app.post("/api/ai", post); +} + +app.get("/", (c) => { + return c.text("TrafficCue Server"); +}) + +export default app \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9c62f74 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}