Skip to content
Open
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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
.idea
.claude/
builds/

# Maps API
maps-api/node_modules/
maps-api/.env
maps-api/data/
storage/

# Maps Web
maps-web/node_modules/
maps-web/dist/
53 changes: 53 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ETJump Maps - Setup Guide

## Prerequisites

- [Bun](https://bun.sh/) (v1.0+) - JavaScript runtime and package manager
- [Node.js](https://nodejs.org/) (v18+) - required by the `sharp` image processing library

```bash
# Linux / macOS
curl -fsSL https://bun.sh/install | bash

# Windows (via PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```

## Backend (maps-api)

```bash
cd maps-api
bun install
cp .env.example .env
```

Edit `.env` and set `UPLOAD_SECRET` to a secret key of your choice. The other defaults work out of the box for local development.

```bash
# Create the database and tables
bun run db:migrate

# Start the API (auto-reloads on changes)
bun run dev
```

The API runs at `http://localhost:3001`.

The migration creates the SQLite database file and its tables. It's safe to run repeatedly - it only creates what doesn't exist yet.

## Frontend (maps-web)

```bash
cd maps-web
bun install
bun run dev
```

The frontend runs at `http://localhost:5173`. API calls to `/api/*` are automatically proxied to the backend during development.

## Testing the Upload Flow

1. Open `http://localhost:5173`
2. Click the key icon and enter the secret key you set in `.env`
3. Go to Upload, select a `.pk3` file, fill in details, and save
4. The map appears in the list with metadata extracted from the BSP file
18 changes: 18 additions & 0 deletions maps-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Database (SQLite - just a file path)
DATABASE_PATH=./data/etjump-maps.db

# Upload secret key (share with trusted uploaders)
UPLOAD_SECRET=your-secret-key-here

# Storage path for PK3 files
STORAGE_PATH=../storage/maps

# Server
PORT=3001

# CORS origins (comma-separated, defaults to localhost)
# CORS_ORIGINS=http://localhost:5173,http://localhost:3000

# File size limits (in MB)
# MAX_PK3_SIZE_MB=500
# MAX_IMAGE_SIZE_MB=10
248 changes: 248 additions & 0 deletions maps-api/bun.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions maps-api/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";

export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "sqlite",
dbCredentials: {
url: "./data/etjump-maps.db",
},
});
23 changes: 23 additions & 0 deletions maps-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "etjump-maps-api",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.31.0",
"hono": "^4.4.0",
"jszip": "^3.10.1",
"sharp": "^0.34.5"
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.22.0",
"typescript": "^5.4.0"
}
}
25 changes: 25 additions & 0 deletions maps-api/src/db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import { existsSync, mkdirSync } from "fs";
import { dirname } from "path";
import * as schema from "./schema";

const DB_PATH = process.env.DATABASE_PATH || "./data/etjump-maps.db";

// Ensure the data directory exists
const dbDir = dirname(DB_PATH);
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}

const sqlite = new Database(DB_PATH);

// Enable foreign keys
sqlite.exec("PRAGMA foreign_keys = ON;");
// Use WAL mode for better concurrent read/write performance
sqlite.exec("PRAGMA journal_mode = WAL;");
// Wait up to 5 seconds if database is locked by another writer
sqlite.exec("PRAGMA busy_timeout = 5000;");

export const db = drizzle(sqlite, { schema });
export { sqlite };
50 changes: 50 additions & 0 deletions maps-api/src/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { sqlite } from "./index";

// Create tables if they don't exist
sqlite.exec(`
CREATE TABLE IF NOT EXISTS maps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
checksum TEXT NOT NULL,
bsp_name TEXT,
map_name TEXT,
features TEXT DEFAULT '[]',
levelshot_path TEXT,
display_name TEXT,
author TEXT,
release_year INTEGER,
difficulty TEXT,
map_types TEXT DEFAULT '[]',
tags TEXT DEFAULT '[]',
download_count INTEGER DEFAULT 0,
is_published INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_maps_checksum ON maps(checksum);
CREATE INDEX IF NOT EXISTS idx_maps_is_published ON maps(is_published);
`);

