From 81749d24d3accbc9bf9b1e4747a908c186d38405 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 27 May 2025 18:36:08 -0400 Subject: [PATCH] first commit --- .dockerignore | 2 + .editorconfig | 12 ++++ .gitattributes | 1 + .gitignore | 6 ++ Dockerfile | 37 +++++++++++ LICENSE | 28 +++++++++ README.md | 78 +++++++++++++++++++++++ biome.json | 48 ++++++++++++++ compose.yml | 48 ++++++++++++++ config/index.ts | 49 +++++++++++++++ logger.json | 39 ++++++++++++ package.json | 22 +++++++ src/discord.ts | 79 ++++++++++++++++++++++++ src/index.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 29 +++++++++ types/discord.d.ts | 17 +++++ types/index.d.ts | 5 ++ 17 files changed, 651 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 compose.yml create mode 100644 config/index.ts create mode 100644 logger.json create mode 100644 package.json create mode 100644 src/discord.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json create mode 100644 types/discord.d.ts create mode 100644 types/index.d.ts 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; +};