some better auth stuff

This commit is contained in:
2025-09-09 23:49:42 +02:00
parent ced8dbf799
commit ec67142907
10 changed files with 558 additions and 0 deletions

5
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
generated/prisma

View File

@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "public"."Test" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Test_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,78 @@
/*
Warnings:
- You are about to drop the `Test` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "public"."Test";
-- CreateTable
CREATE TABLE "public"."user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "public"."user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "public"."session"("token");
-- AddForeignKey
ALTER TABLE "public"."session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

74
app/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,74 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("verification")
}

17
app/src/lib/api/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql'
}),
socialProviders: {
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID as string,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string,
}
}
});

19
app/src/lib/api/macro.ts Normal file
View File

@@ -0,0 +1,19 @@
import Elysia from 'elysia';
import { auth } from './auth';
export const betterAuth = new Elysia({ name: 'better-auth' }).mount(auth.handler).macro({
auth: {
async resolve({ status, request: { headers } }) {
const session = await auth.api.getSession({
headers
});
if (!session) return status(401);
return {
user: session.user,
session: session.session
};
}
}
});

View File

View File

@@ -0,0 +1,346 @@
import { spawn, $, write, file } from 'bun';
import { join } from 'path';
declare namespace Bun {
export const YAML: {
parse(text: string): any;
};
}
interface MinecraftEnvironment {
EULA: string;
VERSION?: string;
TYPE?: 'VANILLA' | 'FORGE' | 'FABRIC' | 'PAPER' | 'SPIGOT' | 'BUKKIT' | 'PURPUR' | 'MODDED';
DIFFICULTY?: 'PEACEFUL' | 'EASY' | 'NORMAL' | 'HARD';
MODE?: 'SURVIVAL' | 'CREATIVE' | 'ADVENTURE' | 'SPECTATOR';
MAX_PLAYERS?: string;
MOTD?: string;
LEVEL_NAME?: string;
SEED?: string;
PVP?: string;
ENABLE_COMMAND_BLOCK?: string;
SPAWN_PROTECTION?: string;
MAX_WORLD_SIZE?: string;
VIEW_DISTANCE?: string;
ONLINE_MODE?: string;
ALLOW_NETHER?: string;
ANNOUNCE_PLAYER_ACHIEVEMENTS?: string;
MEMORY?: string;
JVM_OPTS?: string;
[key: string]: string | undefined;
}
interface MinecraftService {
image: 'itzg/minecraft-server';
container_name: string;
tty: true;
stdin_open: true;
ports: string[];
environment: MinecraftEnvironment;
volumes: ['./data:/data'];
restart?: 'unless-stopped' | 'no';
networks?: string[];
}
// simple YAML stringifier for basic objects
function stringifyYAML(obj: any, indent = 0): string {
const spaces = ' '.repeat(indent);
if (obj === null || obj === undefined) {
return 'null';
}
if (typeof obj === 'string') {
// handle strings that need quoting
if (obj.includes(':') || obj.includes('\n') || obj.includes('#')) {
return `"${obj.replace(/"/g, '\\"')}"`;
}
return obj;
}
if (typeof obj === 'number' || typeof obj === 'boolean') {
return String(obj);
}
if (Array.isArray(obj)) {
if (obj.length === 0) return '[]';
return obj.map((item) => `${spaces}- ${stringifyYAML(item, indent + 2)}`).join('\n');
}
if (typeof obj === 'object') {
const entries = Object.entries(obj);
if (entries.length === 0) return '{}';
return entries
.map(([key, value]) => {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return `${spaces}${key}:\n${stringifyYAML(value, indent + 2)}`;
} else {
return `${spaces}${key}: ${stringifyYAML(value, 0)}`;
}
})
.join('\n');
}
return String(obj);
}
// minimal Minecraft docker-compose template
const baseTemplate: { services: { mc: MinecraftService } } = {
services: {
mc: {
image: 'itzg/minecraft-server',
container_name: 'minecraft',
tty: true,
stdin_open: true,
ports: ['25565:25565'],
environment: {
EULA: 'TRUE',
TYPE: 'PAPER',
ENABLE_WHITELIST: 'true',
WHITELIST: 'f396e2b9-cbb1-46a0-bb72-96898a1ca44d',
DIFFICULTY: 'NORMAL',
SPAWN_PROTECTION: '0',
RCON_CMDS_FIRST_CONNECT: `op vaporvee`
},
volumes: ['./data:/data']
}
}
};
const forbiddenKeys = new Set([
'ENABLE_QUERY',
'QUERY_PORT',
'ENABLE_RCON',
'RCON_PORT',
'RCON_PASSWORD'
]);
class ProjectManager {
constructor(private baseDir = join(process.cwd(), 'docker-projects')) {}
projectPath(name: string) {
return join(this.baseDir, name);
}
composePath(name: string) {
return join(this.projectPath(name), 'docker-compose.yml');
}
async createProject(name: string) {
await $`mkdir -p ${this.projectPath(name)}`;
await write(this.composePath(name), stringifyYAML(baseTemplate));
}
async patchProject(name: string, patch: any) {
const path = this.composePath(name);
const f = file(path);
if (!(await f.exists())) throw new Error('Project not found');
const text = await f.text();
const config = Bun.YAML.parse(text);
if (patch.services?.mc) {
for (const [key, val] of Object.entries(patch.services.mc)) {
if (!forbiddenKeys.has(key)) {
if (typeof val === 'object' && !Array.isArray(val)) {
config.services.mc[key] = { ...config.services.mc[key], ...val };
} else {
config.services.mc[key] = val;
}
}
}
}
await write(path, stringifyYAML(config));
return config;
}
async overrideValue(name: string, key: string, value: string) {
const path = this.composePath(name);
const f = file(path);
if (!(await f.exists())) throw new Error('Project not found');
const text = await f.text();
const config = Bun.YAML.parse(text);
if (forbiddenKeys.has(key)) throw new Error(`Key ${key} cannot be overridden`);
if (!config.services.mc.environment) config.services.mc.environment = {};
config.services.mc.environment[key] = value;
await write(path, stringifyYAML(config));
return config;
}
async removeProject(name: string) {
const cwd = this.projectPath(name);
await spawn(['docker', 'compose', 'down', '-v'], { cwd }).exited;
await $`rm -rf ${cwd}`;
}
async runCompose(name: string, args: string[]) {
const cwd = this.projectPath(name);
const result = await spawn(['docker', 'compose', ...args], { cwd });
return result.exited;
}
async attach(name: string) {
const cwd = this.projectPath(name);
const process = spawn(['docker', 'exec', '-i', 'minecraft', 'rcon-cli'], {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe'
});
const ansiColorMap: Record<string, string> = {
'30': 'black',
'31': 'red',
'32': 'green',
'33': 'yellow',
'34': 'blue',
'35': 'magenta',
'36': 'cyan',
'37': 'white',
'90': 'brightBlack',
'91': 'brightRed',
'92': 'brightGreen',
'93': 'brightYellow',
'94': 'brightBlue',
'95': 'brightMagenta',
'96': 'brightCyan',
'97': 'brightWhite'
};
function parseAnsiColors(text: string) {
const ansiRegex = /\x1b\[([0-9;]+)m/g;
const chunks: any[] = [];
let lastIndex = 0;
let match;
let currentColor: string | undefined;
let currentBold = false;
while ((match = ansiRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textChunk = text.substring(lastIndex, match.index);
chunks.push({
text: textChunk,
color: currentColor,
bold: currentBold
});
}
const codes = match[1].split(';');
for (const code of codes) {
if (code === '0') {
currentColor = undefined;
currentBold = false;
chunks.push({ text: '', reset: true });
} else if (code === '1') {
currentBold = true;
} else if (ansiColorMap[code]) {
currentColor = ansiColorMap[code];
}
}
lastIndex = ansiRegex.lastIndex;
}
if (lastIndex < text.length) {
const textChunk = text.substring(lastIndex);
chunks.push({
text: textChunk,
color: currentColor,
bold: currentBold
});
}
return chunks;
}
const reader = new ReadableStream({
start(controller) {
const decoder = new TextDecoder();
let buffer = '';
const processOutput = async (stream: ReadableStream<Uint8Array>) => {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const colorChunks = parseAnsiColors(line);
let formattedText = '';
for (const chunk of colorChunks) {
if (chunk.reset) {
continue;
}
let text = chunk.text;
if (text) {
if (chunk.color) {
if (chunk.bold) {
text = `<${chunk.color} font-bold>${text}</${chunk.color}>`;
} else {
text = `<${chunk.color}>${text}</${chunk.color}>`;
}
} else if (chunk.bold) {
text = `<span font-bold>${text}</span>`;
}
formattedText += text;
}
}
controller.enqueue({
type: 'output',
formatted: formattedText,
chunks: colorChunks,
rawText: line
});
}
}
} catch (error) {
controller.error(error);
} finally {
reader.releaseLock();
}
};
if (process.stdout) {
processOutput(process.stdout);
}
if (process.stderr) {
processOutput(process.stderr);
}
}
});
const writer = {
async write(text: string) {
if (process.stdin) {
await process.stdin.write(text);
}
},
async close() {
if (process.stdin) {
await process.stdin.end();
}
}
};
return {
reader,
writer,
process
};
}
}
export { ProjectManager };

View File

@@ -0,0 +1,8 @@
import { createAuthClient } from 'better-auth/svelte';
export const authClient = createAuthClient({
baseUrl: '/api',
fetchOptions: {
credentials: 'include'
}
});