From d2f337b281e539e56cd11821e4566adfe69be93a Mon Sep 17 00:00:00 2001 From: Jakob Helgesson Date: Fri, 15 Nov 2024 13:41:24 +0700 Subject: [PATCH] Introduce backend --- .github/workflows/build-backend.yaml | 49 ++++++ backend/.gitignore | 177 ++++++++++++++++++++ backend/Dockerfile | 40 +++++ backend/README.md | 15 ++ backend/bun.lockb | Bin 0 -> 38229 bytes backend/drizzle.config.ts | 6 + backend/drizzle/0000_supreme_gabe_jones.sql | 5 + backend/drizzle/meta/0000_snapshot.json | 50 ++++++ backend/drizzle/meta/_journal.json | 13 ++ backend/package.json | 26 +++ backend/src/db/index.ts | 18 ++ backend/src/db/schema.ts | 7 + backend/src/index.ts | 23 +++ backend/src/routes/_.ts | 7 + backend/src/routes/admin.ts | 157 +++++++++++++++++ backend/src/routes/poap.ts | 24 +++ backend/src/util/environment.ts | 8 + backend/src/util/hono.ts | 111 ++++++++++++ backend/src/util/logger.ts | 11 ++ backend/tsconfig.json | 27 +++ 20 files changed, 774 insertions(+) create mode 100644 .github/workflows/build-backend.yaml create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100755 backend/bun.lockb create mode 100644 backend/drizzle.config.ts create mode 100644 backend/drizzle/0000_supreme_gabe_jones.sql create mode 100644 backend/drizzle/meta/0000_snapshot.json create mode 100644 backend/drizzle/meta/_journal.json create mode 100644 backend/package.json create mode 100644 backend/src/db/index.ts create mode 100644 backend/src/db/schema.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/routes/_.ts create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/routes/poap.ts create mode 100644 backend/src/util/environment.ts create mode 100644 backend/src/util/hono.ts create mode 100644 backend/src/util/logger.ts create mode 100644 backend/tsconfig.json 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 0000000000000000000000000000000000000000..0931898e608ed54872b696457fe537add48c455e GIT binary patch literal 38229 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 z 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 tYYo!y@`QN5PiTVo(wz5pqYHVW6~x { + 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 + } +}