// Add new columns if they don't exist (for existing databases)
const addColumnIfNotExists = (table: string, column: string, type: string) => {
try {
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
console.log(`Added column ${column} to ${table}`);
} catch (e: any) {
// Column already exists, ignore
if (!e.message.includes("duplicate column")) {
throw e;
}
}
};

addColumnIfNotExists("maps", "features", "TEXT DEFAULT '[]'");
addColumnIfNotExists("maps", "levelshot_path", "TEXT");
addColumnIfNotExists("maps", "release_year", "INTEGER");
addColumnIfNotExists("maps", "map_types", "TEXT DEFAULT '[]'");

console.log("Database migrated successfully!");
console.log("Database location:", process.env.DATABASE_PATH || "./data/etjump-maps.db");
43 changes: 43 additions & 0 deletions maps-api/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

export const maps = sqliteTable("maps", {
id: integer("id").primaryKey({ autoIncrement: true }),

// File info
filename: text("filename").notNull(),
filePath: text("file_path").notNull(),
fileSize: integer("file_size").notNull(),
checksum: text("checksum").notNull().unique(),

// Extracted from BSP
bspName: text("bsp_name"),
mapName: text("map_name"),

// ETJump features detected from BSP (stored as JSON array)
features: text("features", { mode: "json" }).$type<string[]>().default([]),

// Levelshot image path (extracted from PK3)
levelshotPath: text("levelshot_path"),

// User-provided metadata
displayName: text("display_name"),
author: text("author"),
releaseYear: integer("release_year"),
difficulty: text("difficulty"),
mapTypes: text("map_types", { mode: "json" }).$type<string[]>().default([]), // gamma, customs, originals
tags: text("tags", { mode: "json" }).$type<string[]>().default([]),

// Stats
downloadCount: integer("download_count").default(0),

// Status (SQLite uses 0/1 for boolean)
isPublished: integer("is_published", { mode: "boolean" }).default(false),

// Timestamps (stored as ISO strings)
createdAt: text("created_at").default(sql`(datetime('now'))`),
updatedAt: text("updated_at").default(sql`(datetime('now'))`),
});

export type Map = typeof maps.$inferSelect;
export type NewMap = typeof maps.$inferInsert;
59 changes: 59 additions & 0 deletions maps-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import mapsRoutes from "./routes/maps";
import { ensureStorageDir } from "./services/storage";

const app = new Hono();

// Global error handler
app.onError((err, c) => {
console.error("Unhandled error:", err);
return c.json({ success: false, error: "Internal server error" }, 500);
});

// Middleware
app.use("*", logger());

const CORS_ORIGINS = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(",").map((o) => o.trim())
: ["http://localhost:5173", "http://localhost:3000"];

app.use(
"*",
cors({
origin: CORS_ORIGINS,
allowMethods: ["GET", "POST", "PUT", "DELETE"],
allowHeaders: ["Content-Type", "X-Upload-Key"],
})
);

// Health check
app.get("/", (c) => {
return c.json({
name: "ETJump Maps API",
version: "0.1.0",
status: "ok",
});
});

// Routes
app.route("/api/maps", mapsRoutes);

// Ensure storage directory exists
try {
await ensureStorageDir();
} catch (err) {
console.error("Failed to create storage directories:", err);
process.exit(1);
}

// Start server
const port = parseInt(process.env.PORT || "3001");

console.log(`ETJump Maps API starting on port ${port}...`);

export default {
port,
fetch: app.fetch,
};
32 changes: 32 additions & 0 deletions maps-api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Context, Next } from "hono";
import { timingSafeEqual } from "crypto";

const UPLOAD_SECRET = process.env.UPLOAD_SECRET;

if (!UPLOAD_SECRET) {
console.warn("WARNING: UPLOAD_SECRET is not set. Protected routes will reject all requests.");
}

export async function requireAuth(c: Context, next: Next) {
const uploadKey = c.req.header("X-Upload-Key");

if (!UPLOAD_SECRET) {
return c.json(
{ success: false, error: "Server misconfigured: no upload secret set" },
500
);
}

if (
!uploadKey ||
uploadKey.length !== UPLOAD_SECRET.length ||
!timingSafeEqual(Buffer.from(uploadKey), Buffer.from(UPLOAD_SECRET))
) {
return c.json(
{ success: false, error: "Unauthorized: invalid or missing upload key" },
401
);
}

await next();
}
Loading