Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Test Dev

on:
pull_request:
branches:
- main
# Allows you to run this workflow manually from the Actions tab on GitHub.
workflow_dispatch:

# Allow this job to clone the repo and create a page deployment
permissions:
contents: read

jobs:
test-api:
name: Test API
runs-on: ubuntu-latest
steps:
- name: Checkout your repository using git
uses: actions/checkout@v6.0.2

- name: Set up Bun
uses: oven-sh/setup-bun@v2.2.0

- name: Install dependencies
run: bun install

- name: Run tests with coverage gate
run: bun run test
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ dist-ssr
# Cloudflare/Wrangler
.wrangler

.env
.env
coverage
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Reporting a Vulnerability

Please contact admin@2dtiler.com with information about the security vulnerability.

We highly appreciate any vulnerability feedback!
6 changes: 3 additions & 3 deletions TODO.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
CI/CD
Create API endpoint for fetching releases
https://lospec.com/palette-list/load?colorNumberFilterType=any&page=0&tag=&sortingType=newest
Add unit tests and add to CI/CD
Update README.md
Push and deploy
247 changes: 244 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"lint": "eslint src"
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage"
},
"dependencies": {
"hono": "^4.12.12"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.14.3",
"@cloudflare/workers-types": "^4.20260410.1",
"@eslint/js": "^10.0.1",
"@vitest/coverage-istanbul": "^4.1.4",
"eslint": "^10.2.0",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.1",
"vitest": "^4.1.4",
"wrangler": "^4.81.1"
}
}
15 changes: 15 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Hono } from "hono";

import { registerHealthRoutes } from "./app/routes/health";
import { registerLospecPaletteRoutes } from "./app/routes/lospec-palettes";
import { corsMiddleware } from "./app/middleware/cors";
import type { AppBindings } from "./config/types";

const app = new Hono<AppBindings>();

app.use("*", corsMiddleware);

registerHealthRoutes(app);
registerLospecPaletteRoutes(app);

export default app;
7 changes: 7 additions & 0 deletions src/app/controllers/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Context } from "hono";

import type { AppBindings } from "../../config/types";

export function getHealth(c: Context<AppBindings>): Response {
return c.json({});
}
67 changes: 67 additions & 0 deletions src/app/controllers/lospec-palettes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Context } from "hono";

import { LOSPEC_PALETTES_PAGE_SIZE } from "../../config/constants";
import type { AppBindings } from "../../config/types";
import {
mapRowToResponse,
type ListLospecPalettesOptions,
} from "../models/lospec-palette";
import { listPalettes } from "../services/lospec-palettes-repository";

function parsePage(value: string | undefined): number | null {
if (value === undefined) {
return 0;
}

if (!/^\d+$/.test(value)) {
return null;
}

return Number.parseInt(value, 10);
}

function normalizeQueryValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

function parseListLospecPalettesOptions(
query: Record<string, string | undefined>,
): ListLospecPalettesOptions | null {
const page = parsePage(query.page);
if (page === null) {
return null;
}

return {
page,
search: normalizeQueryValue(query.search),
tag: normalizeQueryValue(query.tags),
};
}

export async function getLospecPalettes(
c: Context<AppBindings>,
): Promise<Response> {
const options = parseListLospecPalettesOptions(c.req.query());
if (!options) {
return c.json(
{
error: `Invalid page parameter. Expected a non-negative integer with ${LOSPEC_PALETTES_PAGE_SIZE} results per page.`,
},
400,
);
}

const ip = c.req.raw.headers.get("CF-Connecting-IP") ?? "unknown";
const { success } = await c.env.RATE_LIMITER.limit({ key: ip });
if (!success) {
return c.json(
{ error: "Rate limit exceeded. Try again in a minute." },
429,
);
}
Comment thread
wernerbihl marked this conversation as resolved.

const palettes = await listPalettes(c.env.DB, options);
return c.json(palettes.map(mapRowToResponse));
}
45 changes: 45 additions & 0 deletions src/app/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { MiddlewareHandler } from "hono";

import {
ALLOWED_ORIGINS,
INTERNAL_API_KEY_HEADER,
} from "../../config/constants";
import type { AppBindings } from "../../config/types";

function applyCorsHeaders(headers: Headers, origin: string): void {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type");
headers.set("Vary", "Origin");
}

