first commit
This commit is contained in:
commit
81749d24d3
17 changed files with 651 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
postgres-data
|
||||||
|
dragonfly-data
|
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
postgres-data
|
||||||
|
dragonfly-data
|
||||||
|
logs
|
||||||
|
bun.lock
|
||||||
|
.env
|
37
Dockerfile
Normal file
37
Dockerfile
Normal 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
28
LICENSE
Normal 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
78
README.md
Normal 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
48
biome.json
Normal 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
48
compose.yml
Normal 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
49
config/index.ts
Normal 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
39
logger.json
Normal 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
22
package.json
Normal 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
79
src/discord.ts
Normal 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
151
src/index.ts
Normal 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
29
tsconfig.json
Normal 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
17
types/discord.d.ts
vendored
Normal 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
5
types/index.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
type Environment = {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
development: boolean;
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue