diff --git a/README.md b/README.md index a8c44dc..da84913 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# beesrv-server +# BeeSrv -To install dependencies: +## Server + +### Installing Dependencies ```bash bun install @@ -10,4 +12,14 @@ To run: ```bash bun run index.ts -``` \ No newline at end of file +``` + +### Applying DB Changes + +``` +bunx drizzle-kit push +``` + +### Serving Files + +A `beebox.xml` file should be placed the `server/serve/` directory. You will have to create this directory. \ No newline at end of file diff --git a/index.ts b/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index 4ae5041..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "beesrv-server", - "module": "index.ts", - "type": "module", - "private": true, - "devDependencies": { - "@types/bun": "latest" - }, - "peerDependencies": { - "typescript": "^5" - }, - "dependencies": { - "express": "^5.1.0" - } -} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..ec7ec5f --- /dev/null +++ b/server/.env.example @@ -0,0 +1,2 @@ +DB_FILE_NAME=src/db/prod.db +PORT=3000 \ No newline at end of file diff --git a/.gitignore b/server/.gitignore similarity index 94% rename from .gitignore rename to server/.gitignore index 70e10fb..a857864 100644 --- a/.gitignore +++ b/server/.gitignore @@ -34,4 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store # Bun -bun.lock* \ No newline at end of file +bun.lock* + +# Files +serve/ \ No newline at end of file diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts new file mode 100644 index 0000000..87cd1ff --- /dev/null +++ b/server/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config' +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + out: './src/db/drizzle', + schema: './src/db/schema.ts', + dialect: 'sqlite', + dbCredentials: { + url: process.env.DB_FILE_NAME!, + }, +}); \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..ef5349c --- /dev/null +++ b/server/package.json @@ -0,0 +1,25 @@ +{ + "name": "beesrv-server", + "module": "src/index.ts", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "start": "bun run src/index.ts" + }, + "devDependencies": { + "@types/bun": "^1.2.9", + "drizzle-kit": "^0.30.6" + }, + "peerDependencies": { + "typescript": "^5.8.3" + }, + "dependencies": { + "@types/express": "^5.0.1", + "better-sqlite3": "^11.9.1", + "chalk": "^5.4.1", + "dotenv": "^16.5.0", + "drizzle-orm": "^0.41.0", + "express": "^5.1.0" + } +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..215558b --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,32 @@ +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' +import { relations } from 'drizzle-orm' +import { sql } from 'drizzle-orm' + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`CURRENT_TIMESTAMP`), +}) + +export const requests = sqliteTable('requests', { + id: text('id').primaryKey(), + deviceId: text('device_id').notNull(), + version: text('version').notNull(), + status: text('status').notNull().default('pending'), + userId: text('user_id').notNull().references(() => users.id), + bbxHash: text('bbx_hash').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`CURRENT_TIMESTAMP`), +}) + +export const usersRelations = relations(users, ({ many }) => ({ + requests: many(requests), +})) + +export const requestsRelations = relations(requests, ({ one }) => ({ + user: one(users, { + fields: [requests.userId], + references: [users.id], + }), +})) \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..6f45e79 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,170 @@ +import 'dotenv/config' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import { Database } from 'bun:sqlite' +import express from "express" +import type { RequestHandler } from "express" +import { randomUUID } from "crypto" +import { eq, and, gte } from "drizzle-orm" +import chalk from "chalk" +import * as schema from './db/schema' +import path from 'path' + +const app = express() +const sqlite = new Database(process.env.DB_FILE_NAME || 'dev.db') +const db = drizzle(sqlite, { schema }) + +app.use(express.json()) + +// Rate limiting +const RATE_LIMIT_WINDOW_MS = process.env.RATE_LIMIT_WINDOW_MS ? parseInt(process.env.RATE_LIMIT_WINDOW_MS) : 60 * 60 * 1000 // 1h default +const MAX_REQUESTS_PER_WINDOW = process.env.MAX_REQUESTS_PER_WINDOW ? parseInt(process.env.MAX_REQUESTS_PER_WINDOW) : 10 // 10 rph default +const rateLimitMap = new Map() + +enum ReqStatus { + PENDING = "pending", + SENT = "sent", + FAIL = "fail", +} + +function checkRateLimit(email: string): boolean { + const now = Date.now() + const userLimit = rateLimitMap.get(email) + + if (!userLimit || now > userLimit.resetTime) { + rateLimitMap.set(email, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS }) + return true + } + + if (userLimit.count >= MAX_REQUESTS_PER_WINDOW) { + return false + } + + userLimit.count++ + return true +} + +app.post("/bee-req", (async (req, res) => { + const { deviceID, version, email } = req.body + const requestID = randomUUID() + + if (!deviceID || !version || !email) { + console.log(chalk.red(`[REQ | ${requestID}] Missing deviceID, version, or email`)) + return res.status(400).send("Missing deviceID, version, or email") + } + + // Check rate limit + if (!checkRateLimit(email as string)) { + console.log(chalk.red(`[REQ | ${requestID}] Rate limit exceeded for email: ${email}`)) + return res.status(429).send("Rate limit exceeded. Please try again later.") + } + + // Log request + console.log("┌────────────────────┐") + console.log("│ NEW BEEBOX REQUEST │") + console.log("├────────────────────┘") + console.log(`│ Request ID: ${requestID}`) + console.log(`│ Device ID: ${deviceID}`) + console.log(`│ Version: ${version}`) + console.log(`│ Email: ${email}`) + console.log("└─────────────────────") + + // Check for existing user + const existingUser = await db.query.users.findFirst({ + where: eq(schema.users.email, email as string), + }) + console.log(chalk.yellow(`[REQ | ${requestID}]`) + ` Existing user: ${existingUser ? "true" : "false"}`) + + let userId: string; + + if (!existingUser) { + try { + // Create new user + const newUser = await db.insert(schema.users).values({ + id: randomUUID(), + email: email as string, + }).returning() + + if (!newUser[0]) { + throw new Error("Failed to create user") + } + + userId = newUser[0].id + console.log(chalk.green(`[REQ | ${requestID}]`) + ` Created new user for request: ${userId}`) + } catch (error) { + console.log(chalk.red(`[REQ | ${requestID}]`) + ` Error creating user: ${error}`) + return res.status(500).send("Error creating user") + } + } else { + userId = existingUser.id + } + + // Verify file + const beebox = await Bun.file(path.join(__dirname, '../serve/beebox.xml')) + const bbxContent = await beebox.text() + if (!bbxContent) { + console.log(chalk.red(`[REQ | ${requestID}]`) + ` Error reading beebox file`) + return res.status(500).send("Unable to find beebox") + } + + // Get hash + const hasher = new Bun.CryptoHasher("sha256") + const bbxHash = await hasher.update(bbxContent).digest() + const bbxHashHex = bbxHash.toString('hex') + + // Check for duplicate request resulting in same file hash + const recentDuplicate = await db.query.requests.findFirst({ + where: and( + eq(schema.requests.userId, userId), + eq(schema.requests.bbxHash, bbxHashHex), + gte(schema.requests.createdAt, new Date(Date.now() - RATE_LIMIT_WINDOW_MS)) + ), + }) + + if (recentDuplicate) { + console.log(chalk.yellow(`[REQ | ${requestID}]`) + ` Duplicate request detected for user ${userId}`) + // Log failed request + await db.insert(schema.requests).values({ + id: requestID, + deviceId: deviceID as string, + version: version as string, + status: ReqStatus.FAIL, + userId: userId, + bbxHash: bbxHashHex, + }) + return res.status(409).send("No new beebox") + } + + // Create new request + try { + await db.insert(schema.requests).values({ + id: requestID, + deviceId: deviceID as string, + version: version as string, + status: ReqStatus.PENDING, + userId: userId, + bbxHash: bbxHashHex, + }) + console.log(chalk.green(`[REQ | ${requestID}]`) + ` Created request in database`) + } catch (error) { + console.log(chalk.red(`[REQ | ${requestID}]`) + ` Error creating request: ${error}`) + return res.status(500).send("Error creating request") + } + + // Before send, write completed + await db.update(schema.requests).set({ + status: ReqStatus.SENT, + updatedAt: new Date(), + }).where(eq(schema.requests.id, requestID)) + console.log(chalk.green(`[REQ | ${requestID}]`) + ` Updated request status, sending to user`) + + // Send beebox to user + res.json(bbxContent) +}) as RequestHandler) + +app.listen(process.env.PORT || 3000, async () => { + const pkgJson = Bun.file(path.join(__dirname, '../package.json')) + const version = JSON.parse(await pkgJson.text()).version + console.log(chalk.bgBlue("BEESRV") + " v" + version + "\n") + + console.log(chalk.green(`[SERVER] `) + `Running on port ${process.env.PORT || 3000}`) +}) \ No newline at end of file diff --git a/tsconfig.json b/server/tsconfig.json similarity index 100% rename from tsconfig.json rename to server/tsconfig.json