export const corsMiddleware: MiddlewareHandler<AppBindings> = async (
c,
next,
) => {
const origin = c.req.header("Origin");
const internalApiKey = c.req.header(INTERNAL_API_KEY_HEADER);

if (c.env.INTERNAL_API_KEY && internalApiKey === c.env.INTERNAL_API_KEY) {
await next();
return;
}

if (!origin) {
await next();
return;
}

if (!ALLOWED_ORIGINS.has(origin)) {
return c.json({ error: "Forbidden origin" }, 403);
}

if (c.req.method === "OPTIONS") {
const headers = new Headers();
applyCorsHeaders(headers, origin);
return new Response(null, { status: 204, headers });
}

await next();
applyCorsHeaders(c.res.headers, origin);
};
157 changes: 157 additions & 0 deletions src/app/models/lospec-palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { LOSPEC_CDN_BASE_URL } from "../../config/constants";

export interface LospecPaletteApiItem {
_id: string;
title?: string;
slug?: string;
description?: string;
tags?: unknown;
user?: unknown;
colors?: unknown;
examples?: unknown;
publishedAt?: string;
}

export interface LospecPaletteRow {
id: string;
title: string | null;
slug: string | null;
description: string | null;
tags: string | null;
user: string | null;
colors: string | null;
examples: string | null;
published_at: string | null;
}

export interface LospecPaletteExample {
image: string;
description: string | null;
}

export interface LospecPaletteResponse {
id: string;
title: string | null;
slug: string | null;
description: string | null;
tags: unknown | null;
user: string | null;
colors: unknown | null;
examples: LospecPaletteExample[] | null;
published_at: string | null;
}

export interface ListLospecPalettesOptions {
page: number;
search?: string;
tag?: string;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export function isLospecPaletteApiItem(
value: unknown,
): value is LospecPaletteApiItem {
return isRecord(value) && typeof value._id === "string";
}

function asNullableString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}

function normalizePaletteUser(value: unknown): string | null {
if (typeof value === "string") {
return value;
}

if (!isRecord(value)) {
return null;
}

return asNullableString(value.name);
}

function normalizePaletteExampleImage(value: string): string {
return new URL(value, LOSPEC_CDN_BASE_URL).toString();
}

function normalizePaletteExamples(
value: unknown,
): LospecPaletteExample[] | null {
if (!Array.isArray(value)) {
return null;
}

return value.flatMap((example) => {
if (!isRecord(example)) {
return [];
}

const image = asNullableString(example.image);
if (!image) {
return [];
}

return [
{
image: normalizePaletteExampleImage(image),
description: asNullableString(example.description),
},
];
});
}

function serializeJsonField(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}

return JSON.stringify(value);
}

function deserializeJsonField(value: string | null): unknown | null {
if (!value) {
return null;
}

try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}

export function mapPaletteToRow(
palette: LospecPaletteApiItem,
): LospecPaletteRow {
return {
id: palette._id,
title: asNullableString(palette.title),
slug: asNullableString(palette.slug),
description: asNullableString(palette.description),
tags: serializeJsonField(palette.tags),
user: normalizePaletteUser(palette.user),
colors: serializeJsonField(palette.colors),
examples: serializeJsonField(normalizePaletteExamples(palette.examples)),
published_at: asNullableString(palette.publishedAt),
};
}

export function mapRowToResponse(row: LospecPaletteRow): LospecPaletteResponse {
const user = normalizePaletteUser(deserializeJsonField(row.user));
const examples = normalizePaletteExamples(deserializeJsonField(row.examples));

return {
id: row.id,
title: row.title,
slug: row.slug,
description: row.description,
tags: deserializeJsonField(row.tags),
user,
colors: deserializeJsonField(row.colors),
examples,
published_at: row.published_at,
};
}
8 changes: 8 additions & 0 deletions src/app/routes/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Hono } from "hono";

import type { AppBindings } from "../../config/types";
import { getHealth } from "../controllers/health";

export function registerHealthRoutes(app: Hono<AppBindings>): void {
app.get("/", getHealth);
}
8 changes: 8 additions & 0 deletions src/app/routes/lospec-palettes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Hono } from "hono";

import type { AppBindings } from "../../config/types";
import { getLospecPalettes } from "../controllers/lospec-palettes";

export function registerLospecPaletteRoutes(app: Hono<AppBindings>): void {
app.get("/lospec_palettes", getLospecPalettes);
}
Loading
Loading