Compare commits

...

8 commits

54 changed files with 3482 additions and 3231 deletions

View file

@ -4,5 +4,8 @@
"node": true
},
"plugins": ["solid"],
"extends": ["eslint:recommended", "plugin:solid/typescript"]
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:solid/typescript"
]
}

27
.github/workflows/playwright.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm install -g pnpm && pnpm install
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run Playwright tests
run: pnpm exec playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

7
.gitignore vendored
View file

@ -1,4 +1,5 @@
src/drizzle/migrations
log
dist
.vinxi
@ -27,3 +28,9 @@ gitignore
# System Files
.DS_Store
Thumbs.db
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View file

@ -46,26 +46,24 @@ To get started with li'l Judd, follow the instructions below.
Create a `.env` file in the root directory and add the following variables:
```env
VITE_DISCORD_CLIENT=your_discord_client_id
VITE_DISCORD_CLIENT_ID=your_discord_client_id
VITE_DISCORD_CLIENT_SECRET=your_discord_client_secret
VITE_DISCORD_BOT_TOKEN=your_discord_bot_token
VITE_DISCORD_BOT_PERMISSIONS=18977581952080
VITE_DISCORD_OAUTH2_PERMISSIONS=18977581952080
VITE_AUTH_SECRET=your_auth_secret
VITE_DATABASE_URL=your_database_url
```
Recieve your discord applications client id & secret, as well as the bot token from the [Applications dashboard](https://discord.com/developers/applications/).
Recieve your discord applications `CLIENT_ID` & `CLIENT_SECRET`, as well as the bot token from the [Applications dashboard](https://discord.com/developers/applications/).
How to generate your `VITE_AUTH_SECRET` with [`openssl rand -base64 32`](https://authjs.dev/reference/core#secret).
Your `VITE_AUTH_REDIRECT_URL` should look like this: `https://<hostname>/api/auth/callback/discord`.
Composite your `VITE_DATABASE_URL` like [`postgres://postgres:adminadmin@0.0.0.0:5432/db`](https://orm.drizzle.team/docs/get-started-postgresql#postgresjs).
#### Development
Specify `VITE_AUTH_REDIRECT_PROXY_URL` only if necessary, particularly when setting up a reverse proxy to test authentication with callbacks to your development box. [Auth.js Docs Reference](https://authjs.dev/reference/nextjs/#redirectproxyurl)
The duplicate `DATABASE_URL` is only needed for Drizzle Studio.
```

5
app.config.ts Normal file
View file

@ -0,0 +1,5 @@
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({
middleware: "./src/middleware.ts",
});

View file

@ -1,25 +0,0 @@
GET https://discord.com/api/users/@me
Authorization: Bearer {{$dotenv DISCORD_ACCESS_TOKEN}}
###
GET https://discord.com/api/users/@me/guilds
Authorization: Bearer {{$dotenv DISCORD_ACCESS_TOKEN}}
###
GET https://discord.com/api/users/@me/guilds/{{$dotenv DISCORD_GUILD_ID}}/member
Authorization: Bearer {{$dotenv DISCORD_ACCESS_TOKEN}}
###
GET https://discord.com/api/guilds/{{$dotenv DISCORD_GUILD_ID}}
Authorization: Bot {{$dotenv DISCORD_BOT_TOKEN}}
###
POST https://discord.com/api/oauth2/token/revoke
Content-Type: application/x-www-form-urlencoded
Authorization: Basic {{$dotenv DISCORD_CLIENT_ID}}:{{$dotenv DISCORD_CLIENT_SECRET}}
token={{$dotenv DISCORD_ACCESS_TOKEN}}&token_type_hint=access_token

View file

@ -6,6 +6,6 @@ export default {
out: "./src/drizzle/migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL ?? "",
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;

270
e2e/auth.spec.ts Normal file
View file

@ -0,0 +1,270 @@
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { createId } from "@paralleldrive/cuid2";
import { expect, test, type BrowserContext, type Page } from "@playwright/test";
import "dotenv/config";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { Lucia, type Cookie } from "lucia";
import createClient from "openapi-fetch";
import postgres from "postgres";
import * as schema from "~/drizzle/schema";
import type * as discord from "~/types/discord";
import type * as liljudd from "~/types/liljudd";
const unencoded = `${process.env.DISCORD_CLIENT_ID}:${process.env.DISCORD_CLIENT_SECRET}`;
const encoded = btoa(unencoded);
const queryClient = postgres(process.env.DATABASE_URL!);
const db = drizzle(queryClient, {
schema,
});
const adapter = new DrizzlePostgreSQLAdapter(db, schema.sessions, schema.users);
export const lucia = new Lucia(adapter, {
getUserAttributes: (attributes) => attributes,
});
let context: BrowserContext;
let page: Page;
let sessionCookie: Cookie | undefined;
let userId = createId();
let guildId: bigint;
test.describe.serial("User auth process", () => {
test.beforeAll(() => {
expect(
[
"DISCORD_CLIENT_ID",
"DISCORD_CLIENT_SECRET",
"DATABASE_URL",
"DISCORD_BOT_TOKEN",
].filter((e) => typeof process.env[e] === "undefined").length,
{ message: "Please specify all env vars." },
).toBeFalsy();
});
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
});
test.beforeEach(async () => {
if (!sessionCookie) return;
const sameSiteProps = {
lax: "Lax",
strict: "Strict",
none: "None",
} as const;
const expires = sessionCookie.attributes.expires
? sessionCookie.attributes.expires.getTime() / 1000
: undefined;
const sameSite = sessionCookie.attributes.sameSite
? sameSiteProps[sessionCookie.attributes.sameSite]
: undefined;
await context.addCookies([
{
name: sessionCookie.name,
value: sessionCookie.value,
...sessionCookie.attributes,
sameSite,
expires,
secure: false,
domain: "localhost",
path: "/",
},
]);
});
test.afterAll("Delete DB entries", async () => {
await db.delete(schema.users).where(eq(schema.users.id, userId)).execute();
await db
.delete(schema.guilds)
.where(eq(schema.guilds.id, guildId))
.execute();
});
test.afterAll(async () => {
await context.close();
});
test("Landing page", async () => {
await page.goto("/");
await page.waitForLoadState("load");
expect(await page.screenshot()).toMatchSnapshot("landing_page.png");
});
test("Unauthorized Access Redirect Test", async () => {
await page.goto("/config");
await page.waitForURL("/");
});
test("Generate auth session for further tests", async ({ browser }) => {
const { GET } = createClient<discord.paths>({
baseUrl: "https://discord.com/api/v10",
});
const discordUserResponse = await GET("/users/@me", {
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`,
},
});
if (discordUserResponse.error) throw discordUserResponse.error;
const discordUser = discordUserResponse.data;
const browserName = browser.browserType().name() as
| "chromium"
| "webkit"
| "firefox";
userId = discordUser.id + userId.slice(discordUser.id.length);
userId = userId.slice(0, -browserName.length) + browserName;
enum BrowserIds {
chromium,
webkit,
firefox,
}
guildId = BigInt(discordUser.id) ^ BigInt(BrowserIds[browserName]);
await db.insert(schema.users).values({
id: userId,
discord_id: discordUser.id,
name: discordUser.global_name,
image: discordUser.avatar,
});
const session = await lucia.createSession(
userId,
{},
{ sessionId: createId() },
);
sessionCookie = lucia.createSessionCookie(session.id);
await db
.insert(schema.discordTokens)
.values({
userId,
accessToken: "tokens.accessToken",
expiresAt: sessionCookie.attributes.expires ?? new Date(),
refreshToken: "tokens.refreshToken",
})
.returning()
.execute();
});
test("Landing page when logged in", async () => {
await page.goto("/");
await page.waitForLoadState("load");
expect(await page.screenshot()).toMatchSnapshot(
"landing_page_logged_in.png",
);
});
test("Test Api", async () => {
const { GET, POST, PUT } = createClient<liljudd.paths>({
baseUrl: "http://localhost:3000/",
});
const createConfigResponse = await POST("/api/{guildId}/config", {
params: {
path: {
guildId: guildId.toString(),
},
},
headers: {
Authorization: `Basic ${encoded}`,
Origin: "http://localhost:3000",
},
});
if (createConfigResponse.error)
throw new Error(createConfigResponse.error.error);
let getConfigResponse = await GET("/api/{guildId}/config", {
params: {
path: {
guildId: guildId.toString(),
},
},
headers: {
Authorization: `Basic ${encoded}`,
Origin: "http://localhost:3000",
},
});
if (getConfigResponse.error) throw new Error(getConfigResponse.error.error);
switch (getConfigResponse.data?.checksum) {
case "209a644c31a5ef123c432c2885d231a2e0efc4de": // chromium
case "aead21e132a94ab897ec28e0f0c337a66207bad3": // webkit
case "c3e2ff2ce5a8936234552125a54c2fe1ce1a35da": // firefox
break;
default:
throw new Error(
"Before guild GET checksum didn't matched known ones: " +
getConfigResponse.data?.checksum,
);
}
const putTimePlanningResponse = await PUT("/api/{guildId}/timePlanning", {
body: {
enabled: true,
channelId: "1234567890123456789",
rolesEnabled: true,
isAvailableRoleId: "1234567890123456789",
wantsToBeNotifieRoledId: "1234567890123456789",
messageIds: {
"0": "1234567890123456789",
"1": "1234567890123456789",
"2": "1234567890123456789",
"3": "1234567890123456789",
"4": "1234567890123456789",
"5": "1234567890123456789",
"6": "1234567890123456789",
},
},
params: {
path: {
guildId: guildId.toString(),
},
},
headers: {
Authorization: `Basic ${encoded}`,
Origin: "http://localhost:3000",
},
});
if (putTimePlanningResponse.error)
throw new Error(putTimePlanningResponse.error.error);
getConfigResponse = await GET("/api/{guildId}/config", {
params: {
path: {
guildId: guildId.toString(),
},
},
headers: {
Authorization: `Basic ${encoded}`,
Origin: "http://localhost:3000",
},
});
if (getConfigResponse.error) throw new Error(getConfigResponse.error.error);
switch (getConfigResponse.data?.checksum) {
case "681c8324b21096255d942bb78bd6655da90d352e": // chromium
case "a2fb3601b7d0949b1ceada3b3ac0ba408c6159bb": // webkit
case "bf20daba95e8f3ddd17cc64e8a7ba184b68ad37b": // firefox
break;
default:
throw new Error(
"After guild GET checksum didn't matched known ones: " +
getConfigResponse.data?.checksum,
);
}
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

View file

@ -10,12 +10,11 @@
"discord-openapi-gen": "openapi-typescript https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json -o ./src/types/discord.d.ts",
"liljudd-openapi-gen": "openapi-typescript ./public/api/specs/liljudd.json -o ./src/types/liljudd.d.ts",
"typecheck": "tsc --noEmit --checkJs false --skipLibCheck --preserveSymLinks",
"drizzle-studio": "drizzle-kit studio"
"drizzle-studio": "drizzle-kit studio",
"test": "pnpm exec playwright test",
"test-ui": "pnpm exec playwright test --ui"
},
"dependencies": {
"@auth/core": "^0.19.0",
"@auth/drizzle-adapter": "^0.6.3",
"@auth/solid-start": "0.1.2",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/pro-duotone-svg-icons": "^6.5.1",
"@fortawesome/pro-light-svg-icons": "^6.5.1",
@ -23,29 +22,42 @@
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
"@fortawesome/pro-thin-svg-icons": "^6.5.1",
"@fortawesome/sharp-solid-svg-icons": "^6.5.1",
"@lucia-auth/adapter-drizzle": "^1.0.2",
"@paralleldrive/cuid2": "^2.2.2",
"@solidjs/meta": "^0.29.3",
"@solidjs/router": "^0.12.0",
"@solidjs/start": "^0.5.4",
"drizzle-orm": "^0.29.3",
"@solidjs/router": "^0.12.4",
"@solidjs/start": "^0.6.0",
"arctic": "^1.2.1",
"colors": "^1.4.0",
"drizzle-orm": "^0.29.4",
"http-status": "^1.7.4",
"json-stable-stringify": "^1.1.1",
"lucia": "^3.0.1",
"moment-timezone": "^0.5.45",
"openapi-fetch": "^0.8.2",
"object-hash": "^3.0.0",
"openapi-fetch": "^0.9.2",
"postgres": "^3.4.3",
"solid-js": "^1.8.14",
"vinxi": "^0.2.1"
"solid-js": "^1.8.15",
"vinxi": "^0.3.4"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.21.0",
"dotenv": "^16.4.2",
"@playwright/test": "^1.42.0",
"@types/json-stable-stringify": "^1.0.36",
"@types/node": "^20.11.22",
"@types/object-hash": "^3.0.6",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.20.14",
"drizzle-zod": "^0.5.1",
"eslint": "^8.56.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-solid": "^0.13.1",
"h3": "^1.11.1",
"openapi-typescript": "^6.7.4",
"pg": "^8.11.3",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"sass": "^1.70.0",
"sass": "^1.71.1",
"typescript": "^5.3.3",
"zod": "3.22.4"
},

77
playwright.config.ts Normal file
View file

@ -0,0 +1,77 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "pnpm start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

File diff suppressed because it is too large Load diff

View file

@ -10,40 +10,67 @@
"version": "0.0.0"
},
"paths": {
"/api/boot/config": {
"/api/boot": {
"get": {
"tags": ["Guild configs"],
"summary": "Find a guild's config by ID",
"description": "Returns a single guild's config.",
"operationId": "getGuildsFromBoot",
"tags": ["Bot bootup"],
"summary": "Retrieve all guild's configs",
"description": "Returns all guild's configs.",
"operationId": "getGuildsForBoot",
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bootConfig"
"type": "array",
"items": {
"$ref": "#/components/schemas/guildConfig"
}
}
}
}
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
}
},
"/api/{guildId}/config": {
"get": {
"tags": ["Guild configs"],
"tags": ["Guild config"],
"summary": "Find a guild's config by ID",
"description": "Returns a single guild's config.",
"operationId": "getGuildById",
@ -71,28 +98,52 @@
}
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
},
"delete": {
"tags": ["Guild configs"],
"summary": "Deletes a guild's config by ID",
"description": "Delete a guild's config when the bot is removed from the guild.",
"operationId": "deleteGuildById",
"post": {
"tags": ["Guild config"],
"summary": "Creates a guild's config by ID",
"description": "Create a guild's config when the bot is has joined a new guild.",
"operationId": "postGuildById",
"parameters": [
{
"name": "guildId",
"in": "path",
"description": "ID of guild config to delete",
"description": "ID of guild's config to create",
"required": true,
"schema": {
"type": "string",
@ -105,30 +156,112 @@
"description": "successful operation"
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
}
},
"/api/{guildId}/tp_messages": {
"get": {
"tags": ["Time planning messages"],
"summary": "Find the tp_messages of guild by ID",
"description": "Returns tp_messages for a guild",
"operationId": "getTp_messagesOfGuildById",
"delete": {
"tags": ["Guild config"],
"summary": "Deletes a guild's config by ID",
"description": "Delete a guild's config when the bot is removed from the guild.",
"operationId": "deleteGuildById",
"parameters": [
{
"name": "guildId",
"in": "path",
"description": "ID of guild's tp_messages to return",
"description": "ID of guild's config to delete",
"required": true,
"schema": {
"type": "string",
"format": "varchar(20)"
}
}
],
"responses": {
"204": {
"description": "successful operation"
},
"400": {
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"basicAuth": []
}
]
}
},
"/api/{guildId}/timePlanning": {
"get": {
"tags": ["Time planning messages"],
"summary": "Find the timePlanning of guild by ID",
"description": "Returns timePlanning for a guild",
"operationId": "gettimePlanningOfGuildById",
"parameters": [
{
"name": "guildId",
"in": "path",
"description": "ID of guild's timePlanning to return",
"required": true,
"schema": {
"type": "string",
@ -142,37 +275,58 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/tp_messages"
"$ref": "#/components/schemas/timePlanning"
}
}
}
},
"204": {
"description": "Time planning not enabled for this guild"
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
},
"put": {
"tags": ["Time planning messages"],
"summary": "Put message IDs for tp_messages of guild by ID",
"description": "Returns tp_messages for a guild",
"operationId": "putTp_messagesOfGuildById",
"summary": "Put new message IDs for timePlanning of guild by ID",
"description": "Returns timePlanning for a guild",
"operationId": "puttimePlanningOfGuildById",
"parameters": [
{
"name": "guildId",
"in": "path",
"description": "ID of guild's tp_messages to return",
"description": "ID of guild's timePlanning to return",
"required": true,
"schema": {
"type": "string",
@ -180,30 +334,55 @@
}
}
],
"responses": {
"200": {
"description": "successful operation",
"requestBody": {
"description": "Put new message IDs for timePlanning in channel",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/tp_messages"
}
"$ref": "#/components/schemas/timePlanning"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Time planning not enabled for this guild"
"description": "successful operation"
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
}
@ -212,13 +391,13 @@
"get": {
"tags": ["Matches"],
"summary": "Find all matches of guild by ID",
"description": "Returns tp_messages for a guild",
"description": "Returns timePlanning for a guild",
"operationId": "getMatchesOfGuildById",
"parameters": [
{
"name": "guildId",
"in": "path",
"description": "ID of guild's tp_messages to return",
"description": "ID of guild's timePlanning to return",
"required": true,
"schema": {
"type": "string",
@ -232,36 +411,66 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["matches", "timezone"],
"properties": {
"matches": {
"type": "array",
"items": {
"$ref": "#/components/schemas/tp_messages"
}
}
}
"$ref": "#/components/schemas/match"
}
},
"204": {
"description": "Time planning not enabled for this guild"
"timezone": {
"type": "string",
"format": "text",
"example": "Europe/Berlin"
}
}
}
}
}
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
}
},
"/api/{guildId}/matches/{channelId}": {
"post": {
"tags": ["Matches"],
"summary": "Save a new created match in channel of guild by IDs",
"description": "Returns tp_messages for a guild",
"summary": "Save a new created match of guild by ID",
"description": "Returns timePlanning for a guild",
"operationId": "postMatchOfGuildById",
"parameters": [
{
@ -273,108 +482,69 @@
"type": "string",
"format": "varchar(20)"
}
},
{
"name": "channelId",
"in": "path",
"description": "ID of match's channel to set",
"required": true,
"schema": {
"type": "string",
"format": "varchar(20)"
}
}
],
"responses": {
"200": {
"description": "successful operation",
"requestBody": {
"description": "Save a new created match in channel",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/tp_messages"
"type": "object",
"required": ["match", "timezone"],
"properties": {
"match": {
"$ref": "#/components/schemas/match"
},
"timezone": {
"type": "string",
"format": "text",
"example": "Europe/Berlin",
"description": "Has to match guild tz"
}
}
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Time planning not enabled for this guild"
"description": "successful operation"
},
"400": {
"description": "Invalid ID supplied"
"description": "Invalid ID supplied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
},
"404": {
"description": "Guild not found"
"description": "Guild not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error"
}
}
}
}
},
"security": [
{
"api_key": []
}
]
}
},
"/api/{guildId}/matches/{channelId}/{matchMessageId}": {
"put": {
"tags": ["Matches"],
"summary": "Set state for match of guild by IDs",
"description": "Returns tp_messages for a guild",
"operationId": "putMatchOfGuildById",
"parameters": [
{
"name": "guildId",
"in": "path",
"description": "ID of guild's tp_messages to return",
"required": true,
"schema": {
"type": "string",
"format": "varchar(20)"
}
},
{
"name": "channelId",
"in": "path",
"description": "ID of match's channel to set",
"required": true,
"schema": {
"type": "string",
"format": "varchar(20)"
}
},
{
"name": "matchMessageId",
"in": "path",
"description": "ID of match's message Id to set",
"required": true,
"schema": {
"type": "string",
"format": "varchar(20)"
}
}
],
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/tp_messages"
}
}
}
},
"204": {
"description": "Time planning not enabled for this guild"
},
"400": {
"description": "Invalid ID supplied"
},
"404": {
"description": "Guild not found"
}
},
"security": [
{
"api_key": []
"basicAuth": []
}
]
}
@ -382,27 +552,12 @@
},
"components": {
"schemas": {
"bootConfig": {
"type": "object",
"properties": {
"guilds": {
"type": "array",
"items": {
"$ref": "#/components/schemas/guildConfig"
}
},
"accessToken": {
"type": "string"
}
}
},
"guildConfig": {
"type": "object",
"required": ["guildId", "timezone", "features", "matches", "checksum"],
"properties": {
"guildID": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
"guildId": {
"$ref": "#/components/schemas/id"
},
"timezone": {
"type": "string",
@ -411,14 +566,24 @@
},
"features": {
"type": "object",
"required": ["timePlanning"],
"properties": {
"time_planning": {
"timePlanning": {
"type": "object",
"required": [
"enabled",
"channelId",
"targetMinute",
"targetHour",
"targetDay",
"roles"
],
"properties": {
"channelID": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
"enabled": {
"type": "boolean"
},
"channelId": {
"$ref": "#/components/schemas/idOrNull"
},
"targetMinute": {
"type": "number",
@ -434,21 +599,20 @@
},
"roles": {
"type": "object",
"required": [
"enabled",
"isAvailableRoleId",
"wantsToBeNotifieRoledId"
],
"properties": {
"enabled": {
"type": "boolean"
},
"isAvailableRoleId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789",
"nullable": true
"$ref": "#/components/schemas/idOrNull"
},
"wantsToBeNotifieRoledId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789",
"nullable": true
"$ref": "#/components/schemas/idOrNull"
}
}
}
@ -461,85 +625,132 @@
"items": {
"$ref": "#/components/schemas/match"
}
},
"checksum": {
"type": "string"
}
}
},
"match": {
"type": "object",
"required": [
"channelId",
"matchType",
"createrId",
"roleId",
"opponentName",
"messageId",
"utc_ts"
],
"properties": {
"channelID": {
"type": "string",
"format": "varcharq(20)",
"example": "1234567890123456789"
"channelId": {
"$ref": "#/components/schemas/id"
},
"createrId": {
"$ref": "#/components/schemas/id"
},
"roleId": {
"$ref": "#/components/schemas/id"
},
"messageId": {
"$ref": "#/components/schemas/id"
},
"matchType": {
"type": "string",
"format": "varchar(50)",
"example": "Scrim"
},
"createrId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
},
"roleId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
},
"opponentName": {
"type": "string",
"format": "varchar(100)",
"example": "?"
},
"messsageId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
},
"utc_ts": {
"type": "string",
"example": "1706180188"
"example": "2020-01-01T00:00:00Z"
}
}
},
"tp_messages": {
"timePlanning": {
"type": "object",
"required": [
"enabled",
"channelId",
"rolesEnabled",
"isAvailableRoleId",
"wantsToBeNotifieRoledId",
"messageIds"
],
"properties": {
"guildId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
"enabled": {
"type": "boolean"
},
"channelId": {
"type": "string",
"format": "varchar(20)",
"example": "1234567890123456789"
"$ref": "#/components/schemas/idOrNull"
},
"rolesEnabled": {
"type": "boolean"
},
"isAvailableRoleId": {
"$ref": "#/components/schemas/idOrNull"
},
"wantsToBeNotifieRoledId": {
"$ref": "#/components/schemas/idOrNull"
},
"messageIds": {
"type": "array",
"items": {
"type": "string",
"format": "varchar(20)"
"type": "object",
"required": ["0", "1", "2", "3", "4", "5", "6"],
"properties": {
"0": {
"$ref": "#/components/schemas/idOrNull"
},
"example": [
"1234567890123456789",
"1234567890123456789",
"1234567890123456789",
"1234567890123456789",
"1234567890123456789",
"1234567890123456789",
"1234567890123456789"
]
"1": {
"$ref": "#/components/schemas/idOrNull"
},
"2": {
"$ref": "#/components/schemas/idOrNull"
},
"3": {
"$ref": "#/components/schemas/idOrNull"
},
"4": {
"$ref": "#/components/schemas/idOrNull"
},
"5": {
"$ref": "#/components/schemas/idOrNull"
},
"6": {
"$ref": "#/components/schemas/idOrNull"
}
}
}
}
},
"id": {
"type": "string",
"pattern": "^\\d{7,20}$",
"example": "1234567890123456789"
},
"idOrNull": {
"type": "string",
"pattern": "^\\d{7,20}$",
"example": "1234567890123456789",
"nullable": true
},
"error": {
"type": "object",
"required": "error",
"properties": {
"error": {
"type": "string"
}
}
}
},
"securitySchemes": {
"api_key": {
"type": "apiKey",
"name": "api_key",
"in": "header"
"basicAuth": {
"type": "http",
"scheme": "basic"
}
}
}

View file

@ -1,32 +0,0 @@
{
"data": {
"guilds": [
{
"guildID": "some ID",
"UTCOffset": 0,
"features": {
"time_planning": {
"channelID": "some ID",
"targetWeekday": 0,
"targetHour": 0,
"targetMinute": 0,
"isAvailableRoleId": "some ID",
"wantsToBeNotifieRoledId": "some ID"
}
},
"matches": [
{
"channelID": "some ID",
"matchType": "",
"createrId": "some ID",
"roleId": "some ID",
"opponentName": "",
"messsageId": "",
"plannedFor": 1704314625000
}
]
}
]
},
"accessToken": "some Token"
}

View file

@ -1,22 +1,24 @@
import { faCirclePlus } from "@fortawesome/pro-regular-svg-icons";
import { JSX, Show, Suspense } from "solid-js";
import { JSX, Show } from "solid-js";
import "../styles/components/NavBar.scss";
import { FontAwesomeIcon } from "./FontAwesomeIcon";
import NavUser from "./NavUser";
if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
throw new Error("No env VITE_DISCORD_CLIENT_ID found!");
if (typeof import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS === "undefined")
throw new Error("No env VITE_DISCORD_OAUTH2_PERMISSIONS found!");
export function Li(props: {
href: string;
action?: () => void;
rel?: string;
name?: string;
children?: JSX.Element;
}) {
return (
<li class="navElem flex-row thick">
<a
class="flex-row"
href={props.href}
onClick={() => props.action && props.action()}
>
<a class="flex-row" href={props.href} rel={props.rel}>
{props.children ?? <></>}
<Show when={props.name}>
<span>{props.name}</span>
@ -40,14 +42,12 @@ function NavBar() {
</ul>
<ul class="flex-row responsive thick">
<Li
href={`https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_BOT_PERMISSIONS}&scope=bot`}
href={`https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS}&scope=bot`}
name="Invite to your server"
>
<FontAwesomeIcon class="lower" icon={faCirclePlus} size="xl" />
</Li>
<Suspense>
<NavUser />
</Suspense>
</ul>
</nav>
);

View file

@ -1,70 +1,40 @@
import { getSession } from "@auth/solid-start";
import { signIn, signOut } from "@auth/solid-start/client";
import {
faArrowRightFromBracket,
faArrowRightToBracket,
faGear,
} from "@fortawesome/pro-regular-svg-icons";
import { eq } from "drizzle-orm";
import { Show, createResource } from "solid-js";
import { cache, createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { getRequestEvent } from "solid-js/web";
import db from "~/drizzle";
import { users } from "~/drizzle/schema";
import { authOptions } from "~/server/auth";
import { FontAwesomeIcon } from "./FontAwesomeIcon";
import { Li } from "./NavBar";
const initialUser = {
id: "",
name: null as string | null,
email: "",
emailVerified: null as Date | null,
image: null as string | null,
};
async function getUser() {
"use server";
const event = getRequestEvent();
if (!event)
return { success: false, message: "No request event!", ...initialUser };
const session = await getSession(event.request, authOptions);
if (!session?.user?.id)
return { success: false, message: "No user with id!", ...initialUser };
const user = (
await db
.selectDistinct()
.from(users)
.where(eq(users.id, session.user?.id))
.limit(1)
.execute()
)[0];
console.log("userInfo", "success");
return { success: true, message: "", ...user };
return event?.nativeEvent.context.user;
}
const cachedUser = cache(() => getUser(), "userInfo");
function NavUser() {
const [user] = createResource(async () => {
const user = await getUser();
const user = createAsync(() => cachedUser());
const pfp = () => {
const thisUser = user();
if (!thisUser?.id) return "";
if (!user.success) console.error("userInfo", user.message);
return user;
});
return thisUser.image
? `https://cdn.discordapp.com/avatars/${thisUser.discord_id}/${thisUser.image}.png`
: `https://cdn.discordapp.com/embed/avatars/${(parseInt(thisUser.discord_id) >> 22) % 6}.png`;
};
return (
<Show
when={user()?.id}
fallback={
<Li
href="#"
name="Login"
action={() => signIn("discord", { callbackUrl: "/config" })}
>
<Li href="/api/auth/login" name="Login" rel="external">
<FontAwesomeIcon
class="secondary"
icon={faArrowRightToBracket}
@ -75,11 +45,11 @@ function NavUser() {
>
<Li href="/config">
<div class="swap lower">
<img class="primary" src={user()?.image ?? ""} alt="User pfp" />
<img class="primary" src={pfp()} alt="User pfp" />
<FontAwesomeIcon class="secondary" icon={faGear} size="xl" />
</div>
</Li>
<Li href="#" action={() => signOut({ callbackUrl: "/" })}>
<Li href="/api/auth/logout" rel="external">
<FontAwesomeIcon icon={faArrowRightFromBracket} size="xl" />
</Li>
</Show>

View file

@ -2,7 +2,10 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const queryClient = postgres(import.meta.env.VITE_DATABASE_URL ?? "");
if (typeof import.meta.env.VITE_DATABASE_URL === "undefined")
throw new Error("No env VITE_DATABASE_URL found!");
const queryClient = postgres(import.meta.env.VITE_DATABASE_URL);
const db = drizzle(queryClient, {
schema,
});

View file

@ -1,8 +1,7 @@
import type { AdapterAccount } from "@auth/core/adapters";
import { relations } from "drizzle-orm";
import {
bigint,
boolean,
integer,
pgTable,
primaryKey,
serial,
@ -13,130 +12,90 @@ import {
} from "drizzle-orm/pg-core";
export const users = pgTable("user", {
id: text("id").notNull().primaryKey(),
id: varchar("id", { length: 24 }).primaryKey(),
discord_id: text("discord_id").notNull(),
name: text("name"),
email: text("email").notNull(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
}),
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: text("userId")
id: varchar("id", { length: 24 }).primaryKey(),
userId: varchar("user_id", { length: 24 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
expiresAt: timestamp("expires_at", {
withTimezone: true,
mode: "date",
}).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
export const discordTokens = pgTable("tokens", {
userId: varchar("user_id", { length: 24 })
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
refreshToken: text("refresh_token").notNull(),
accessToken: text("access_token").notNull(),
expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
});
export const guilds = pgTable("guilds", {
id: bigint("id", { mode: "bigint" }).primaryKey(),
timezone: text("timezone").notNull().default("Etc/UTC"),
tpEnabled: boolean("tp_enabled").notNull().default(false),
tpChannelId: bigint("tp_channel_id", { mode: "bigint" }),
tpInterval: smallint("target_interval").notNull().default(64),
tpRolesEnabled: boolean("tp_roles_enabled").notNull().default(false),
isAvailableRoleId: bigint("is_available_role_id", { mode: "bigint" }),
wantsToBeNotifieRoledId: bigint("wants_to_be_notified_role_id", {
mode: "bigint",
}),
});
export const guildsRelations = relations(guilds, ({ many }) => ({
tpMessages: many(tpMessages),
matches: many(matches),
}));
export const tpMessages = pgTable(
"tp_messages",
{
messageId: bigint("message_id", { mode: "bigint" }),
day: smallint("day").notNull(),
guildId: bigint("guild_id", { mode: "bigint" })
.notNull()
.references(() => guilds.id, { onDelete: "cascade" }),
},
(table) => {
return {
pk: primaryKey({ columns: [table.guildId, table.day] }),
};
},
);
export const matchPlannings = pgTable("match_planning", {
export const tpMessagesRelations = relations(tpMessages, ({ one }) => ({
guild: one(guilds, {
fields: [tpMessages.guildId],
references: [guilds.id],
}),
}));
export const matches = pgTable("matches", {
id: serial("id").primaryKey(),
channelId: varchar("channel_id", { length: 20 }).notNull(),
matchtype: varchar("match_type", { length: 50 }).notNull(),
createrId: varchar("creater_id", { length: 20 }).notNull(),
roleId: varchar("role_id", { length: 20 }).notNull(),
channelId: bigint("channel_id", { mode: "bigint" }).notNull(),
matchType: varchar("match_type", { length: 50 }).notNull(),
createrId: bigint("creater_id", { mode: "bigint" }).notNull(),
roleId: bigint("role_id", { mode: "bigint" }).notNull(),
opponentName: varchar("opponent_name", { length: 100 }).notNull(),
messageId: varchar("message_id", { length: 20 }).notNull(),
messageId: bigint("message_id", { mode: "bigint" }).notNull(),
utc_ts: timestamp("utc_ts").notNull(),
guildId: varchar("guild_id", { length: 20 })
guildId: bigint("guild_id", { mode: "bigint" })
.notNull()
.references(() => guilds.id, { onDelete: "cascade" }),
});
export const matchPlanningsRelations = relations(matchPlannings, ({ one }) => ({
export const matchPlanningsRelations = relations(matches, ({ one }) => ({
guild: one(guilds, {
fields: [matchPlannings.guildId],
fields: [matches.guildId],
references: [guilds.id],
}),
}));
export const guilds = pgTable("guild", {
id: varchar("id", { length: 20 }).primaryKey(),
timezone: text("timezone").notNull(),
});
export const guildsRelations = relations(guilds, ({ one, many }) => ({
matches: many(matchPlannings),
timePlanning: one(timePlannings, {
fields: [guilds.id],
references: [timePlannings.guildId],
}),
}));
export const timePlannings = pgTable("time_planning", {
id: serial("id").primaryKey(),
guildId: varchar("guild_id", { length: 20 })
.notNull()
.unique()
.references(() => guilds.id, {
onDelete: "cascade",
}),
channelId: varchar("channel_id", { length: 20 }).notNull(),
target_interval: smallint("target_interval").notNull(),
roles: boolean("roles").notNull(),
isAvailableRoleId: varchar("is_available_role_id", { length: 20 }),
wantsToBeNotifieRoledId: varchar("wants_to_be_notified_role_id", {
length: 20,
}),
});
export const timePlanningsRelations = relations(
timePlannings,
({ one, many }) => ({
guild: one(guilds, {
fields: [timePlannings.guildId],
references: [guilds.id],
}),
messages: many(tpMessages),
}),
);
export const tpMessages = pgTable("tp_message", {
messageId: varchar("message_id", { length: 20 }).primaryKey(),
day: smallint("day").notNull(),
planId: integer("plan_id")
.notNull()
.references(() => timePlannings.id, { onDelete: "cascade" }),
});
export const tpMessagesRelations = relations(tpMessages, ({ one }) => ({
plan: one(timePlannings, {
fields: [tpMessages.planId],
references: [timePlannings.id],
}),
}));

View file

@ -1,3 +1,3 @@
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app"));
mount(() => <StartClient />, document.getElementById("app")!);

43
src/lib/auth.ts Normal file
View file

@ -0,0 +1,43 @@
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { Discord } from "arctic";
import { Lucia } from "lucia";
import db from "~/drizzle";
import { sessions, users } from "~/drizzle/schema";
if (typeof import.meta.env.PROD === "undefined")
throw new Error("No env PROD found!");
if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
throw new Error("No env VITE_DISCORD_CLIENT_ID found!");
if (typeof import.meta.env.VITE_DISCORD_CLIENT_SECRET === "undefined")
throw new Error("No env PROD found!");
if (typeof import.meta.env.VITE_AUTH_REDIRECT_URL === "undefined")
throw new Error("No env VITE_AUTH_REDIRECT_URL found!");
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: import.meta.env.PROD,
},
},
getUserAttributes: (attributes) => attributes,
});
export const discord = new Discord(
import.meta.env.VITE_DISCORD_CLIENT_ID,
import.meta.env.VITE_DISCORD_CLIENT_SECRET,
import.meta.env.VITE_AUTH_REDIRECT_URL,
);
const unencoded = `${import.meta.env.VITE_DISCORD_CLIENT_ID}:${import.meta.env.VITE_DISCORD_CLIENT_SECRET}`;
const encoded = btoa(unencoded);
export const BasicAuth = {
unencoded: `Basic ${unencoded}`,
encoded: `Basic ${encoded}`,
};

View file

@ -0,0 +1,74 @@
import stringify from "json-stable-stringify";
import objectHash from "object-hash";
import { guilds, matches, tpMessages } from "~/drizzle/schema";
import { ExtractDataTypes, GetColumns } from "~/types/db";
import { components } from "~/types/liljudd";
export const buildMatches = (
queryMatches: ExtractDataTypes<GetColumns<typeof matches>>[],
): components["schemas"]["match"][] =>
queryMatches.map(
({
channelId,
createrId,
roleId,
messageId,
matchType,
opponentName,
utc_ts,
}) => ({
channelId: channelId.toString(),
createrId: createrId.toString(),
roleId: roleId.toString(),
messageId: messageId.toString(),
matchType,
opponentName,
utc_ts: utc_ts.toISOString(),
}),
);
export function buildConfig(
guildQuery: ExtractDataTypes<GetColumns<typeof guilds>> & {
tpMessages: ExtractDataTypes<GetColumns<typeof tpMessages>>[];
matches: ExtractDataTypes<GetColumns<typeof matches>>[];
},
): components["schemas"]["guildConfig"] {
const {
id,
timezone,
tpEnabled,
tpChannelId,
tpInterval,
tpRolesEnabled: tpRoles,
isAvailableRoleId,
wantsToBeNotifieRoledId,
} = guildQuery;
const targetMinute = tpInterval & 63;
const targetHour = (tpInterval >> 6) & 31;
const targetDay = (tpInterval >> 11) & 7;
const payload = {
guildId: id.toString(),
timezone,
features: {
timePlanning: {
enabled: tpEnabled,
channelId: tpChannelId?.toString() ?? null,
targetMinute,
targetHour,
targetDay,
roles: {
enabled: tpRoles,
isAvailableRoleId: isAvailableRoleId?.toString() ?? null,
wantsToBeNotifieRoledId: wantsToBeNotifieRoledId?.toString() ?? null,
},
},
},
matches: buildMatches(guildQuery.matches),
};
// generate checksum from payload because
// from guildQuery results in bigint serialization error
return { ...payload, checksum: objectHash(stringify(payload)) };
}

39
src/lib/responses.ts Normal file
View file

@ -0,0 +1,39 @@
import httpStatus from "http-status";
import {
APIResponse,
Methods,
MyPaths,
ResponseSchemas,
StatusCodes,
} from "~/types/backend";
export function ErrorResponse<
P extends MyPaths,
M extends Methods<P>,
C extends StatusCodes<P, M> = StatusCodes<P, M>,
>(code: C, error?: string): APIResponse<P, M> {
console.log(code, error);
const responseData = {
error: error ?? httpStatus[`${httpStatus[code]}_NAME`],
};
return new Response(JSON.stringify(responseData), {
status: httpStatus[code],
headers: {
"Content-Type": "application/json",
},
});
}
export function Res<
P extends MyPaths,
M extends Methods<P>,
C extends StatusCodes<P, M> = StatusCodes<P, M>,
>(code: C, payload: ResponseSchemas<P, M, C>): APIResponse<P, M> {
return new Response(payload === null ? null : JSON.stringify(payload), {
status: httpStatus[code],
headers: {
"Content-Type": "application/json",
},
});
}

42
src/lib/zod.ts Normal file
View file

@ -0,0 +1,42 @@
import moment from "moment-timezone";
import { z } from "zod";
const zodId = z
.string()
.refine((value) => /^\d{7,20}$/.test(value), "Invalid ID supplied");
export const zodBigIntId = zodId.transform((value) => BigInt(value));
export const zodTpMessages = z.object({
enabled: z.boolean(),
channelId: zodId.nullable(),
rolesEnabled: z.boolean(),
isAvailableRoleId: zodId.nullable(),
wantsToBeNotifieRoledId: zodId.nullable(),
messageIds: z.object({
"0": zodId.nullable(),
"1": zodId.nullable(),
"2": zodId.nullable(),
"3": zodId.nullable(),
"4": zodId.nullable(),
"5": zodId.nullable(),
"6": zodId.nullable(),
}),
});
export const zodMatch = z.object({
match: z.object({
channelId: zodId,
createrId: zodId,
messageId: zodId,
roleId: zodId,
matchType: z.string(),
opponentName: z.string(),
utc_ts: z.string().datetime(),
}),
timezone: z
.string()
.refine(
(value) => moment.tz.names().includes(value),
"Unknown timezone supplied",
),
});

100
src/middleware.ts Normal file
View file

@ -0,0 +1,100 @@
import { createMiddleware } from "@solidjs/start/middleware";
import colors from "colors";
import fs from "fs";
import { verifyRequestOrigin } from "lucia";
import { appendHeader, getCookie, getHeader } from "vinxi/http";
import { lucia } from "./lib/auth";
colors.enable();
let started: boolean = false;
export default createMiddleware({
onRequest: async (event) => {
if (event.nativeEvent.node.req.method !== "GET") {
const originHeader = getHeader(event, "Origin") ?? null;
const hostHeader = getHeader(event, "Host") ?? null;
if (
!originHeader ||
!hostHeader ||
!verifyRequestOrigin(originHeader, [hostHeader])
) {
event.nativeEvent.node.res.writeHead(403).end();
return;
}
}
const sessionId = getCookie(event, lucia.sessionCookieName) ?? null;
if (!sessionId) {
event.nativeEvent.context.session = null;
event.nativeEvent.context.user = null;
return;
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
appendHeader(
event,
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize(),
);
}
if (!session) {
appendHeader(
event,
"Set-Cookie",
lucia.createBlankSessionCookie().serialize(),
);
}
event.nativeEvent.context.session = session;
event.nativeEvent.context.user = user;
},
onBeforeResponse: async (event, response) => {
let consoleLog = "",
fileLog = "";
if (!started) {
try {
await fs.promises.mkdir("log");
console.log("Created 'log' Folder.");
} catch {}
started = true;
}
const currentDate = new Date();
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
const day = String(currentDate.getDate()).padStart(2, "0");
const hours = String(currentDate.getHours()).padStart(2, "0");
const minutes = String(currentDate.getMinutes()).padStart(2, "0");
const seconds = String(currentDate.getSeconds()).padStart(2, "0");
// Create a short and numeric representation
const date = `[${year}-${month}-${day}_${hours}:${minutes}:${seconds}]`;
const xForwardedFor = event.request.headers.get("x-forwarded-for");
const ip = (xForwardedFor || "127.0.0.1, 192.168.178.1").split(",");
const route = event.request.url;
const frontend = !new URL(event.request.url).pathname.startsWith("/api");
const method = frontend ? "Frontend" : event.request.method;
const code =
(response.body as Response | undefined)?.status ?? event.response.status;
consoleLog += [
date,
ip[0].yellow,
method,
code,
route?.green,
event.nativeEvent.context.user?.discord_id.rainbow,
].join(" ");
fileLog += [
date,
ip[0],
method,
code,
route,
event.nativeEvent.context.user?.discord_id,
].join(" ");
await fs.promises.appendFile("log/log.txt", fileLog + "\n");
console.log(consoleLog);
},
});

View file

@ -2,47 +2,100 @@ import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
import { BasicAuth } from "~/lib/auth";
import { buildConfig } from "~/lib/responseBuilders";
import { ErrorResponse, Res } from "~/lib/responses";
import { zodBigIntId } from "~/lib/zod";
import { APIResponse } from "~/types/backend";
export const GET = async ({ params }: APIEvent) => {
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, params.guildId),
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
})
.execute();
type Path = "/api/{guildId}/config";
if (!guild)
return new Response(JSON.stringify({ error: "No such guild found." }), {
status: 404,
});
export const GET = async (
event: APIEvent,
): Promise<APIResponse<Path, "get">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
return guild;
};
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
export const DELETE = async ({ params }: APIEvent) => {
const guildQuery = await db.query.guilds
.findFirst({
where: eq(guilds.id, params.guildId),
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
where: eq(guilds.id, guildId),
with: { tpMessages: true, matches: true },
})
.execute();
if (!guildQuery)
return new Response(JSON.stringify({ error: "No such guild found." }), {
status: 404,
});
if (!guildQuery) return ErrorResponse("NOT_FOUND");
const guild = await db
.delete(guilds)
.where(eq(guilds.id, params.guildId))
.returning()
return Res("OK", buildConfig(guildQuery));
};
export const POST = async (
event: APIEvent,
): Promise<APIResponse<Path, "post">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guildQuery = await db.insert(guilds).values({ id: guildId }).execute();
if (!guildQuery) return ErrorResponse("NOT_FOUND");
return Res("NO_CONTENT", null);
};
export const DELETE = async (
event: APIEvent,
): Promise<APIResponse<Path, "delete">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guildQuery = await db.query.guilds
.findFirst({
where: eq(guilds.id, guildId),
with: { tpMessages: true, matches: true },
})
.execute();
return guild;
if (!guildQuery) return ErrorResponse("NOT_FOUND");
await db.delete(guilds).where(eq(guilds.id, guildId)).execute();
return Res("NO_CONTENT", null);
};

View file

@ -0,0 +1,108 @@
import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds, matches } from "~/drizzle/schema";
import { BasicAuth } from "~/lib/auth";
import { buildMatches } from "~/lib/responseBuilders";
import { ErrorResponse, Res } from "~/lib/responses";
import { zodBigIntId, zodMatch } from "~/lib/zod";
import { APIResponse, RequestBody } from "~/types/backend";
type Path = "/api/{guildId}/matches";
export const GET = async (
event: APIEvent,
): Promise<APIResponse<Path, "get">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, guildId),
with: {
matches: true,
},
})
.execute();
if (!guild) return ErrorResponse("NOT_FOUND");
if (guild.matches.length < 1) return Res("NO_CONTENT", null);
return Res("OK", {
matches: buildMatches(guild.matches),
timezone: guild.timezone,
});
};
export const POST = async (
event: APIEvent,
): Promise<APIResponse<Path, "post">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, guildId),
with: {
matches: true,
},
})
.execute();
if (!guild) return ErrorResponse("NOT_FOUND");
const unparsedBody = await new Response(event.request.body).json();
let body: RequestBody<Path, "post">;
try {
body = zodMatch.parse(unparsedBody);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
if (body.timezone !== guild.timezone)
return ErrorResponse(
"BAD_REQUEST",
"Match's timezone is different from guild's timezone",
);
await db.insert(matches).values({
guildId: guild.id,
channelId: BigInt(body.match.channelId),
roleId: BigInt(body.match.roleId),
createrId: BigInt(body.match.createrId),
messageId: BigInt(body.match.messageId),
matchType: body.match.matchType,
opponentName: body.match.opponentName,
utc_ts: new Date(body.match.utc_ts),
});
return Res("NO_CONTENT", null);
};

View file

@ -1,24 +0,0 @@
import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
export const PUT = async ({ params }: APIEvent) => {
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, params.guildId),
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
})
.execute();
if (!guild)
return new Response(JSON.stringify({ error: "No such guild found." }), {
status: 404,
});
return "TODO";
// return guild.timePlanning;
};

