commit 81749d24d3accbc9bf9b1e4747a908c186d38405 Author: creations Date: Tue May 27 18:36:08 2025 -0400 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..19936a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +postgres-data +dragonfly-data diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..980ef21 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22bacac --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +postgres-data +dragonfly-data +logs +bun.lock +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b048099 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:latest AS base +WORKDIR /usr/src/app + +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lock /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/src ./src +COPY --from=prerelease /usr/src/app/package.json . +COPY --from=prerelease /usr/src/app/tsconfig.json . +COPY --from=prerelease /usr/src/app/config ./config +COPY --from=prerelease /usr/src/app/types ./types + +RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs + +USER bun +ENTRYPOINT [ "bun", "run", "start" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d93a942 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, creations.works + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8280e1 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# timezone-db + +A simple Bun-powered API service for managing and retrieving user timezones. It supports both local and Discord OAuth-authenticated timezone storage, backed by PostgreSQL and Redis. + +## Features + +- Store user timezones via `/set` endpoint (requires Discord OAuth) +- Retrieve timezones by user ID via `/get` +- Cookie-based session handling using Redis +- Built-in CORS support +- Dockerized with PostgreSQL and DragonflyDB + +## Requirements + +- [Bun](https://bun.sh/) +- Docker & Docker Compose +- `.env` file with required environment variables + +## Environment Variables + +Create a `.env` file with the following: + +``` +HOST=0.0.0.0 +PORT=3000 + +PGHOST=postgres +PGPORT=5432 +PGUSERNAME=postgres +PGPASSWORD=postgres +PGDATABASE=timezone + +REDIS_URL=redis://dragonfly:6379 + +CLIENT_ID=your_discord_client_id +CLIENT_SECRET=your_discord_client_secret +REDIRECT_URI=https://your.domain/auth/discord/callback +``` + +## Setup + +### Build and Run with Docker + +```bash +docker compose up --build +``` + +### Development Mode + +```bash +bun dev +``` + +## API Endpoints + +### `GET /get?id=` + +Returns stored timezone and username for the given user ID. + +### `GET /set?timezone=` + +Stores timezone for the authenticated user. Requires Discord OAuth session. + +### `GET /me` + +Returns Discord profile info for the current session. + +### `GET /auth/discord` + +Starts OAuth2 authentication flow. + +### `GET /auth/discord/callback` + +Handles OAuth2 redirect and sets a session cookie. + +## License + +[BSD-3-Clause](LICENSE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..c74f3df --- /dev/null +++ b/biome.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": true, + "ignore": ["dist", "dragonfly-data", "postgres-data"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error" + }, + "style": { + "useConst": "error", + "noVar": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "indentStyle": "tab", + "lineEnding": "lf", + "jsxQuoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c94ef05 --- /dev/null +++ b/compose.yml @@ -0,0 +1,48 @@ +services: + timezone-db: + container_name: timezoneDB + build: + context: . + restart: unless-stopped + ports: + - "${PORT:-3000}:${PORT:-3000}" + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + dragonfly: + condition: service_started + networks: + - timezoneDB-network + + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: ${PGUSERNAME:-postgres} + POSTGRES_PASSWORD: ${PGPASSWORD:-postgres} + POSTGRES_DB: ${PGDATABASE:-postgres} + volumes: + - ./postgres-data:/var/lib/postgresql/data + networks: + - timezoneDB-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + restart: unless-stopped + ulimits: + memlock: -1 + volumes: + - ./dragonfly-data:/data + networks: + - timezoneDB-network + +networks: + timezoneDB-network: + driver: bridge diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 0000000..a2bc6e4 --- /dev/null +++ b/config/index.ts @@ -0,0 +1,49 @@ +import { echo } from "@atums/echo"; + +const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), +}; + +const discordConfig = { + clientId: process.env.CLIENT_ID || "", + clientSecret: process.env.CLIENT_SECRET || "", + redirectUri: process.env.REDIRECT_URI || "", +}; + +function verifyRequiredVariables(): void { + const requiredVariables = [ + "HOST", + "PORT", + + "PGHOST", + "PGPORT", + "PGUSERNAME", + "PGPASSWORD", + "PGDATABASE", + + "REDIS_URL", + + "CLIENT_ID", + "CLIENT_SECRET", + "REDIRECT_URI", + ]; + + let hasError = false; + + for (const key of requiredVariables) { + const value = process.env[key]; + if (value === undefined || value.trim() === "") { + echo.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export { environment, discordConfig, verifyRequiredVariables }; diff --git a/logger.json b/logger.json new file mode 100644 index 0000000..65c0d32 --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "directory": "./logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "red", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1dcddcf --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "timezone-db", + "version": "1.0.0", + "description": "", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run --hot src/index.ts --dev", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --fix", + "cleanup": "rm -rf logs node_modules bun.lock" + }, + "license": "BSD-3-Clause", + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.2.14" + }, + "dependencies": { + "@atums/echo": "^1.0.3" + } +} diff --git a/src/discord.ts b/src/discord.ts new file mode 100644 index 0000000..2b45ba3 --- /dev/null +++ b/src/discord.ts @@ -0,0 +1,79 @@ +import { discordConfig } from "@config"; +import { randomUUIDv7, redis } from "bun"; + +export class DiscordAuth { + #clientId = discordConfig.clientId; + #clientSecret = discordConfig.clientSecret; + #redirectUri = discordConfig.redirectUri; + + startOAuthRedirect(): Response { + const query = new URLSearchParams({ + client_id: this.#clientId, + redirect_uri: this.#redirectUri, + response_type: "code", + scope: "identify", + }); + return Response.redirect( + `https://discord.com/oauth2/authorize?${query}`, + 302, + ); + } + + async handleCallback(req: Request): Promise { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + if (!code) return Response.json({ error: "Missing code" }, { status: 400 }); + + const tokenRes = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: this.#clientId, + client_secret: this.#clientSecret, + grant_type: "authorization_code", + code, + redirect_uri: this.#redirectUri, + }), + }); + + const tokenData: { access_token?: string } = await tokenRes.json(); + if (!tokenData.access_token) + return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const userRes = await fetch("https://discord.com/api/users/@me", { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + const user: DiscordUser = await userRes.json(); + + const sessionId = randomUUIDv7(); + await redis.set(sessionId, JSON.stringify(user), "EX", 3600); + + return Response.json( + { message: "Authenticated" }, + { + headers: { + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=3600`, + "Content-Type": "application/json", + }, + }, + ); + } + + async getUser(req: Request): Promise { + const cookie = req.headers.get("cookie"); + if (!cookie) return null; + + const match = cookie.match(/session=([^;]+)/); + if (!match) return null; + + const sessionId = match[1]; + const userData = await redis.get(sessionId); + if (!userData) return null; + + try { + return JSON.parse(userData); + } catch { + return null; + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7fdebdc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,151 @@ +import { DiscordAuth } from "@/discord"; +import { echo } from "@atums/echo"; +import { environment, verifyRequiredVariables } from "@config"; +import { serve, sql } from "bun"; + +verifyRequiredVariables(); + +try { + await sql`SELECT 1`; + await sql` + CREATE TABLE IF NOT EXISTS timezones ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + timezone TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `; + + echo.info( + `Connected to PostgreSQL on ${process.env.PGHOST}:${process.env.PGPORT}`, + ); +} catch (error) { + echo.error({ + message: "Could not establish a connection to PostgreSQL", + error, + }); + process.exit(1); +} + +try { + const url = new URL(process.env.REDIS_URL || "redis://localhost:6379"); + echo.info(`Connected to Redis on ${url.hostname}:${url.port || "6379"}`); +} catch (error) { + echo.error({ message: "Redis connection failed", error }); + process.exit(1); +} + +echo.info(`Listening on http://${environment.host}:${environment.port}`); + +const auth = new DiscordAuth(); + +function withCors(res: Response): Response { + const headers = new Headers(res.headers); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set( + "Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); + headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + headers.set("Access-Control-Allow-Credentials", "true"); + headers.set("Access-Control-Max-Age", "86400"); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); +} + +serve({ + port: environment.port, + fetch: async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", // 24 hours + "Access-Control-Allow-Credentials": "true", + }, + }); + } + + const url = new URL(req.url); + + if (url.pathname === "/auth/discord") return auth.startOAuthRedirect(); + if (url.pathname === "/auth/discord/callback") + return auth.handleCallback(req); + + if (url.pathname === "/set") { + const user = await auth.getUser(req); + if (!user) + return withCors( + Response.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const tz = url.searchParams.get("timezone"); + if (!tz) + return withCors( + Response.json( + { error: "Timezone parameter is required" }, + { status: 400 }, + ), + ); + + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + } catch { + return withCors( + Response.json({ error: "Invalid timezone" }, { status: 400 }), + ); + } + + await sql` + INSERT INTO timezones (user_id, username, timezone) + VALUES (${user.id}, ${user.username}, ${tz}) + ON CONFLICT (user_id) DO UPDATE + SET username = EXCLUDED.username, timezone = EXCLUDED.timezone + `; + + return withCors(Response.json({ success: true })); + } + + if (url.pathname === "/get") { + const id = url.searchParams.get("id"); + if (!id) + return withCors( + Response.json({ error: "Missing user ID" }, { status: 400 }), + ); + + const rows = await sql` + SELECT username, timezone FROM timezones WHERE user_id = ${id} + `; + + if (rows.length === 0) { + return withCors( + Response.json({ error: "User not found" }, { status: 404 }), + ); + } + + return withCors( + Response.json({ + user: { id, username: rows[0].username }, + timezone: rows[0].timezone, + }), + ); + } + + if (url.pathname === "/me") { + const user = await auth.getUser(req); + if (!user) + return withCors( + Response.json({ error: "Unauthorized" }, { status: 401 }), + ); + return withCors(Response.json(user)); + } + + return withCors(Response.json({ error: "Not Found" }, { status: 404 })); + }, +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f4366fa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "@config": ["config/index.ts"] + }, + "typeRoots": ["types", "./node_modules/@types"], + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": false, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src", "types", "config"] +} diff --git a/types/discord.d.ts b/types/discord.d.ts new file mode 100644 index 0000000..87982a3 --- /dev/null +++ b/types/discord.d.ts @@ -0,0 +1,17 @@ +type DiscordUser = { + id: string; + username: string; + discriminator: string; + avatar: string | null; + bot?: boolean; + system?: boolean; + mfa_enabled?: boolean; + banner?: string | null; + accent_color?: number | null; + locale?: string; + verified?: boolean; + email?: string | null; + flags?: number; + premium_type?: number; + public_flags?: number; +}; diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..57584ed --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,5 @@ +type Environment = { + port: number; + host: string; + development: boolean; +};