first commit

This commit is contained in:
creations 2025-05-27 18:36:08 -04:00
commit 81749d24d3
Signed by: creations
GPG key ID: 8F553AA4320FC711
17 changed files with 651 additions and 0 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
postgres-data
dragonfly-data

12
.editorconfig Normal file
View file

@ -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

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules
postgres-data
dragonfly-data
logs
bun.lock
.env

37
Dockerfile Normal file
View file

@ -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" ]

28
LICENSE Normal file
View file

@ -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.

78
README.md Normal file
View file

@ -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=<discord_user_id>`
Returns stored timezone and username for the given user ID.
### `GET /set?timezone=<iana_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)

48
biome.json Normal file
View file

@ -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"
}
}
}

48
compose.yml Normal file
View file

@ -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

49
config/index.ts Normal file
View file

@ -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 };

39
logger.json Normal file
View file

@ -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
}

22
package.json Normal file
View file

@ -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"
}
}

79
src/discord.ts Normal file
View file

@ -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<Response> {
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<DiscordUser | null> {
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;
}
}
}

151
src/index.ts Normal file
View file

@ -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 }));
},
});

29
tsconfig.json Normal file
View file

@ -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"]
}

17
types/discord.d.ts vendored Normal file
View file

@ -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;
};

5
types/index.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
type Environment = {
port: number;
host: string;
development: boolean;
};