View file

@ -1,24 +0,0 @@
import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
export const POST = async ({ params }: APIEvent) => {
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, params.guildId),
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
})
.execute();
if (!guild)
return new Response(JSON.stringify({ error: "No such guild found." }), {
status: 404,
});
return "TODO";
// return guild.timePlanning;
};

View file

@ -1,24 +0,0 @@
import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
export const GET = async ({ params }: APIEvent) => {
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, params.guildId),
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
})
.execute();
if (!guild)
return new Response(JSON.stringify({ error: "No such guild found." }), {
status: 404,
});
return "TODO";
// return guild.timePlanning;
};

View file

@ -0,0 +1,145 @@
import { APIEvent } from "@solidjs/start/server/types";
import { and, eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds, tpMessages } from "~/drizzle/schema";
import { BasicAuth } from "~/lib/auth";
import { ErrorResponse, Res } from "~/lib/responses";
import { zodBigIntId, zodTpMessages } from "~/lib/zod";
import { APIResponse, RequestBody } from "~/types/backend";
type Path = "/api/{guildId}/timePlanning";
const DayKeys = ["0", "1", "2", "3", "4", "5", "6"] as const;
type DayKeys = (typeof DayKeys)[number];
type Messages = Record<DayKeys, string | null>;
export const GET = async (
event: APIEvent,
): Promise<APIResponse<Path, "get">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guild = await db.query.guilds.findFirst({
where: eq(guilds.id, guildId),
with: {
tpMessages: true,
},
});
if (!guild) return ErrorResponse("NOT_FOUND");
const tpMessages = guild.tpMessages.reduce(
(acc, message) => {
const day = message.day.toString() as DayKeys;
if (!/^[0-6]$/.test(day)) return acc;
acc[day] = message.messageId?.toString() ?? null;
return acc;
},
{
"0": null,
"1": null,
"2": null,
"3": null,
"4": null,
"5": null,
"6": null,
} as Messages,
);
return Res("OK", {
enabled: guild.tpEnabled,
channelId: guild.tpChannelId?.toString() ?? null,
rolesEnabled: guild.tpRolesEnabled,
isAvailableRoleId: guild.isAvailableRoleId?.toString() ?? null,
wantsToBeNotifieRoledId: guild.wantsToBeNotifieRoledId?.toString() ?? null,
messageIds: tpMessages,
});
};
export const PUT = async (
event: APIEvent,
): Promise<APIResponse<Path, "put">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, guildId),
with: { tpMessages: true },
})
.execute();
if (!guild) return ErrorResponse("NOT_FOUND");
const unparsedBody = await new Response(event.request.body).json();
let body: RequestBody<Path, "put">;
try {
body = zodTpMessages.parse(unparsedBody);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const {
enabled,
channelId,
rolesEnabled,
isAvailableRoleId,
wantsToBeNotifieRoledId,
messageIds,
} = body;
if (guild.tpChannelId !== channelId)
await db
.update(guilds)
.set({
tpEnabled: enabled,
tpChannelId: channelId ? BigInt(channelId) : null,
tpRolesEnabled: rolesEnabled,
isAvailableRoleId: isAvailableRoleId ? BigInt(isAvailableRoleId) : null,
wantsToBeNotifieRoledId: wantsToBeNotifieRoledId
? BigInt(wantsToBeNotifieRoledId)
: null,
})
.where(eq(guilds.id, guild.id))
.execute();
await Promise.all(
DayKeys.map(async (dayStr) => {
const day = parseInt(dayStr);
const messageId = messageIds[dayStr];
await db
.update(tpMessages)
.set({ messageId: messageId ? BigInt(messageId) : null })
.where(and(eq(tpMessages.guildId, guild.id), eq(tpMessages.day, day)))
.execute();
}),
);
return Res("NO_CONTENT", null);
};

