diff --git a/.github/workflows/build-backend.yaml b/.github/workflows/build-backend.yaml new file mode 100644 index 0000000..f23fb30 --- /dev/null +++ b/.github/workflows/build-backend.yaml @@ -0,0 +1,49 @@ +name: Build and Push Backend Docker Image + +on: + push: + branches: ["main"] + paths: + - "backend/**" + workflow_dispatch: # Allows manual triggering + +env: + IMAGE_NAME: poap-distributor # Define image name as environment variable + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=edge + type=semver,pattern={{version}} + type=sha,format=long + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..293700e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,177 @@ +db.sqlite + +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..bff5553 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,40 @@ +# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:1 AS base +WORKDIR /usr/src/app + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# # [optional] tests & build +# ENV NODE_ENV=production +# RUN bun run typecheck + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/drizzle ./drizzle +COPY --from=prerelease /usr/src/app/src ./src +COPY --from=prerelease /usr/src/app/package.json . +COPY --from=prerelease /usr/src/app/drizzle.config.ts . +COPY --from=prerelease /usr/src/app/tsconfig.json . + +# run the app +USER bun +EXPOSE 3000/tcp +ENTRYPOINT [ "bun", "run", "src/index.ts" ] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..0a8b19d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,15 @@ +# poap-distributor + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.1.34. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/backend/bun.lockb b/backend/bun.lockb new file mode 100755 index 0000000..0931898 Binary files /dev/null and b/backend/bun.lockb differ diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts new file mode 100644 index 0000000..9ae6992 --- /dev/null +++ b/backend/drizzle.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/db/schema.ts", +}); diff --git a/backend/drizzle/0000_supreme_gabe_jones.sql b/backend/drizzle/0000_supreme_gabe_jones.sql new file mode 100644 index 0000000..0a70d6d --- /dev/null +++ b/backend/drizzle/0000_supreme_gabe_jones.sql @@ -0,0 +1,5 @@ +CREATE TABLE `links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `url` text NOT NULL, + `used` integer DEFAULT false +); diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..38534c8 --- /dev/null +++ b/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,50 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "34a4f4db-562d-405f-bc7f-0ed2208242c1", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "links": { + "name": "links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..8389aa4 --- /dev/null +++ b/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1731651198038, + "tag": "0000_supreme_gabe_jones", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c90c431 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,26 @@ +{ + "name": "@poap-distributor/backend", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "^1.1.13", + "drizzle-kit": "^0.28.1", + "pino-pretty": "^13.0.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@hono/zod-validator": "^0.4.1", + "drizzle-orm": "^0.36.2", + "envalid": "^8.0.0", + "hono": "^4.6.10", + "pino": "^9.5.0", + "zod": "^3.23.8" + }, + "scripts": { + "dev": "NODE_ENV=development bun run --watch src/index.ts | pino-pretty", + "start": "NODE_ENV=production bun run src/index.ts", + "typecheck": "tsc --noEmit" + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..db9fb0d --- /dev/null +++ b/backend/src/db/index.ts @@ -0,0 +1,18 @@ +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { logger } from "../util/logger"; +import { environment } from "../util/environment"; +import * as schema from "./schema"; + +export const db = drizzle(environment.DB_FILE_NAME, { schema }); + +if (!environment.SKIP_MIGRATION) { + // import.meta.url is the path to the current file, ../drizzle/migrations + const migrationsFolder = new URL("../../drizzle", import.meta.url).pathname; + logger.info({ migrationsFolder }, `Migrating database`); + migrate(db, { + migrationsFolder, + }); + + logger.info(`Database migrated`); +} diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts new file mode 100644 index 0000000..87ace85 --- /dev/null +++ b/backend/src/db/schema.ts @@ -0,0 +1,7 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const links = sqliteTable("links", { + id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), + url: text("url").notNull(), + used: integer("used", { mode: "boolean" }).default(false), +}); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..5a7dc60 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,23 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { requestId } from "hono/request-id"; +import { + honoErrorHandler, + loggerProviderMiddleware, + requestLoggerMiddleware, +} from "./util/hono"; +import { routesRouter } from "./routes/_"; + +const app = new Hono() + .use(cors()) + .use("*", requestId()) + .use(loggerProviderMiddleware) + // Add health check before request tracer to avoid unnecessary log spam + .get("/health", async (c) => { + return c.json({ status: "ok" }); + }) + .use(requestLoggerMiddleware) + .route("/", routesRouter) + .onError(honoErrorHandler); + +export default app; diff --git a/backend/src/routes/_.ts b/backend/src/routes/_.ts new file mode 100644 index 0000000..8b1ad59 --- /dev/null +++ b/backend/src/routes/_.ts @@ -0,0 +1,7 @@ +import { createBaseApp } from "../util/hono"; +import { adminRouter } from "./admin"; +import { poapRouter } from "./poap"; + +export const routesRouter = createBaseApp() + .route("/poap", poapRouter) + .route("/admin", adminRouter); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..d723511 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,157 @@ +import { zValidator } from "@hono/zod-validator"; +import { db } from "../db"; +import { createBaseApp } from "../util/hono"; +import { z } from "zod"; +import { links } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { bearerAuth } from "hono/bearer-auth"; +import { environment } from "../util/environment"; + +// Sub router at /admin +export const adminRouter = createBaseApp() + .use("/*", bearerAuth({ token: environment.ADMIN_TOKEN })) + .get("/links", async (c) => { + const links = await db.query.links.findMany(); + return c.json(links); + }) + .get( + "/links/:id", + zValidator( + "param", + z.object({ + id: z.number(), + }) + ), + async (c) => { + const { id } = c.req.valid("param"); + + const link = await db.query.links.findFirst({ + where: (links, { eq }) => eq(links.id, id), + }); + + if (!link) { + return c.json({ error: "Link not found" }, 404); + } + + return c.json(link); + } + ) + .post( + "/links", + zValidator( + "json", + z + .object({ + url: z.string(), + used: z.boolean().optional(), + }) + .strip() + ), + async (c) => { + const body = c.req.valid("json"); + + if (!body.url) { + return c.json({ error: "URL is required" }, 400); + } + + const result = await db + .insert(links) + .values({ + url: body.url, + used: body.used ?? false, + }) + .returning({ + id: links.id, + }) + .then(([result]) => result); + + return c.json({ id: result.id }, 201); + } + ) + // Bulk insert links + .post( + "/links/bulk", + zValidator( + "json", + z.array( + z + .object({ + url: z.string(), + used: z.boolean().optional(), + }) + .strip() + ) + ), + async (c) => { + const body = c.req.valid("json"); + const result = await db + .insert(links) + .values(body) + .returning({ id: links.id }); + return c.json(result, 201); + } + ) + .put( + "/links/:id", + zValidator( + "param", + z.object({ + id: z.number(), + }) + ), + zValidator( + "json", + z + .object({ + url: z.string(), + used: z.boolean(), + }) + .partial() + .strip() + ), + async (c) => { + const { id } = c.req.valid("param"); + const body = c.req.valid("json"); + + const result = await db + .update(links) + .set(body) + .where(eq(links.id, id)) + .returning({ + id: links.id, + }) + .then(([result]) => result); + + if (!result) { + return c.json({ error: "Link not found" }, 404); + } + + return c.json({ success: true }); + } + ) + .delete( + "/links/:id", + zValidator( + "param", + z.object({ + id: z.number(), + }) + ), + async (c) => { + const { id } = c.req.valid("param"); + + const result = await db + .delete(links) + .where(eq(links.id, id)) + .returning({ + id: links.id, + }) + .then(([result]) => result); + + if (!result) { + return c.json({ error: "Link not found" }, 404); + } + + return c.json({ success: true }); + } + ); diff --git a/backend/src/routes/poap.ts b/backend/src/routes/poap.ts new file mode 100644 index 0000000..f518564 --- /dev/null +++ b/backend/src/routes/poap.ts @@ -0,0 +1,24 @@ +import { eq } from "drizzle-orm"; +import { db } from "../db"; +import { links } from "../db/schema"; +import { createBaseApp } from "../util/hono"; + +export const poapRouter = createBaseApp().post("/", async (c) => { + const { logger } = c.var; + + const link = await db.query.links.findFirst({ + where: (links, { eq }) => eq(links.used, false), + }); + + if (!link) { + return c.json({ error: "No link found" }, 404); + } + + logger.trace("Redirecting to link", { linkId: link.id }); + + await db.update(links).set({ used: true }).where(eq(links.id, link.id)); + + return c.json({ + url: link.url, + }); +}); diff --git a/backend/src/util/environment.ts b/backend/src/util/environment.ts new file mode 100644 index 0000000..484565a --- /dev/null +++ b/backend/src/util/environment.ts @@ -0,0 +1,8 @@ +import { bool, cleanEnv, str } from "envalid"; + +export const environment = cleanEnv(process.env, { + SKIP_MIGRATION: bool({ default: false }), + ADMIN_TOKEN: str(), + DB_FILE_NAME: str(), + LOG_LEVEL: str({ default: "info", devDefault: "trace" }), +}); diff --git a/backend/src/util/hono.ts b/backend/src/util/hono.ts new file mode 100644 index 0000000..646ac18 --- /dev/null +++ b/backend/src/util/hono.ts @@ -0,0 +1,111 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import type { HonoOptions } from "hono/hono-base"; +import type { + BlankEnv, + BlankSchema, + Env, + ErrorHandler, + Schema, +} from "hono/types"; +import { logger } from "./logger"; +import "hono/request-id"; +import { HTTPException } from "hono/http-exception"; + +type Variables = { + logger: typeof logger; +}; + +type BaseEnv = { + Variables: Variables; +}; + +export const honoFactory = createFactory(); + +export const loggerProviderMiddleware = honoFactory.createMiddleware( + async (c, next) => { + const childLogger = logger.child({ + type: "request", + requestId: c.get("requestId"), + path: c.req.path, + method: c.req.method, + }); + + c.set("logger", childLogger); + + await next(); + } +); + +export const requestLoggerMiddleware = honoFactory.createMiddleware( + async (c, next) => { + const { logger } = c.var; + + logger.trace("Request received"); + + const start = Date.now(); + + await next(); + + logger.trace( + { + status: c.res.status, + duration: Date.now() - start, + }, + "Request completed" + ); + } +); + +export const createBaseApp = < + BasePath extends string = "/", + E extends Env = BlankEnv, + S extends Schema = BlankSchema +>( + options?: HonoOptions +) => { + const baseApp = new Hono(options); + + return baseApp; +}; + +export const honoErrorHandler: ErrorHandler = async (error, c) => { + const { logger, requestId } = c.var; + + if (error instanceof HTTPException) { + logger.error({ + err: error, + cause: error.cause, + }); + + if (error.res) { + const newResponse = new Response(error.res.body, { + status: error.status, + headers: error.res.headers, + }); + return newResponse; + } + + if (!error.message) { + return error.getResponse(); + } + + return c.json( + { + message: error.message, + code: "HTTP_EXCEPTION", + }, + error.status + ); + } + + logger.error(error); + + return c.json( + { + message: "Internal server error " + requestId, + code: "INTERNAL_SERVER_ERROR", + }, + 500 + ); +}; diff --git a/backend/src/util/logger.ts b/backend/src/util/logger.ts new file mode 100644 index 0000000..8c69fec --- /dev/null +++ b/backend/src/util/logger.ts @@ -0,0 +1,11 @@ +import pino from "pino"; +import { environment } from "./environment"; + +export const logger = pino({ + level: environment.LOG_LEVEL, + formatters: { + level(label) { + return { level: label }; + }, + }, +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}