View file

@ -1,28 +0,0 @@
import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
export const GET = async ({ params }: APIEvent) => {
const guild = await db.query.guilds
.findFirst({
where: eq(guilds.id, params.guildId),
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
})
.execute();
if (!guild)
return new Response(JSON.stringify({ error: "No such guild found." }), {
status: 404,
});
return "TODO";
// return guild.timePlanning;
};
export const PUT = async () => {
return "TODO";
};

View file

@ -1,4 +0,0 @@
import { SolidAuth } from "@auth/solid-start"
import { authOptions } from "~/server/auth"
export const { GET, POST } = SolidAuth(authOptions)

View file

@ -0,0 +1,130 @@
import { createId } from "@paralleldrive/cuid2";
import { APIEvent } from "@solidjs/start/server/types";
import { OAuth2RequestError } from "arctic";
import { eq } from "drizzle-orm";
import httpStatus from "http-status";
import createClient from "openapi-fetch";
import { getCookie, setCookie } from "vinxi/http";
import db from "~/drizzle";
import { discordTokens, users } from "~/drizzle/schema";
import { discord, lucia } from "~/lib/auth";
import { paths } from "~/types/discord";
export async function GET(event: APIEvent): Promise<Response> {
const url = new URL(event.request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const error_description = url.searchParams.get("error_description");
if (error)
switch (error) {
case "access_denied":
return new Response(null, {
status: httpStatus.FOUND,
headers: { Location: "/" },
});
default:
console.log("Discord oauth error:", error_description);
return new Response(decodeURI(error_description ?? ""), {
status: httpStatus.BAD_REQUEST,
});
}
const storedState = getCookie("discord_oauth_state") ?? null;
if (!code || !state || !storedState || state !== storedState) {
return new Response(null, {
status: httpStatus.BAD_REQUEST,
});
}
try {
const tokens = await discord.validateAuthorizationCode(code);
const { GET } = createClient<paths>({
baseUrl: "https://discord.com/api/v10",
});
const discordUserResponse = await GET("/users/@me", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (discordUserResponse.error) throw discordUserResponse.error;
const discordUser = discordUserResponse.data;
const existingUser = await db.query.users
.findFirst({
where: eq(users.discord_id, discordUser.id),
})
.execute();
if (existingUser) {
const session = await lucia.createSession(
existingUser.id,
{},
{ sessionId: createId() },
);
const sessionCookie = lucia.createSessionCookie(session.id);
setCookie(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
await db
.update(users)
.set({
name: discordUser.global_name,
image: discordUser.avatar,
})
.where(eq(users.discord_id, discordUser.id))
.returning()
.execute();
return new Response(null, {
status: httpStatus.FOUND,
headers: { Location: "/config" },
});
}
const userId = createId();
await db.insert(users).values({
id: userId,
discord_id: discordUser.id,
name: discordUser.global_name,
image: discordUser.avatar,
});
await db
.insert(discordTokens)
.values({
userId,
accessToken: tokens.accessToken,
expiresAt: tokens.accessTokenExpiresAt,
refreshToken: tokens.refreshToken,
})
.returning()
.execute();
const session = await lucia.createSession(
userId,
{},
{ sessionId: createId() },
);
const sessionCookie = lucia.createSessionCookie(session.id);
setCookie(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
return new Response(null, {
status: httpStatus.FOUND,
headers: { Location: "/config" },
});
} catch (e) {
// the specific error message depends on the provider
if (e instanceof OAuth2RequestError) {
// invalid code
return new Response(null, {
status: httpStatus.BAD_REQUEST,
});
}
console.error("Unknown error on callback.");
console.error(e);
return new Response(null, {
status: httpStatus.INTERNAL_SERVER_ERROR,
});
}
}

View file

@ -0,0 +1,28 @@
import { APIEvent } from "@solidjs/start/server/types";
import { generateState } from "arctic";
import httpStatus from "http-status";
import { setCookie } from "vinxi/http";
import { discord } from "~/lib/auth";
if (typeof import.meta.env.PROD === "undefined")
throw new Error("No env PROD found!");
export async function GET(event: APIEvent) {
const state = generateState();
const url = await discord.createAuthorizationURL(state, {
scopes: ["identify", "guilds", "guilds.members.read"],
});
setCookie(event, "discord_oauth_state", state, {
path: "/",
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: "lax",
});
return new Response(null, {
status: httpStatus.FOUND,
headers: { Location: url.toString() },
});
}

View file

@ -0,0 +1,20 @@
import { APIEvent } from "@solidjs/start/server/types";
import httpStatus from "http-status";
import { appendHeader } from "vinxi/http";
import { lucia } from "~/lib/auth";
export const GET = async (event: APIEvent) => {
if (!event.nativeEvent.context.session) {
return new Error("Unauthorized");
}
await lucia.invalidateSession(event.nativeEvent.context.session.id);
appendHeader(
event,
"Set-Cookie",
lucia.createBlankSessionCookie().serialize(),
);
return new Response(null, {
status: httpStatus.FOUND,
headers: { Location: "/" },
});
};

43
src/routes/api/boot.ts Normal file
View file

@ -0,0 +1,43 @@
import { APIEvent } from "@solidjs/start/server/types";
import { eq } from "drizzle-orm";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
import { BasicAuth } from "~/lib/auth";
import { buildConfig } from "~/lib/responseBuilders";
import { ErrorResponse, Res } from "~/lib/responses";
import { zodBigIntId } from "~/lib/zod";
import { APIResponse } from "~/types/backend";
type Path = "/api/boot";
export const GET = async (
event: APIEvent,
): Promise<APIResponse<Path, "get">> => {
switch (event.request.headers.get("authorization")) {
case BasicAuth.unencoded:
case BasicAuth.encoded:
break;
default:
return ErrorResponse("UNAUTHORIZED");
}
let guildId: bigint;
try {
guildId = zodBigIntId.parse(event.params.guildId);
} catch (e) {
return ErrorResponse("BAD_REQUEST", JSON.stringify(e));
}
const guildQuery = await db.query.guilds
.findMany({
where: eq(guilds.id, guildId),
with: { tpMessages: true, matches: true },
})
.execute();
return Res(
"OK",
guildQuery.map((e) => buildConfig(e)),
);
};

View file

@ -1,14 +0,0 @@
import db from "~/drizzle";
export const GET = async () => {
const guilds = await db.query.guilds
.findMany({
with: {
timePlanning: { with: { messages: true } },
matches: true,
},
})
.execute();
return { guilds };
};

View file

@ -1,4 +1,3 @@
import { getSession } from "@auth/solid-start";
import { faToggleOff, faToggleOn } from "@fortawesome/pro-regular-svg-icons";
import { useLocation, useNavigate, useParams } from "@solidjs/router";
import { eq } from "drizzle-orm";
@ -16,11 +15,13 @@ import { getRequestEvent } from "solid-js/web";
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
import Layout from "~/components/Layout";
import db from "~/drizzle";
import { accounts } from "~/drizzle/schema";
import { authOptions } from "~/server/auth";
import { discordTokens } from "~/drizzle/schema";
import { paths } from "~/types/discord";
import "../../styles/pages/config.scss";
if (typeof import.meta.env.VITE_DISCORD_BOT_TOKEN === "undefined")
throw new Error("No env VITE_DISCORD_BOT_TOKEN found!");
const guessTZ = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
const initialValue = (params: ReturnType<typeof useParams>) => ({
@ -47,26 +48,21 @@ const getPayload = async (
if (!event) return { success: false, message: "No request event!" };
const pathname = new URL(event.request.url).pathname;
const session = await getSession(event.request, authOptions);
if (!session?.user?.id)
return { success: false, message: "No user with id!" };
const { user } = event.nativeEvent.context;
if (!user) return { success: false, message: "User not logged in!" };
const { DISCORD_ACCESS_TOKEN } = (
await db
.selectDistinct({ DISCORD_ACCESS_TOKEN: accounts.access_token })
.from(accounts)
.where(eq(accounts.userId, session.user?.id))
.limit(1)
.execute()
)[0];
if (!DISCORD_ACCESS_TOKEN)
return { success: false, message: "No discord access token!" };
const tokens = await db.query.discordTokens
.findFirst({
where: eq(discordTokens.userId, user.id),
})
.execute();
if (!tokens) return { success: false, message: "No discord access token!" };
const { GET } = createClient<paths>({
baseUrl: "https://discord.com/api/v10",
});
const guildsRequest = await GET("/users/@me/guilds", {
headers: { Authorization: `Bearer ${DISCORD_ACCESS_TOKEN}` },
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
const channelsRequest = await GET("/guilds/{guild_id}/channels", {
params: {
@ -98,7 +94,7 @@ const getPayload = async (
"User is no MANAGE_GUILD permissions on this guild with requested id!",
};
let channels: ReturnType<typeof initialValue>["guild"]["channels"] = [];
const channels: ReturnType<typeof initialValue>["guild"]["channels"] = [];
channelsRequest.data?.forEach((channel) => {
if (channel.type !== 0) return;
channels.push({
@ -115,7 +111,6 @@ const getPayload = async (
id: guild.id,
name: guild.name,
icon: guild.icon,
// channel: "1162917335275950180",
channel: "",
channels,
},

View file

@ -1,4 +1,3 @@
import { getSession } from "@auth/solid-start";
import {
faBadgeCheck,
faCircleExclamation,
@ -12,11 +11,16 @@ import { getRequestEvent } from "solid-js/web";
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
import Layout from "~/components/Layout";
import db from "~/drizzle";
import { accounts } from "~/drizzle/schema";
import { authOptions } from "~/server/auth";
import { discordTokens } from "~/drizzle/schema";
import { paths } from "~/types/discord";
import "../../styles/pages/config.scss";
if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
throw new Error("No env VITE_DISCORD_CLIENT_ID found!");
if (typeof import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS === "undefined")
throw new Error("No env VITE_DISCORD_OAUTH2_PERMISSIONS found!");
const initialValue = () => ({
success: null as boolean | null,
guilds: [] as {
@ -36,26 +40,21 @@ const getPayload = async (): Promise<
if (!event) return { success: false, message: "No request event!" };
const pathname = new URL(event.request.url).pathname;
const session = await getSession(event.request, authOptions);
if (!session?.user?.id)
return { success: false, message: "No user with id!" };
const { user } = event.nativeEvent.context;
if (!user) return { success: false, message: "User not logged in!" };
const { DISCORD_ACCESS_TOKEN } = (
await db
.selectDistinct({ DISCORD_ACCESS_TOKEN: accounts.access_token })
.from(accounts)
.where(eq(accounts.userId, session.user?.id))
.limit(1)
.execute()
)[0];
if (!DISCORD_ACCESS_TOKEN)
return { success: false, message: "No discord access token!" };
const tokens = await db.query.discordTokens
.findFirst({
where: eq(discordTokens.userId, user.id),
})
.execute();
if (!tokens) return { success: false, message: "No discord access token!" };
const { GET } = createClient<paths>({
baseUrl: "https://discord.com/api/v10",
});
const { data: guilds, error } = await GET("/users/@me/guilds", {
headers: { Authorization: `Bearer ${DISCORD_ACCESS_TOKEN}` },
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (error) {
@ -112,7 +111,7 @@ function index() {
href={
i() % 3 === 0
? `/config/${guild.id}`
: `https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_BOT_PERMISSIONS}&scope=bot&guild_id=${guild.id}`
: `https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS}&scope=bot&guild_id=${guild.id}`
}
class="flex-row centered"
>

View file

@ -1,38 +0,0 @@
import Discord from "@auth/core/providers/discord";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { type SolidAuthConfig } from "@auth/solid-start";
import db from "~/drizzle";
export const authOptions: SolidAuthConfig = {
providers: [
{
...Discord({
clientId: import.meta.env.VITE_DISCORD_CLIENT_ID,
clientSecret: import.meta.env.VITE_DISCORD_CLIENT_SECRET,
}),
authorization:
"https://discord.com/api/oauth2/authorize?scope=identify+email+guilds+guilds.members.read",
},
],
adapter: DrizzleAdapter(db),
secret: import.meta.env.VITE_AUTH_SECRET,
callbacks: {
// @ts-ignore
session: ({ session, user }) => {
if (session?.user) {
session.user.id = user.id;
}
return session;
},
},
pages: {
// signIn: "/signin",
// signOut: "/signout",
// error: '/auth/error', // Error code passed in query string as ?error=
// verifyRequest: '/auth/verify-request', // (used for check email message)
// newUser: '/auth/new-user' // New users will be directed here on first sign in (leave the property out if not of interest)
},
redirectProxyUrl: import.meta.env.DEV
? import.meta.env.VITE_AUTH_REDIRECT_PROXY_URL
: undefined,
};

15
src/types/authjs.d.ts vendored
View file

@ -1,15 +0,0 @@
import { DefaultSession as DSession } from "@auth/core/types"
declare module "@auth/core/types" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session extends DSession {
user?: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
}

70
src/types/backend.d.ts vendored Normal file
View file

@ -0,0 +1,70 @@
import { HttpStatus } from "http-status";
import { paths } from "./liljudd";
export type MyPaths = keyof paths;
export type Methods<Path extends MyPaths> = keyof paths[Path];
export type Responses<
Path extends MyPaths,
Method extends Methods<Path>,
> = "responses" extends keyof paths[Path][Method]
? paths[Path][Method]["responses"]
: never;
type StatusCodes<P extends MyPaths, M extends Methods<P>> = {
[CodeName in keyof HttpStatus]: HttpStatus[CodeName] extends number
? HttpStatus[CodeName] extends keyof Responses<P, M>
? CodeName
: never
: never;
}[keyof HttpStatus];
export type ResponseSchemas<
Path extends MyPaths,
Method extends Methods<Path>,
Code extends StatusCodes<Path, Method>,
> = Code extends keyof HttpStatus
? HttpStatus[Code] extends keyof Responses<Path, Method>
? "content" extends keyof Responses<Path, Method>[HttpStatus[Code]]
? "application/json" extends keyof Responses<
Path,
Method
>[HttpStatus[Code]]["content"]
? Responses<
Path,
Method
>[HttpStatus[Code]]["content"]["application/json"] extends never
? null
: Responses<
Path,
Method
>[HttpStatus[Code]]["content"]["application/json"]
: never
: never
: never
: never;
export type Parameters<
Path extends MyPaths,
Method extends Methods<Path>,
> = "parameters" extends keyof paths[Path][Method]
? "path" extends keyof paths[Path][Method]["parameters"]
? paths[Path][Method]["parameters"]["path"]
: never
: never;
export type RequestBody<
Path extends MyPaths,
Method extends Methods<Path>,
> = "requestBody" extends keyof paths[Path][Method]
? "content" extends keyof paths[Path][Method]["requestBody"]
? "application/json" extends keyof paths[Path][Method]["requestBody"]["content"]
? paths[Path][Method]["requestBody"]["content"]["application/json"]
: never
: never
: never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface APIResponse<Path extends MyPaths, Method extends Methods<Path>>
extends Response {}

13
src/types/db.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
import { PgColumn, PgTableWithColumns } from "drizzle-orm/pg-core";
export type GetColumns<T> =
T extends PgTableWithColumns<infer Table> ? Table["columns"] : never;
export type ExtractDataTypes<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof T]: T[K] extends PgColumn<infer ColumnConfig, any, any>
? ColumnConfig["notNull"] extends true
? ColumnConfig["data"]
: ColumnConfig["data"] | null
: unknown;
};

3
src/types/env.d.ts vendored
View file

@ -4,7 +4,7 @@ interface ImportMetaEnv {
readonly VITE_DISCORD_CLIENT_ID: string;
readonly VITE_DISCORD_CLIENT_SECRET: string;
readonly VITE_DISCORD_BOT_TOKEN: string;
readonly VITE_DISCORD_BOT_PERMISSIONS: string;
readonly VITE_DISCORD_OAUTH2_PERMISSIONS: string;
readonly VITE_AUTH_SECRET: string;
readonly VITE_AUTH_REDIRECT_PROXY_URL: string | undefined;
@ -12,7 +12,6 @@ interface ImportMetaEnv {
readonly VITE_DATABASE_URL: string;
}
// eslint-disable-next-line no-unused-vars
interface ImportMeta {
readonly env: ImportMetaEnv;
}

411
src/types/liljudd.d.ts vendored
View file

@ -5,24 +5,53 @@
export interface paths {
"/api/config/{guildId}": {
"/api/boot": {
/**
* Find guild config by ID
* @description Returns a single guild config
* Retrieve all guild's configs
* @description Returns all guild's configs.
*/
get: operations["getGuildsForBoot"];
};
"/api/{guildId}/config": {
/**
* Find a guild's config by ID
* @description Returns a single guild's config.
*/
get: operations["getGuildById"];
/**
* Deletes a guild config by ID
* @description Delete a guild's config
* Creates a guild's config by ID
* @description Create a guild's config when the bot is has joined a new guild.
*/
post: operations["postGuildById"];
/**
* Deletes a guild's config by ID
* @description Delete a guild's config when the bot is removed from the guild.
*/
delete: operations["deleteGuildById"];
};
"/api/tp_messages/{guildId}": {
"/api/{guildId}/timePlanning": {
/**
* Find guild by ID for it's tp_messages
* @description Returns tp_messages for a guild
* Find the timePlanning of guild by ID
* @description Returns timePlanning for a guild
*/
get: operations["getTp_messagesOfGuildById"];
get: operations["gettimePlanningOfGuildById"];
/**
* Put new message IDs for timePlanning of guild by ID
* @description Returns timePlanning for a guild
*/
put: operations["puttimePlanningOfGuildById"];
};
"/api/{guildId}/matches": {
/**
* Find all matches of guild by ID
* @description Returns timePlanning for a guild
*/
get: operations["getMatchesOfGuildById"];
/**
* Save a new created match of guild by ID
* @description Returns timePlanning for a guild
*/
post: operations["postMatchOfGuildById"];
};
}
@ -31,67 +60,72 @@ export type webhooks = Record<string, never>;
export interface components {
schemas: {
guildConfig: {
guildId: components["schemas"]["id"];
/**
* Format: varchar(19)
* @example 1234567890123456789
* Format: text
* @example Europe/Berlin
*/
guildID?: string;
features?: {
time_planning?: {
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
channelID?: string;
/** @example 0 0 1 * * * 60o 1w */
cron?: string;
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
isAvailableRoleId?: string;
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
wantsToBeNotifieRoledId?: string;
timezone: string;
features: {
timePlanning: {
enabled: boolean;
channelId: components["schemas"]["idOrNull"];
/** @example 0 */
targetMinute: number;
/** @example 1 */
targetHour: number;
/** @example 1 */
targetDay: number;
roles: {
enabled: boolean;
isAvailableRoleId: components["schemas"]["idOrNull"];
wantsToBeNotifieRoledId: components["schemas"]["idOrNull"];
};
};
matches?: components["schemas"]["match"][];
};
matches: components["schemas"]["match"][];
checksum: string;
};
match: {
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
channelID?: string;
channelId: components["schemas"]["id"];
createrId: components["schemas"]["id"];
roleId: components["schemas"]["id"];
messageId: components["schemas"]["id"];
/**
* Format: varchar(50)
* @example Scrim
*/
matchType?: string;
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
createrId?: string;
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
roleId?: string;
matchType: string;
/**
* Format: varchar(100)
* @example ?
*/
opponentName?: string;
/**
* Format: varchar(19)
* @example 1234567890123456789
*/
messsageId?: string;
/** @example 0 0 1 5 2 2023 60o */
cron?: string;
opponentName: string;
/** @example 2020-01-01T00:00:00Z */
utc_ts: string;
};
timePlanning: {
enabled: boolean;
channelId: components["schemas"]["idOrNull"];
rolesEnabled: boolean;
isAvailableRoleId: components["schemas"]["idOrNull"];
wantsToBeNotifieRoledId: components["schemas"]["idOrNull"];
messageIds: {
0: components["schemas"]["idOrNull"];
1: components["schemas"]["idOrNull"];
2: components["schemas"]["idOrNull"];
3: components["schemas"]["idOrNull"];
4: components["schemas"]["idOrNull"];
5: components["schemas"]["idOrNull"];
6: components["schemas"]["idOrNull"];
};
};
/** @example 1234567890123456789 */
id: string;
/** @example 1234567890123456789 */
idOrNull: string | null;
error: {
error?: string;
};
};
responses: never;
@ -108,8 +142,40 @@ export type external = Record<string, never>;
export interface operations {
/**
* Find guild config by ID
* @description Returns a single guild config
* Retrieve all guild's configs
* @description Returns all guild's configs.
*/
getGuildsForBoot: {
responses: {
/** @description successful operation */
200: {
content: {
"application/json": components["schemas"]["guildConfig"][];
};
};
/** @description Invalid ID supplied */
400: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Find a guild's config by ID
* @description Returns a single guild's config.
*/
getGuildById: {
parameters: {
@ -127,22 +193,32 @@ export interface operations {
};
/** @description Invalid ID supplied */
400: {
content: never;
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: never;
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Deletes a guild config by ID
* @description Delete a guild's config
* Creates a guild's config by ID
* @description Create a guild's config when the bot is has joined a new guild.
*/
deleteGuildById: {
postGuildById: {
parameters: {
path: {
/** @description ID of guild config to delete */
/** @description ID of guild's config to create */
guildId: string;
};
};
@ -153,22 +229,68 @@ export interface operations {
};
/** @description Invalid ID supplied */
400: {
content: never;
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: never;
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Find guild by ID for it's tp_messages
* @description Returns tp_messages for a guild
* Deletes a guild's config by ID
* @description Delete a guild's config when the bot is removed from the guild.
*/
getTp_messagesOfGuildById: {
deleteGuildById: {
parameters: {
path: {
/** @description ID of guild's tp_messages to return */
/** @description ID of guild's config to delete */
guildId: string;
};
};
responses: {
/** @description successful operation */
204: {
content: never;
};
/** @description Invalid ID supplied */
400: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Find the timePlanning of guild by ID
* @description Returns timePlanning for a guild
*/
gettimePlanningOfGuildById: {
parameters: {
path: {
/** @description ID of guild's timePlanning to return */
guildId: string;
};
};
@ -176,21 +298,164 @@ export interface operations {
/** @description successful operation */
200: {
content: {
"application/json": components["schemas"]["guildConfig"];
"application/json": components["schemas"]["timePlanning"];
};
};
/** @description Time planning not enabled for this guild */
/** @description Invalid ID supplied */
400: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Put new message IDs for timePlanning of guild by ID
* @description Returns timePlanning for a guild
*/
puttimePlanningOfGuildById: {
parameters: {
path: {
/** @description ID of guild's timePlanning to return */
guildId: string;
};
};
/** @description Put new message IDs for timePlanning in channel */
requestBody: {
content: {
"application/json": components["schemas"]["timePlanning"];
};
};
responses: {
/** @description successful operation */
204: {
content: never;
};
/** @description Invalid ID supplied */
400: {
content: never;
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Find all matches of guild by ID
* @description Returns timePlanning for a guild
*/
getMatchesOfGuildById: {
parameters: {
path: {
/** @description ID of guild's timePlanning to return */
guildId: string;
};
};
responses: {
/** @description successful operation */
200: {
content: {
"application/json": {
matches: components["schemas"]["match"][];
/**
* Format: text
* @example Europe/Berlin
*/
timezone: string;
};
};
};
/** @description Invalid ID supplied */
400: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
/**
* Save a new created match of guild by ID
* @description Returns timePlanning for a guild
*/
postMatchOfGuildById: {
parameters: {
path: {
/** @description ID of match's guild to set */
guildId: string;
};
};
/** @description Save a new created match in channel */
requestBody: {
content: {
"application/json": {
match: components["schemas"]["match"];
/**
* Format: text
* @description Has to match guild tz
* @example Europe/Berlin
*/
timezone: string;
};
};
};
responses: {
/** @description successful operation */
204: {
content: never;
};
/** @description Invalid ID supplied */
400: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Unauthorized */
401: {
content: {
"application/json": components["schemas"]["error"];
};
};
/** @description Guild not found */
404: {
content: {
"application/json": components["schemas"]["error"];
};
};
};
};
}

13
src/types/lucia-auth.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
import { users } from "~/drizzle/schema";
import { lucia } from "~/lib/auth";
import { ExtractDataTypes, GetColumns } from "./db";
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes
extends ExtractDataTypes<GetColumns<typeof users>> {}

12
src/types/vinxi.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
import { H3EventContext as EventContext } from "h3/dist";
import { Session, User } from "lucia";
declare module "vinxi/http" {
interface H3EventContext extends EventContext {
user: User | null;
session: Session | null;
}
class H3Eventt {
context: H3EventContext;
}
}

View file

@ -1,3 +0,0 @@
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({});