├── .github └── workflows │ ├── check.yaml │ └── db.yaml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── drizzle.config.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── index.ts ├── lib │ ├── auth │ │ ├── auth.ts │ │ └── middleware.ts │ ├── db │ │ ├── connection.ts │ │ ├── migrations │ │ │ ├── 0000_workable_talos.sql │ │ │ ├── 0001_faulty_slayback.sql │ │ │ ├── 0002_dapper_golden_guardian.sql │ │ │ ├── 0003_groovy_la_nuit.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ ├── 0001_snapshot.json │ │ │ │ ├── 0002_snapshot.json │ │ │ │ ├── 0003_snapshot.json │ │ │ │ └── _journal.json │ │ └── schema │ │ │ ├── asset │ │ │ ├── asset.ts │ │ │ ├── assetLink.ts │ │ │ ├── assetToTag.ts │ │ │ ├── downloadHistory.ts │ │ │ ├── savedAsset.ts │ │ │ └── tag.ts │ │ │ ├── category │ │ │ └── category.ts │ │ │ ├── game │ │ │ ├── game.ts │ │ │ └── gameToCategory.ts │ │ │ ├── index.ts │ │ │ └── user │ │ │ └── user.ts │ ├── handler.ts │ └── response-schemas.ts ├── routes │ ├── asset │ │ ├── approval-queue.ts │ │ ├── history.ts │ │ ├── id.ts │ │ ├── index.ts │ │ ├── search.ts │ │ └── upload.ts │ ├── category │ │ ├── all.ts │ │ ├── index.ts │ │ └── slug.ts │ ├── game │ │ ├── all.ts │ │ ├── index.ts │ │ └── slug.ts │ ├── tag │ │ ├── all.ts │ │ └── index.ts │ └── user │ │ ├── download-history.ts │ │ ├── index.ts │ │ ├── save-asset.ts │ │ ├── saved-asset-id.ts │ │ ├── saved-assets-list.ts │ │ ├── unsave-asset.ts │ │ └── update-attributes.ts └── scripts │ └── db │ ├── migrate.mts │ └── seed.ts ├── tsconfig.json └── wrangler.jsonc /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Check Code 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'dev' 7 | 8 | concurrency: 9 | group: ${{ github.job }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | prettier: 14 | name: Prettier 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 24.1.0 24 | 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v4 27 | with: 28 | version: 10.11.0 29 | 30 | - name: Install dependencies 31 | run: pnpm i 32 | 33 | - name: Prettier check 34 | run: pnpm run prettier:check 35 | 36 | typecheck: 37 | name: Typecheck 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout repo 41 | uses: actions/checkout@v4 42 | 43 | - name: Setup Node.js 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: 24.1.0 47 | 48 | - name: Install pnpm 49 | uses: pnpm/action-setup@v4 50 | with: 51 | version: 10.11.0 52 | 53 | - name: Install dependencies 54 | run: pnpm i 55 | 56 | - name: Typecheck 57 | run: pnpm run typecheck 58 | -------------------------------------------------------------------------------- /.github/workflows/db.yaml: -------------------------------------------------------------------------------- 1 | name: Run DB Test 2 | on: 3 | push: 4 | branches: 5 | - 'dev' 6 | 7 | jobs: 8 | tests: 9 | name: DB Test 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | 13 | services: 14 | sqld: 15 | image: ghcr.io/libsql/sqld:latest 16 | ports: 17 | - 8080:8080 18 | 19 | env: 20 | ENVIRONMENT: DEV 21 | TURSO_DEV_DATABASE_URL: http://127.0.0.1:8080 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 24.1.0 31 | 32 | - name: Install pnpm 33 | uses: pnpm/action-setup@v4 34 | with: 35 | version: 10.11.0 36 | 37 | - name: Install dependencies 38 | run: pnpm i 39 | 40 | - name: Create .dev.vars file 41 | run: | 42 | echo ENVIRONMENT=$ENVIRONMENT >> .dev.vars 43 | echo TURSO_DEV_DATABASE_URL=$TURSO_DEV_DATABASE_URL >> .dev.vars 44 | 45 | - name: Initialize Drizzle 46 | run: pnpm run drizzle:dev:init 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | 4 | # :3 5 | .claude/ 6 | .idea/ 7 | 8 | # dev 9 | .yarn/ 10 | !.yarn/releases 11 | .vscode/* 12 | !.vscode/launch.json 13 | !.vscode/*.code-snippets 14 | .idea/workspace.xml 15 | .idea/usage.statistics.xml 16 | .idea/shelf 17 | 18 | # deps 19 | node_modules/ 20 | .wrangler 21 | 22 | # env 23 | .env 24 | .env.production 25 | .dev.vars 26 | 27 | # logs 28 | logs/ 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | pnpm-debug.log* 34 | lerna-debug.log* 35 | 36 | # misc 37 | .DS_Store 38 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid", 6 | "printWidth": 120, 7 | "tabWidth": 4, 8 | "useTabs": false, 9 | "bracketSpacing": true, 10 | "bracketSameLine": false, 11 | "jsxBracketSameLine": false 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | skowt.cc's backend/api 2 | 3 | api subdomain: den.skowt.cc 4 | cdn subdomain: pack.skowt.cc 5 | bridge (cors proxy): bridge.skowt.cc 6 | 7 | - better-auth for discord authentication 8 | - turso for db 9 | - r2 for storage 10 | - hono as the backend 11 | - ratelimiting with do 12 | - fully typesafe openapi spec, using scalar to make it pretty 13 | - hosted entirely on cf workers 14 | 15 | this code is pretty much self documenting 16 | 17 | types for frontend gen (u need to get the yaml file from ref): 18 | 19 | `pnpm dlx typed-openapi "skowtcc-api.yaml" -o "api.zod.ts"` 20 | 21 | licensed under GNU General Public License v3.0 22 | 23 | authored by [@dromzeh](https://dromzeh.dev/) 24 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | dialect: 'turso', 5 | dbCredentials: { 6 | url: 'http://127.0.0.1:8080', 7 | }, 8 | schema: './src/lib/db/schema', 9 | out: './src/lib/db/migrations', 10 | strict: true, 11 | verbose: true, 12 | }) 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-skowt-cc", 3 | "version": "2.0.0b", 4 | "scripts": { 5 | "dev": "wrangler dev", 6 | "dev:setup": "drizzle-kit generate && tsx src/scripts/db/migrate.mts && pnpm dev", 7 | "wrangler:dev:local": "wrangler dev", 8 | "wrangler:dev:remote": "wrangler dev --remote", 9 | "wrangler:publish": "wrangler publish --minify", 10 | "lint": "eslint . --ext .ts", 11 | "lint:fix": "eslint . --ext .ts --fix", 12 | "typecheck": "tsc --noEmit", 13 | "prettier:write": "prettier --write .", 14 | "prettier:check": "prettier --check .", 15 | "drizzle:generate": "drizzle-kit generate", 16 | "drizzle:migrate": "tsx src/scripts/db/migrate.mts", 17 | "drizzle:seed": "tsx src/scripts/db/seed.ts", 18 | "drizzle:push": "drizzle-kit push", 19 | "drizzle:dev:init": "drizzle-kit generate && tsx src/scripts/db/migrate.mts", 20 | "drizzle:studio": "drizzle-kit studio --port 7331 --host 127.0.0.1 --verbose" 21 | }, 22 | "devDependencies": { 23 | "@asteasolutions/zod-to-openapi": "^7.3.0", 24 | "@cloudflare/workers-types": "^4.20250129.0", 25 | "@types/node": "^22.13.1", 26 | "dotenv": "^16.4.7", 27 | "drizzle-kit": "^0.25.0", 28 | "eslint": "^9.19.0", 29 | "husky": "^9.1.7", 30 | "tsx": "^4.19.2", 31 | "typescript": "^5.7.3", 32 | "wrangler": "4.25.0" 33 | }, 34 | "private": true, 35 | "dependencies": { 36 | "@axiomhq/js": "1.2.0", 37 | "@faker-js/faker": "^9.4.0", 38 | "@hono-rate-limiter/cloudflare": "^0.2.2", 39 | "@hono/swagger-ui": "^0.4.1", 40 | "@hono/zod-openapi": "^0.16.4", 41 | "@libsql/client": "0.14.0", 42 | "@oslojs/crypto": "^1.0.1", 43 | "@oslojs/encoding": "^1.1.0", 44 | "@scalar/hono-api-reference": "^0.5.170", 45 | "@typescript-eslint/eslint-plugin": "^8.23.0", 46 | "better-auth": "^1.2.10", 47 | "better-sqlite3": "^11.8.1", 48 | "dayjs": "^1.11.13", 49 | "drizzle-orm": "^0.34.1", 50 | "drizzle-zod": "^0.5.1", 51 | "hono": "^4.6.20", 52 | "hono-rate-limiter": "^0.4.2", 53 | "oslo": "^1.2.1", 54 | "prettier": "^3.4.2", 55 | "uuid": "^11.1.0", 56 | "vitest": "^2.1.9", 57 | "zod": "^3.24.1", 58 | "zod-error": "^1.5.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import { cors } from 'hono/cors' 3 | import { swaggerUI } from '@hono/swagger-ui' 4 | import { Env, AuthVariables } from '~/lib/handler' 5 | import { AssetHandler } from './routes/asset' 6 | import { GameHandler } from './routes/game' 7 | import { CategoryHandler } from './routes/category' 8 | import { TagHandler } from './routes/tag' 9 | import { UserHandler } from './routes/user' 10 | import { apiReference } from '@scalar/hono-api-reference' 11 | import { rateLimiter } from 'hono-rate-limiter' 12 | import { DurableObjectStore, DurableObjectRateLimiter } from '@hono-rate-limiter/cloudflare' 13 | import { createAuth } from './lib/auth/auth' 14 | 15 | const app = new OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }>() 16 | 17 | app.use( 18 | '/*', 19 | cors({ 20 | origin: [ 21 | // 'http://localhost:8787', 22 | // 'http://localhost:3000', 23 | 'https://skowt.cc', 24 | // 'https://staging.skowt.cc', 25 | ], 26 | allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], 27 | allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'], 28 | credentials: true, 29 | }), 30 | ) 31 | 32 | app.use((c, next) => 33 | rateLimiter<{ Bindings: Env; Variables: AuthVariables }>({ 34 | windowMs: 1 * 60 * 1000, 35 | limit: 10000, 36 | standardHeaders: 'draft-6', 37 | keyGenerator: c => c.req.header('CF-Connecting-IP') ?? '', 38 | store: new DurableObjectStore({ namespace: c.env.RATE_LIMITER }), 39 | })(c, next), 40 | ) 41 | 42 | app.all('/auth/*', async c => { 43 | const auth = createAuth(c.env) 44 | return auth.handler(c.req.raw) 45 | }) 46 | 47 | app.route('/asset', AssetHandler) 48 | app.route('/game', GameHandler) 49 | app.route('/category', CategoryHandler) 50 | app.route('/tag', TagHandler) 51 | app.route('/user', UserHandler) 52 | 53 | app.get('/', c => { 54 | return c.json({ message: 'api is up!', swagger: '/swagger', reference: '/reference' }) 55 | }) 56 | 57 | app.get('/swagger', swaggerUI({ url: '/doc' })) 58 | 59 | app.doc('/doc', { 60 | openapi: '3.0.0', 61 | info: { 62 | version: '1.0.0', 63 | title: 'skowt.cc API', 64 | description: 'API for skowt.cc', 65 | }, 66 | servers: [ 67 | { 68 | url: 'https://den.skowt.cc', 69 | description: 'Production server', 70 | }, 71 | { 72 | url: 'http://localhost:8787', 73 | description: 'Development server', 74 | }, 75 | ], 76 | }) 77 | 78 | app.get( 79 | '/reference', 80 | apiReference({ 81 | spec: { 82 | url: '/doc', 83 | }, 84 | theme: 'bluePlanet', 85 | }), 86 | ) 87 | 88 | app.onError((err, c) => { 89 | console.error('[API] Internal server error: ', err) 90 | return c.json( 91 | { 92 | success: false, 93 | message: 'Internal server error. Please contact support@originoid.co if this issue persists.', 94 | }, 95 | 500, 96 | ) 97 | }) 98 | 99 | export { DurableObjectRateLimiter } 100 | 101 | export default app 102 | -------------------------------------------------------------------------------- /src/lib/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from 'better-auth' 2 | import { drizzleAdapter } from 'better-auth/adapters/drizzle' 3 | import { name } from 'drizzle-orm' 4 | import { getConnection } from '~/lib/db/connection' 5 | import * as schema from '~/lib/db/schema' 6 | import { Env } from '~/lib/handler' 7 | 8 | export function createAuth(env: Env) { 9 | const { drizzle } = getConnection(env) 10 | 11 | return betterAuth({ 12 | basePath: '/auth', 13 | database: drizzleAdapter(drizzle, { 14 | provider: 'sqlite', 15 | schema: { 16 | ...schema, 17 | user: schema.user, 18 | session: schema.session, 19 | account: schema.account, 20 | verification: schema.verification, 21 | }, 22 | }), 23 | trustedOrigins: [ 24 | // 'http://localhost:8787', 25 | // 'http://localhost:3000', 26 | 'https://skowt.cc', 27 | // 'https://staging.skowt.cc', 28 | ], 29 | secret: env.BETTER_AUTH_SECRET, 30 | baseURL: env.BETTER_AUTH_URL, 31 | socialProviders: { 32 | discord: { 33 | overrideUserInfoOnSignIn: true, 34 | clientId: env.DISCORD_CLIENT_ID as string, 35 | clientSecret: env.DISCORD_CLIENT_SECRET as string, 36 | mapProfileToUser: async profile => { 37 | return { 38 | name: profile.username, 39 | displayName: profile.global_name || profile.username, 40 | } 41 | }, 42 | }, 43 | }, 44 | emailAndPassword: { 45 | enabled: false, 46 | }, 47 | session: { 48 | cookieCache: { 49 | enabled: true, 50 | maxAge: 5 * 60, // (seconds) 51 | }, 52 | }, 53 | user: { 54 | modelName: 'user', 55 | additionalFields: { 56 | role: { 57 | type: 'string', 58 | required: false, 59 | input: false, 60 | defaultValue: 'user', 61 | }, 62 | displayName: { 63 | type: 'string', 64 | required: false, 65 | }, 66 | }, 67 | }, 68 | }) 69 | } 70 | 71 | export type Auth = ReturnType 72 | -------------------------------------------------------------------------------- /src/lib/auth/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from 'hono/factory' 2 | import { createAuth, type Auth } from '~/lib/auth/auth' 3 | import type { Session, User } from 'better-auth' 4 | 5 | export interface Env { 6 | BETTER_AUTH_SECRET: string 7 | BETTER_AUTH_URL: string 8 | DISCORD_CLIENT_ID: string 9 | DISCORD_CLIENT_SECRET: string 10 | TURSO_DATABASE_URL: string 11 | TURSO_DATABASE_AUTH_TOKEN?: string 12 | DISCORD_WEBHOOK?: string 13 | CDN: R2Bucket 14 | RATE_LIMITER: DurableObjectNamespace 15 | } 16 | 17 | export interface AuthVariables { 18 | auth: Auth 19 | user?: User & { 20 | role: string 21 | displayName?: string 22 | } 23 | session?: Session 24 | } 25 | 26 | export const authMiddleware = createMiddleware<{ 27 | Bindings: Env 28 | Variables: AuthVariables 29 | }>(async (c, next) => { 30 | const auth = createAuth({ 31 | BETTER_AUTH_SECRET: c.env.BETTER_AUTH_SECRET, 32 | BETTER_AUTH_URL: c.env.BETTER_AUTH_URL, 33 | DISCORD_CLIENT_ID: c.env.DISCORD_CLIENT_ID, 34 | DISCORD_CLIENT_SECRET: c.env.DISCORD_CLIENT_SECRET, 35 | TURSO_DATABASE_URL: c.env.TURSO_DATABASE_URL, 36 | TURSO_DATABASE_AUTH_TOKEN: c.env.TURSO_DATABASE_AUTH_TOKEN, 37 | CDN: c.env.CDN, 38 | RATE_LIMITER: c.env.RATE_LIMITER, 39 | }) 40 | 41 | c.set('auth', auth) 42 | await next() 43 | }) 44 | 45 | export const requireAuth = createMiddleware<{ 46 | Bindings: Env 47 | Variables: AuthVariables 48 | }>(async (c, next) => { 49 | const auth = c.get('auth') 50 | 51 | try { 52 | const session = await auth.api.getSession({ 53 | headers: c.req.raw.headers, 54 | }) 55 | 56 | if (!session || !session.user) { 57 | return c.json({ success: 'False', error: 'Unauthorized' }, 401) 58 | } 59 | 60 | c.set('user', session.user as User & { role: string; displayName?: string }) 61 | c.set('session', session.session) 62 | 63 | await next() 64 | } catch (error) { 65 | return c.json({ success: false, error: 'Authentication middleware failed' }, 401) 66 | } 67 | }) 68 | 69 | export const requireAdminOrContributor = createMiddleware<{ 70 | Bindings: Env 71 | Variables: AuthVariables 72 | }>(async (ctx, next) => { 73 | const user = ctx.get('user') 74 | if (!user || (user.role !== 'admin' && user.role !== 'contributor')) { 75 | return ctx.json({ success: false, message: 'Forbidden: Only admin or contributor can access this route' }, 403) 76 | } 77 | await next() 78 | }) 79 | 80 | export const requireAdmin = createMiddleware<{ 81 | Bindings: Env 82 | Variables: AuthVariables 83 | }>(async (ctx, next) => { 84 | const user = ctx.get('user') 85 | if (!user || user.role !== 'admin') { 86 | return ctx.json({ success: false, message: 'Forbidden: Only admin can access this route' }, 403) 87 | } 88 | await next() 89 | }) 90 | -------------------------------------------------------------------------------- /src/lib/db/connection.ts: -------------------------------------------------------------------------------- 1 | import { drizzle as drizzleORM } from 'drizzle-orm/libsql' 2 | import { createClient } from '@libsql/client/web' 3 | import { Logger } from 'drizzle-orm/logger' 4 | import type { Env } from '~/lib/handler' 5 | import * as schema from '~/lib/db/schema' 6 | 7 | class LoggerWrapper implements Logger { 8 | logQuery(query: string, params: unknown[]): void {} 9 | } 10 | 11 | export function getConnection(env: Env) { 12 | if (!env.TURSO_DATABASE_URL || !env.TURSO_DATABASE_AUTH_TOKEN) { 13 | const missingVars = [ 14 | !env.TURSO_DATABASE_URL && 'TURSO_DATABASE_URL', 15 | !env.TURSO_DATABASE_AUTH_TOKEN && 'TURSO_DATABASE_AUTH_TOKEN', 16 | ].filter(Boolean) 17 | 18 | if (missingVars.length > 0) { 19 | throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`) 20 | } 21 | } 22 | 23 | const turso = createClient({ 24 | url: env.TURSO_DATABASE_URL, 25 | authToken: env.TURSO_DATABASE_AUTH_TOKEN, 26 | }) 27 | 28 | const drizzle = drizzleORM(turso, { 29 | schema: { 30 | ...schema, 31 | }, 32 | logger: new LoggerWrapper(), 33 | }) 34 | 35 | return { 36 | drizzle, 37 | turso, 38 | } 39 | } 40 | 41 | export type DrizzleInstance = ReturnType['drizzle'] 42 | export type TursoInstance = ReturnType['turso'] 43 | -------------------------------------------------------------------------------- /src/lib/db/migrations/0000_workable_talos.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `asset` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `name` text NOT NULL, 4 | `game_id` text NOT NULL, 5 | `category_id` text NOT NULL, 6 | `created_at` integer NOT NULL, 7 | `uploaded_by` text NOT NULL, 8 | `download_count` integer DEFAULT 0 NOT NULL, 9 | `view_count` integer DEFAULT 0 NOT NULL, 10 | `is_suggestive` integer DEFAULT false NOT NULL, 11 | `status` text DEFAULT 'pending' NOT NULL, 12 | `hash` text NOT NULL, 13 | `size` integer NOT NULL, 14 | `extension` text NOT NULL, 15 | FOREIGN KEY (`game_id`) REFERENCES `game`(`id`) ON UPDATE no action ON DELETE cascade, 16 | FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade, 17 | FOREIGN KEY (`uploaded_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 18 | ); 19 | --> statement-breakpoint 20 | CREATE TABLE `asset_link` ( 21 | `id` text PRIMARY KEY NOT NULL, 22 | `asset_id` text NOT NULL, 23 | `to_asset_id` text NOT NULL, 24 | `created_at` integer NOT NULL, 25 | FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE no action ON DELETE cascade, 26 | FOREIGN KEY (`to_asset_id`) REFERENCES `asset`(`id`) ON UPDATE no action ON DELETE cascade 27 | ); 28 | --> statement-breakpoint 29 | CREATE TABLE `asset_to_tag` ( 30 | `id` text PRIMARY KEY NOT NULL, 31 | `asset_id` text NOT NULL, 32 | `tag_id` text NOT NULL, 33 | FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE no action ON DELETE cascade, 34 | FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade 35 | ); 36 | --> statement-breakpoint 37 | CREATE TABLE `download_history` ( 38 | `id` text PRIMARY KEY NOT NULL, 39 | `user_id` text NOT NULL, 40 | `created_at` integer NOT NULL, 41 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 42 | ); 43 | --> statement-breakpoint 44 | CREATE TABLE `download_history_to_asset` ( 45 | `id` text PRIMARY KEY NOT NULL, 46 | `download_history_id` text NOT NULL, 47 | `asset_id` text NOT NULL, 48 | FOREIGN KEY (`download_history_id`) REFERENCES `download_history`(`id`) ON UPDATE no action ON DELETE no action, 49 | FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE no action ON DELETE no action 50 | ); 51 | --> statement-breakpoint 52 | CREATE TABLE `saved_asset` ( 53 | `id` text PRIMARY KEY NOT NULL, 54 | `user_id` text NOT NULL, 55 | `asset_id` text NOT NULL, 56 | `created_at` integer NOT NULL, 57 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, 58 | FOREIGN KEY (`asset_id`) REFERENCES `asset`(`id`) ON UPDATE no action ON DELETE cascade 59 | ); 60 | --> statement-breakpoint 61 | CREATE TABLE `tag` ( 62 | `id` text PRIMARY KEY NOT NULL, 63 | `name` text NOT NULL, 64 | `slug` text NOT NULL, 65 | `color` text 66 | ); 67 | --> statement-breakpoint 68 | CREATE UNIQUE INDEX `tag_slug_unique` ON `tag` (`slug`);--> statement-breakpoint 69 | CREATE TABLE `category` ( 70 | `id` text PRIMARY KEY NOT NULL, 71 | `name` text NOT NULL, 72 | `slug` text NOT NULL 73 | ); 74 | --> statement-breakpoint 75 | CREATE UNIQUE INDEX `category_slug_unique` ON `category` (`slug`);--> statement-breakpoint 76 | CREATE TABLE `game` ( 77 | `id` text PRIMARY KEY NOT NULL, 78 | `slug` text NOT NULL, 79 | `name` text NOT NULL, 80 | `last_updated` integer NOT NULL, 81 | `asset_count` integer DEFAULT 0 NOT NULL 82 | ); 83 | --> statement-breakpoint 84 | CREATE UNIQUE INDEX `game_slug_unique` ON `game` (`slug`);--> statement-breakpoint 85 | CREATE TABLE `account` ( 86 | `id` text PRIMARY KEY NOT NULL, 87 | `account_id` text NOT NULL, 88 | `provider_id` text NOT NULL, 89 | `user_id` text NOT NULL, 90 | `access_token` text, 91 | `refresh_token` text, 92 | `id_token` text, 93 | `access_token_expires_at` integer, 94 | `refresh_token_expires_at` integer, 95 | `scope` text, 96 | `password` text, 97 | `created_at` integer NOT NULL, 98 | `updated_at` integer NOT NULL, 99 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 100 | ); 101 | --> statement-breakpoint 102 | CREATE TABLE `session` ( 103 | `id` text PRIMARY KEY NOT NULL, 104 | `expires_at` integer NOT NULL, 105 | `token` text NOT NULL, 106 | `user_id` text NOT NULL, 107 | `ip_address` text, 108 | `user_agent` text, 109 | `created_at` integer NOT NULL, 110 | `updated_at` integer NOT NULL, 111 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 112 | ); 113 | --> statement-breakpoint 114 | CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint 115 | CREATE TABLE `user` ( 116 | `id` text PRIMARY KEY NOT NULL, 117 | `name` text NOT NULL, 118 | `username` text, 119 | `email` text NOT NULL, 120 | `email_verified` integer DEFAULT false NOT NULL, 121 | `image` text, 122 | `created_at` integer NOT NULL, 123 | `updated_at` integer NOT NULL, 124 | `role` text DEFAULT 'user' NOT NULL 125 | ); 126 | --> statement-breakpoint 127 | CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint 128 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint 129 | CREATE TABLE `verification` ( 130 | `id` text PRIMARY KEY NOT NULL, 131 | `identifier` text NOT NULL, 132 | `value` text NOT NULL, 133 | `expires_at` integer NOT NULL, 134 | `created_at` integer NOT NULL, 135 | `updated_at` integer NOT NULL 136 | ); 137 | -------------------------------------------------------------------------------- /src/lib/db/migrations/0001_faulty_slayback.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `game_to_category` ( 2 | `game_id` text NOT NULL, 3 | `category_id` text NOT NULL, 4 | PRIMARY KEY(`game_id`, `category_id`), 5 | FOREIGN KEY (`game_id`) REFERENCES `game`(`id`) ON UPDATE no action ON DELETE cascade, 6 | FOREIGN KEY (`category_id`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE cascade 7 | ); 8 | -------------------------------------------------------------------------------- /src/lib/db/migrations/0002_dapper_golden_guardian.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS `user_username_unique`;--> statement-breakpoint 2 | CREATE UNIQUE INDEX `user_name_unique` ON `user` (`name`); -------------------------------------------------------------------------------- /src/lib/db/migrations/0003_groovy_la_nuit.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `user` RENAME COLUMN "username" TO "display_name"; -------------------------------------------------------------------------------- /src/lib/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "af78e694-eb58-44db-9350-307ee20bf9a9", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "asset": { 8 | "name": "asset", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "game_id": { 25 | "name": "game_id", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "category_id": { 32 | "name": "category_id", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "created_at": { 39 | "name": "created_at", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "uploaded_by": { 46 | "name": "uploaded_by", 47 | "type": "text", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false 51 | }, 52 | "download_count": { 53 | "name": "download_count", 54 | "type": "integer", 55 | "primaryKey": false, 56 | "notNull": true, 57 | "autoincrement": false, 58 | "default": 0 59 | }, 60 | "view_count": { 61 | "name": "view_count", 62 | "type": "integer", 63 | "primaryKey": false, 64 | "notNull": true, 65 | "autoincrement": false, 66 | "default": 0 67 | }, 68 | "is_suggestive": { 69 | "name": "is_suggestive", 70 | "type": "integer", 71 | "primaryKey": false, 72 | "notNull": true, 73 | "autoincrement": false, 74 | "default": false 75 | }, 76 | "status": { 77 | "name": "status", 78 | "type": "text", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false, 82 | "default": "'pending'" 83 | }, 84 | "hash": { 85 | "name": "hash", 86 | "type": "text", 87 | "primaryKey": false, 88 | "notNull": true, 89 | "autoincrement": false 90 | }, 91 | "size": { 92 | "name": "size", 93 | "type": "integer", 94 | "primaryKey": false, 95 | "notNull": true, 96 | "autoincrement": false 97 | }, 98 | "extension": { 99 | "name": "extension", 100 | "type": "text", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | } 105 | }, 106 | "indexes": {}, 107 | "foreignKeys": { 108 | "asset_game_id_game_id_fk": { 109 | "name": "asset_game_id_game_id_fk", 110 | "tableFrom": "asset", 111 | "tableTo": "game", 112 | "columnsFrom": ["game_id"], 113 | "columnsTo": ["id"], 114 | "onDelete": "cascade", 115 | "onUpdate": "no action" 116 | }, 117 | "asset_category_id_category_id_fk": { 118 | "name": "asset_category_id_category_id_fk", 119 | "tableFrom": "asset", 120 | "tableTo": "category", 121 | "columnsFrom": ["category_id"], 122 | "columnsTo": ["id"], 123 | "onDelete": "cascade", 124 | "onUpdate": "no action" 125 | }, 126 | "asset_uploaded_by_user_id_fk": { 127 | "name": "asset_uploaded_by_user_id_fk", 128 | "tableFrom": "asset", 129 | "tableTo": "user", 130 | "columnsFrom": ["uploaded_by"], 131 | "columnsTo": ["id"], 132 | "onDelete": "no action", 133 | "onUpdate": "no action" 134 | } 135 | }, 136 | "compositePrimaryKeys": {}, 137 | "uniqueConstraints": {} 138 | }, 139 | "asset_link": { 140 | "name": "asset_link", 141 | "columns": { 142 | "id": { 143 | "name": "id", 144 | "type": "text", 145 | "primaryKey": true, 146 | "notNull": true, 147 | "autoincrement": false 148 | }, 149 | "asset_id": { 150 | "name": "asset_id", 151 | "type": "text", 152 | "primaryKey": false, 153 | "notNull": true, 154 | "autoincrement": false 155 | }, 156 | "to_asset_id": { 157 | "name": "to_asset_id", 158 | "type": "text", 159 | "primaryKey": false, 160 | "notNull": true, 161 | "autoincrement": false 162 | }, 163 | "created_at": { 164 | "name": "created_at", 165 | "type": "integer", 166 | "primaryKey": false, 167 | "notNull": true, 168 | "autoincrement": false 169 | } 170 | }, 171 | "indexes": {}, 172 | "foreignKeys": { 173 | "asset_link_asset_id_asset_id_fk": { 174 | "name": "asset_link_asset_id_asset_id_fk", 175 | "tableFrom": "asset_link", 176 | "tableTo": "asset", 177 | "columnsFrom": ["asset_id"], 178 | "columnsTo": ["id"], 179 | "onDelete": "cascade", 180 | "onUpdate": "no action" 181 | }, 182 | "asset_link_to_asset_id_asset_id_fk": { 183 | "name": "asset_link_to_asset_id_asset_id_fk", 184 | "tableFrom": "asset_link", 185 | "tableTo": "asset", 186 | "columnsFrom": ["to_asset_id"], 187 | "columnsTo": ["id"], 188 | "onDelete": "cascade", 189 | "onUpdate": "no action" 190 | } 191 | }, 192 | "compositePrimaryKeys": {}, 193 | "uniqueConstraints": {} 194 | }, 195 | "asset_to_tag": { 196 | "name": "asset_to_tag", 197 | "columns": { 198 | "id": { 199 | "name": "id", 200 | "type": "text", 201 | "primaryKey": true, 202 | "notNull": true, 203 | "autoincrement": false 204 | }, 205 | "asset_id": { 206 | "name": "asset_id", 207 | "type": "text", 208 | "primaryKey": false, 209 | "notNull": true, 210 | "autoincrement": false 211 | }, 212 | "tag_id": { 213 | "name": "tag_id", 214 | "type": "text", 215 | "primaryKey": false, 216 | "notNull": true, 217 | "autoincrement": false 218 | } 219 | }, 220 | "indexes": {}, 221 | "foreignKeys": { 222 | "asset_to_tag_asset_id_asset_id_fk": { 223 | "name": "asset_to_tag_asset_id_asset_id_fk", 224 | "tableFrom": "asset_to_tag", 225 | "tableTo": "asset", 226 | "columnsFrom": ["asset_id"], 227 | "columnsTo": ["id"], 228 | "onDelete": "cascade", 229 | "onUpdate": "no action" 230 | }, 231 | "asset_to_tag_tag_id_tag_id_fk": { 232 | "name": "asset_to_tag_tag_id_tag_id_fk", 233 | "tableFrom": "asset_to_tag", 234 | "tableTo": "tag", 235 | "columnsFrom": ["tag_id"], 236 | "columnsTo": ["id"], 237 | "onDelete": "cascade", 238 | "onUpdate": "no action" 239 | } 240 | }, 241 | "compositePrimaryKeys": {}, 242 | "uniqueConstraints": {} 243 | }, 244 | "download_history": { 245 | "name": "download_history", 246 | "columns": { 247 | "id": { 248 | "name": "id", 249 | "type": "text", 250 | "primaryKey": true, 251 | "notNull": true, 252 | "autoincrement": false 253 | }, 254 | "user_id": { 255 | "name": "user_id", 256 | "type": "text", 257 | "primaryKey": false, 258 | "notNull": true, 259 | "autoincrement": false 260 | }, 261 | "created_at": { 262 | "name": "created_at", 263 | "type": "integer", 264 | "primaryKey": false, 265 | "notNull": true, 266 | "autoincrement": false 267 | } 268 | }, 269 | "indexes": {}, 270 | "foreignKeys": { 271 | "download_history_user_id_user_id_fk": { 272 | "name": "download_history_user_id_user_id_fk", 273 | "tableFrom": "download_history", 274 | "tableTo": "user", 275 | "columnsFrom": ["user_id"], 276 | "columnsTo": ["id"], 277 | "onDelete": "no action", 278 | "onUpdate": "no action" 279 | } 280 | }, 281 | "compositePrimaryKeys": {}, 282 | "uniqueConstraints": {} 283 | }, 284 | "download_history_to_asset": { 285 | "name": "download_history_to_asset", 286 | "columns": { 287 | "id": { 288 | "name": "id", 289 | "type": "text", 290 | "primaryKey": true, 291 | "notNull": true, 292 | "autoincrement": false 293 | }, 294 | "download_history_id": { 295 | "name": "download_history_id", 296 | "type": "text", 297 | "primaryKey": false, 298 | "notNull": true, 299 | "autoincrement": false 300 | }, 301 | "asset_id": { 302 | "name": "asset_id", 303 | "type": "text", 304 | "primaryKey": false, 305 | "notNull": true, 306 | "autoincrement": false 307 | } 308 | }, 309 | "indexes": {}, 310 | "foreignKeys": { 311 | "download_history_to_asset_download_history_id_download_history_id_fk": { 312 | "name": "download_history_to_asset_download_history_id_download_history_id_fk", 313 | "tableFrom": "download_history_to_asset", 314 | "tableTo": "download_history", 315 | "columnsFrom": ["download_history_id"], 316 | "columnsTo": ["id"], 317 | "onDelete": "no action", 318 | "onUpdate": "no action" 319 | }, 320 | "download_history_to_asset_asset_id_asset_id_fk": { 321 | "name": "download_history_to_asset_asset_id_asset_id_fk", 322 | "tableFrom": "download_history_to_asset", 323 | "tableTo": "asset", 324 | "columnsFrom": ["asset_id"], 325 | "columnsTo": ["id"], 326 | "onDelete": "no action", 327 | "onUpdate": "no action" 328 | } 329 | }, 330 | "compositePrimaryKeys": {}, 331 | "uniqueConstraints": {} 332 | }, 333 | "saved_asset": { 334 | "name": "saved_asset", 335 | "columns": { 336 | "id": { 337 | "name": "id", 338 | "type": "text", 339 | "primaryKey": true, 340 | "notNull": true, 341 | "autoincrement": false 342 | }, 343 | "user_id": { 344 | "name": "user_id", 345 | "type": "text", 346 | "primaryKey": false, 347 | "notNull": true, 348 | "autoincrement": false 349 | }, 350 | "asset_id": { 351 | "name": "asset_id", 352 | "type": "text", 353 | "primaryKey": false, 354 | "notNull": true, 355 | "autoincrement": false 356 | }, 357 | "created_at": { 358 | "name": "created_at", 359 | "type": "integer", 360 | "primaryKey": false, 361 | "notNull": true, 362 | "autoincrement": false 363 | } 364 | }, 365 | "indexes": {}, 366 | "foreignKeys": { 367 | "saved_asset_user_id_user_id_fk": { 368 | "name": "saved_asset_user_id_user_id_fk", 369 | "tableFrom": "saved_asset", 370 | "tableTo": "user", 371 | "columnsFrom": ["user_id"], 372 | "columnsTo": ["id"], 373 | "onDelete": "cascade", 374 | "onUpdate": "no action" 375 | }, 376 | "saved_asset_asset_id_asset_id_fk": { 377 | "name": "saved_asset_asset_id_asset_id_fk", 378 | "tableFrom": "saved_asset", 379 | "tableTo": "asset", 380 | "columnsFrom": ["asset_id"], 381 | "columnsTo": ["id"], 382 | "onDelete": "cascade", 383 | "onUpdate": "no action" 384 | } 385 | }, 386 | "compositePrimaryKeys": {}, 387 | "uniqueConstraints": {} 388 | }, 389 | "tag": { 390 | "name": "tag", 391 | "columns": { 392 | "id": { 393 | "name": "id", 394 | "type": "text", 395 | "primaryKey": true, 396 | "notNull": true, 397 | "autoincrement": false 398 | }, 399 | "name": { 400 | "name": "name", 401 | "type": "text", 402 | "primaryKey": false, 403 | "notNull": true, 404 | "autoincrement": false 405 | }, 406 | "slug": { 407 | "name": "slug", 408 | "type": "text", 409 | "primaryKey": false, 410 | "notNull": true, 411 | "autoincrement": false 412 | }, 413 | "color": { 414 | "name": "color", 415 | "type": "text", 416 | "primaryKey": false, 417 | "notNull": false, 418 | "autoincrement": false 419 | } 420 | }, 421 | "indexes": { 422 | "tag_slug_unique": { 423 | "name": "tag_slug_unique", 424 | "columns": ["slug"], 425 | "isUnique": true 426 | } 427 | }, 428 | "foreignKeys": {}, 429 | "compositePrimaryKeys": {}, 430 | "uniqueConstraints": {} 431 | }, 432 | "category": { 433 | "name": "category", 434 | "columns": { 435 | "id": { 436 | "name": "id", 437 | "type": "text", 438 | "primaryKey": true, 439 | "notNull": true, 440 | "autoincrement": false 441 | }, 442 | "name": { 443 | "name": "name", 444 | "type": "text", 445 | "primaryKey": false, 446 | "notNull": true, 447 | "autoincrement": false 448 | }, 449 | "slug": { 450 | "name": "slug", 451 | "type": "text", 452 | "primaryKey": false, 453 | "notNull": true, 454 | "autoincrement": false 455 | } 456 | }, 457 | "indexes": { 458 | "category_slug_unique": { 459 | "name": "category_slug_unique", 460 | "columns": ["slug"], 461 | "isUnique": true 462 | } 463 | }, 464 | "foreignKeys": {}, 465 | "compositePrimaryKeys": {}, 466 | "uniqueConstraints": {} 467 | }, 468 | "game": { 469 | "name": "game", 470 | "columns": { 471 | "id": { 472 | "name": "id", 473 | "type": "text", 474 | "primaryKey": true, 475 | "notNull": true, 476 | "autoincrement": false 477 | }, 478 | "slug": { 479 | "name": "slug", 480 | "type": "text", 481 | "primaryKey": false, 482 | "notNull": true, 483 | "autoincrement": false 484 | }, 485 | "name": { 486 | "name": "name", 487 | "type": "text", 488 | "primaryKey": false, 489 | "notNull": true, 490 | "autoincrement": false 491 | }, 492 | "last_updated": { 493 | "name": "last_updated", 494 | "type": "integer", 495 | "primaryKey": false, 496 | "notNull": true, 497 | "autoincrement": false 498 | }, 499 | "asset_count": { 500 | "name": "asset_count", 501 | "type": "integer", 502 | "primaryKey": false, 503 | "notNull": true, 504 | "autoincrement": false, 505 | "default": 0 506 | } 507 | }, 508 | "indexes": { 509 | "game_slug_unique": { 510 | "name": "game_slug_unique", 511 | "columns": ["slug"], 512 | "isUnique": true 513 | } 514 | }, 515 | "foreignKeys": {}, 516 | "compositePrimaryKeys": {}, 517 | "uniqueConstraints": {} 518 | }, 519 | "account": { 520 | "name": "account", 521 | "columns": { 522 | "id": { 523 | "name": "id", 524 | "type": "text", 525 | "primaryKey": true, 526 | "notNull": true, 527 | "autoincrement": false 528 | }, 529 | "account_id": { 530 | "name": "account_id", 531 | "type": "text", 532 | "primaryKey": false, 533 | "notNull": true, 534 | "autoincrement": false 535 | }, 536 | "provider_id": { 537 | "name": "provider_id", 538 | "type": "text", 539 | "primaryKey": false, 540 | "notNull": true, 541 | "autoincrement": false 542 | }, 543 | "user_id": { 544 | "name": "user_id", 545 | "type": "text", 546 | "primaryKey": false, 547 | "notNull": true, 548 | "autoincrement": false 549 | }, 550 | "access_token": { 551 | "name": "access_token", 552 | "type": "text", 553 | "primaryKey": false, 554 | "notNull": false, 555 | "autoincrement": false 556 | }, 557 | "refresh_token": { 558 | "name": "refresh_token", 559 | "type": "text", 560 | "primaryKey": false, 561 | "notNull": false, 562 | "autoincrement": false 563 | }, 564 | "id_token": { 565 | "name": "id_token", 566 | "type": "text", 567 | "primaryKey": false, 568 | "notNull": false, 569 | "autoincrement": false 570 | }, 571 | "access_token_expires_at": { 572 | "name": "access_token_expires_at", 573 | "type": "integer", 574 | "primaryKey": false, 575 | "notNull": false, 576 | "autoincrement": false 577 | }, 578 | "refresh_token_expires_at": { 579 | "name": "refresh_token_expires_at", 580 | "type": "integer", 581 | "primaryKey": false, 582 | "notNull": false, 583 | "autoincrement": false 584 | }, 585 | "scope": { 586 | "name": "scope", 587 | "type": "text", 588 | "primaryKey": false, 589 | "notNull": false, 590 | "autoincrement": false 591 | }, 592 | "password": { 593 | "name": "password", 594 | "type": "text", 595 | "primaryKey": false, 596 | "notNull": false, 597 | "autoincrement": false 598 | }, 599 | "created_at": { 600 | "name": "created_at", 601 | "type": "integer", 602 | "primaryKey": false, 603 | "notNull": true, 604 | "autoincrement": false 605 | }, 606 | "updated_at": { 607 | "name": "updated_at", 608 | "type": "integer", 609 | "primaryKey": false, 610 | "notNull": true, 611 | "autoincrement": false 612 | } 613 | }, 614 | "indexes": {}, 615 | "foreignKeys": { 616 | "account_user_id_user_id_fk": { 617 | "name": "account_user_id_user_id_fk", 618 | "tableFrom": "account", 619 | "tableTo": "user", 620 | "columnsFrom": ["user_id"], 621 | "columnsTo": ["id"], 622 | "onDelete": "cascade", 623 | "onUpdate": "no action" 624 | } 625 | }, 626 | "compositePrimaryKeys": {}, 627 | "uniqueConstraints": {} 628 | }, 629 | "session": { 630 | "name": "session", 631 | "columns": { 632 | "id": { 633 | "name": "id", 634 | "type": "text", 635 | "primaryKey": true, 636 | "notNull": true, 637 | "autoincrement": false 638 | }, 639 | "expires_at": { 640 | "name": "expires_at", 641 | "type": "integer", 642 | "primaryKey": false, 643 | "notNull": true, 644 | "autoincrement": false 645 | }, 646 | "token": { 647 | "name": "token", 648 | "type": "text", 649 | "primaryKey": false, 650 | "notNull": true, 651 | "autoincrement": false 652 | }, 653 | "user_id": { 654 | "name": "user_id", 655 | "type": "text", 656 | "primaryKey": false, 657 | "notNull": true, 658 | "autoincrement": false 659 | }, 660 | "ip_address": { 661 | "name": "ip_address", 662 | "type": "text", 663 | "primaryKey": false, 664 | "notNull": false, 665 | "autoincrement": false 666 | }, 667 | "user_agent": { 668 | "name": "user_agent", 669 | "type": "text", 670 | "primaryKey": false, 671 | "notNull": false, 672 | "autoincrement": false 673 | }, 674 | "created_at": { 675 | "name": "created_at", 676 | "type": "integer", 677 | "primaryKey": false, 678 | "notNull": true, 679 | "autoincrement": false 680 | }, 681 | "updated_at": { 682 | "name": "updated_at", 683 | "type": "integer", 684 | "primaryKey": false, 685 | "notNull": true, 686 | "autoincrement": false 687 | } 688 | }, 689 | "indexes": { 690 | "session_token_unique": { 691 | "name": "session_token_unique", 692 | "columns": ["token"], 693 | "isUnique": true 694 | } 695 | }, 696 | "foreignKeys": { 697 | "session_user_id_user_id_fk": { 698 | "name": "session_user_id_user_id_fk", 699 | "tableFrom": "session", 700 | "tableTo": "user", 701 | "columnsFrom": ["user_id"], 702 | "columnsTo": ["id"], 703 | "onDelete": "cascade", 704 | "onUpdate": "no action" 705 | } 706 | }, 707 | "compositePrimaryKeys": {}, 708 | "uniqueConstraints": {} 709 | }, 710 | "user": { 711 | "name": "user", 712 | "columns": { 713 | "id": { 714 | "name": "id", 715 | "type": "text", 716 | "primaryKey": true, 717 | "notNull": true, 718 | "autoincrement": false 719 | }, 720 | "name": { 721 | "name": "name", 722 | "type": "text", 723 | "primaryKey": false, 724 | "notNull": true, 725 | "autoincrement": false 726 | }, 727 | "username": { 728 | "name": "username", 729 | "type": "text", 730 | "primaryKey": false, 731 | "notNull": false, 732 | "autoincrement": false 733 | }, 734 | "email": { 735 | "name": "email", 736 | "type": "text", 737 | "primaryKey": false, 738 | "notNull": true, 739 | "autoincrement": false 740 | }, 741 | "email_verified": { 742 | "name": "email_verified", 743 | "type": "integer", 744 | "primaryKey": false, 745 | "notNull": true, 746 | "autoincrement": false, 747 | "default": false 748 | }, 749 | "image": { 750 | "name": "image", 751 | "type": "text", 752 | "primaryKey": false, 753 | "notNull": false, 754 | "autoincrement": false 755 | }, 756 | "created_at": { 757 | "name": "created_at", 758 | "type": "integer", 759 | "primaryKey": false, 760 | "notNull": true, 761 | "autoincrement": false 762 | }, 763 | "updated_at": { 764 | "name": "updated_at", 765 | "type": "integer", 766 | "primaryKey": false, 767 | "notNull": true, 768 | "autoincrement": false 769 | }, 770 | "role": { 771 | "name": "role", 772 | "type": "text", 773 | "primaryKey": false, 774 | "notNull": true, 775 | "autoincrement": false, 776 | "default": "'user'" 777 | } 778 | }, 779 | "indexes": { 780 | "user_username_unique": { 781 | "name": "user_username_unique", 782 | "columns": ["username"], 783 | "isUnique": true 784 | }, 785 | "user_email_unique": { 786 | "name": "user_email_unique", 787 | "columns": ["email"], 788 | "isUnique": true 789 | } 790 | }, 791 | "foreignKeys": {}, 792 | "compositePrimaryKeys": {}, 793 | "uniqueConstraints": {} 794 | }, 795 | "verification": { 796 | "name": "verification", 797 | "columns": { 798 | "id": { 799 | "name": "id", 800 | "type": "text", 801 | "primaryKey": true, 802 | "notNull": true, 803 | "autoincrement": false 804 | }, 805 | "identifier": { 806 | "name": "identifier", 807 | "type": "text", 808 | "primaryKey": false, 809 | "notNull": true, 810 | "autoincrement": false 811 | }, 812 | "value": { 813 | "name": "value", 814 | "type": "text", 815 | "primaryKey": false, 816 | "notNull": true, 817 | "autoincrement": false 818 | }, 819 | "expires_at": { 820 | "name": "expires_at", 821 | "type": "integer", 822 | "primaryKey": false, 823 | "notNull": true, 824 | "autoincrement": false 825 | }, 826 | "created_at": { 827 | "name": "created_at", 828 | "type": "integer", 829 | "primaryKey": false, 830 | "notNull": true, 831 | "autoincrement": false 832 | }, 833 | "updated_at": { 834 | "name": "updated_at", 835 | "type": "integer", 836 | "primaryKey": false, 837 | "notNull": true, 838 | "autoincrement": false 839 | } 840 | }, 841 | "indexes": {}, 842 | "foreignKeys": {}, 843 | "compositePrimaryKeys": {}, 844 | "uniqueConstraints": {} 845 | } 846 | }, 847 | "enums": {}, 848 | "_meta": { 849 | "schemas": {}, 850 | "tables": {}, 851 | "columns": {} 852 | }, 853 | "internal": { 854 | "indexes": {} 855 | } 856 | } 857 | -------------------------------------------------------------------------------- /src/lib/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1753296675886, 9 | "tag": "0000_workable_talos", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1753353855162, 16 | "tag": "0001_faulty_slayback", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1755362385359, 23 | "tag": "0002_dapper_golden_guardian", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "6", 29 | "when": 1755362508108, 30 | "tag": "0003_groovy_la_nuit", 31 | "breakpoints": true 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/db/schema/asset/asset.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { game } from '../game/game' 4 | import { category } from '../category/category' 5 | import { assetToTag } from './assetToTag' 6 | import { savedAsset } from './savedAsset' 7 | import { user } from '../user/user' 8 | import { v7 as uuidv7 } from 'uuid' 9 | import { assetLink } from './assetLink' 10 | import { downloadHistoryToAsset } from './downloadHistory' 11 | 12 | export const asset = sqliteTable('asset', { 13 | id: text('id') 14 | .primaryKey() 15 | .$defaultFn(() => uuidv7()), 16 | name: text('name').notNull(), 17 | gameId: text('game_id') 18 | .notNull() 19 | .references(() => game.id, { onDelete: 'cascade' }), 20 | categoryId: text('category_id') 21 | .notNull() 22 | .references(() => category.id, { onDelete: 'cascade' }), 23 | createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), 24 | uploadedBy: text('uploaded_by') 25 | .notNull() 26 | .references(() => user.id), 27 | downloadCount: integer('download_count').notNull().default(0), 28 | viewCount: integer('view_count').notNull().default(0), 29 | isSuggestive: integer('is_suggestive', { mode: 'boolean' }).notNull().default(false), 30 | status: text('status', { enum: ['pending', 'approved', 'denied'] }) 31 | .notNull() 32 | .default('pending'), 33 | hash: text('hash').notNull(), 34 | size: integer('size').notNull(), 35 | extension: text('extension').notNull(), 36 | }) 37 | 38 | export const assetGameIdx = index('asset_game_idx').on(asset.gameId) 39 | export const assetCategoryIdx = index('asset_category_idx').on(asset.categoryId) 40 | export const assetNameIdx = index('asset_name_idx').on(asset.name) 41 | 42 | export const assetStatusIdx = index('asset_status_idx').on(asset.status) 43 | export const assetGameStatusIdx = index('asset_game_status_idx').on(asset.gameId, asset.status) 44 | export const assetCategoryStatusIdx = index('asset_category_status_idx').on(asset.categoryId, asset.status) 45 | export const assetSuggestiveStatusIdx = index('asset_suggestive_status_idx').on(asset.isSuggestive, asset.status) 46 | export const assetCreatedAtIdx = index('asset_created_at_idx').on(asset.createdAt) 47 | export const assetUploadedByIdx = index('asset_uploaded_by_idx').on(asset.uploadedBy) 48 | 49 | export const assetGameCategoryStatusIdx = index('asset_game_category_status_idx').on( 50 | asset.gameId, 51 | asset.categoryId, 52 | asset.status, 53 | ) 54 | export const assetStatusCreatedIdx = index('asset_status_created_idx').on(asset.status, asset.createdAt) 55 | 56 | export const assetRelations = relations(asset, ({ one, many }) => ({ 57 | game: one(game, { 58 | fields: [asset.gameId], 59 | references: [game.id], 60 | }), 61 | category: one(category, { 62 | fields: [asset.categoryId], 63 | references: [category.id], 64 | }), 65 | uploadedByUser: one(user, { 66 | fields: [asset.uploadedBy], 67 | references: [user.id], 68 | }), 69 | downloadHistoryToAsset: many(downloadHistoryToAsset), 70 | tagLinks: many(assetToTag), 71 | savedByUsers: many(savedAsset), 72 | assetLink: many(assetLink, { relationName: 'assetLink' }), 73 | toAssetLink: many(assetLink, { relationName: 'toAssetLink' }), 74 | })) 75 | -------------------------------------------------------------------------------- /src/lib/db/schema/asset/assetLink.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 2 | import { asset } from './asset' 3 | import { user } from '../user/user' 4 | import { v7 as uuidv7 } from 'uuid' 5 | import { relations } from 'drizzle-orm' 6 | 7 | export const assetLink = sqliteTable('asset_link', { 8 | id: text('id') 9 | .primaryKey() 10 | .$defaultFn(() => uuidv7()), 11 | assetId: text('asset_id') 12 | .notNull() 13 | .references(() => asset.id, { onDelete: 'cascade' }), 14 | toAssetId: text('to_asset_id') 15 | .notNull() 16 | .references(() => asset.id, { onDelete: 'cascade' }), 17 | createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), 18 | }) 19 | 20 | export const assetLinkRelations = relations(assetLink, ({ one }) => ({ 21 | assetLink: one(asset, { 22 | fields: [assetLink.assetId], 23 | references: [asset.id], 24 | relationName: 'assetLink', 25 | }), 26 | toAssetLink: one(asset, { 27 | fields: [assetLink.toAssetId], 28 | references: [asset.id], 29 | relationName: 'toAssetLink', 30 | }), 31 | })) 32 | -------------------------------------------------------------------------------- /src/lib/db/schema/asset/assetToTag.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { asset } from './asset' 4 | import { tag } from './tag' 5 | import { v7 as uuidv7 } from 'uuid' 6 | 7 | export const assetToTag = sqliteTable('asset_to_tag', { 8 | id: text('id') 9 | .primaryKey() 10 | .$defaultFn(() => uuidv7()), 11 | assetId: text('asset_id') 12 | .notNull() 13 | .references(() => asset.id, { onDelete: 'cascade' }), 14 | tagId: text('tag_id') 15 | .notNull() 16 | .references(() => tag.id, { onDelete: 'cascade' }), 17 | }) 18 | 19 | export const assetToTagAssetIdx = index('att_asset_idx').on(assetToTag.assetId) 20 | export const assetToTagTagIdx = index('att_tag_idx').on(assetToTag.tagId) 21 | 22 | export const assetToTagRelations = relations(assetToTag, ({ one }) => ({ 23 | asset: one(asset, { 24 | fields: [assetToTag.assetId], 25 | references: [asset.id], 26 | }), 27 | tag: one(tag, { 28 | fields: [assetToTag.tagId], 29 | references: [tag.id], 30 | }), 31 | })) 32 | -------------------------------------------------------------------------------- /src/lib/db/schema/asset/downloadHistory.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 2 | import { user } from '../user/user' 3 | import { asset } from './asset' 4 | import { v7 as uuidv7 } from 'uuid' 5 | import { relations } from 'drizzle-orm' 6 | 7 | export const downloadHistory = sqliteTable('download_history', { 8 | id: text('id') 9 | .primaryKey() 10 | .$defaultFn(() => uuidv7()), 11 | userId: text('user_id') 12 | .notNull() 13 | .references(() => user.id), 14 | createdAt: integer('created_at', { mode: 'timestamp' }) 15 | .notNull() 16 | .$defaultFn(() => new Date()), 17 | }) 18 | 19 | export const downloadHistoryToAsset = sqliteTable('download_history_to_asset', { 20 | id: text('id') 21 | .primaryKey() 22 | .$defaultFn(() => uuidv7()), 23 | downloadHistoryId: text('download_history_id') 24 | .notNull() 25 | .references(() => downloadHistory.id), 26 | assetId: text('asset_id') 27 | .notNull() 28 | .references(() => asset.id), 29 | }) 30 | 31 | export const downloadHistoryUserIdx = index('download_history_user_idx').on(downloadHistory.userId) 32 | export const downloadHistoryCreatedIdx = index('download_history_created_idx').on(downloadHistory.createdAt) 33 | export const downloadHistoryUserCreatedIdx = index('download_history_user_created_idx').on( 34 | downloadHistory.userId, 35 | downloadHistory.createdAt, 36 | ) 37 | 38 | export const downloadHistoryToAssetHistoryIdx = index('dhta_history_idx').on(downloadHistoryToAsset.downloadHistoryId) 39 | export const downloadHistoryToAssetAssetIdx = index('dhta_asset_idx').on(downloadHistoryToAsset.assetId) 40 | 41 | export const downloadHistoryRelations = relations(downloadHistory, ({ one }) => ({ 42 | user: one(user, { 43 | fields: [downloadHistory.userId], 44 | references: [user.id], 45 | }), 46 | })) 47 | 48 | export const downloadHistoryToAssetRelations = relations(downloadHistoryToAsset, ({ one }) => ({ 49 | downloadHistory: one(downloadHistory, { 50 | fields: [downloadHistoryToAsset.downloadHistoryId], 51 | references: [downloadHistory.id], 52 | }), 53 | asset: one(asset, { 54 | fields: [downloadHistoryToAsset.assetId], 55 | references: [asset.id], 56 | }), 57 | })) 58 | -------------------------------------------------------------------------------- /src/lib/db/schema/asset/savedAsset.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { user } from '../user/user' 4 | import { asset } from './asset' 5 | import { v7 as uuidv7 } from 'uuid' 6 | 7 | export const savedAsset = sqliteTable('saved_asset', { 8 | id: text('id') 9 | .primaryKey() 10 | .$defaultFn(() => uuidv7()), 11 | userId: text('user_id') 12 | .notNull() 13 | .references(() => user.id, { onDelete: 'cascade' }), 14 | assetId: text('asset_id') 15 | .notNull() 16 | .references(() => asset.id, { onDelete: 'cascade' }), 17 | createdAt: integer('created_at', { mode: 'timestamp' }) 18 | .notNull() 19 | .$defaultFn(() => new Date()), 20 | }) 21 | 22 | export const savedAssetUserIdx = index('saved_asset_user_idx').on(savedAsset.userId) 23 | export const savedAssetAssetIdx = index('saved_asset_asset_idx').on(savedAsset.assetId) 24 | export const savedAssetUserAssetIdx = index('saved_asset_user_asset_idx').on(savedAsset.userId, savedAsset.assetId) 25 | 26 | export const savedAssetUserCreatedIdx = index('saved_asset_user_created_idx').on( 27 | savedAsset.userId, 28 | savedAsset.createdAt, 29 | ) 30 | export const savedAssetCreatedAtIdx = index('saved_asset_created_at_idx').on(savedAsset.createdAt) 31 | 32 | export const savedAssetRelations = relations(savedAsset, ({ one }) => ({ 33 | user: one(user, { 34 | fields: [savedAsset.userId], 35 | references: [user.id], 36 | }), 37 | asset: one(asset, { 38 | fields: [savedAsset.assetId], 39 | references: [asset.id], 40 | }), 41 | })) 42 | -------------------------------------------------------------------------------- /src/lib/db/schema/asset/tag.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { assetToTag } from './assetToTag' 4 | import { v7 as uuidv7 } from 'uuid' 5 | 6 | export const tag = sqliteTable('tag', { 7 | id: text('id') 8 | .primaryKey() 9 | .$defaultFn(() => uuidv7()), 10 | name: text('name').notNull(), 11 | slug: text('slug').notNull().unique(), 12 | color: text('color'), 13 | }) 14 | 15 | export const tagSlugIdx = index('tag_slug_idx').on(tag.slug) 16 | export const tagNameIdx = index('tag_name_idx').on(tag.name) 17 | 18 | export const tagRelations = relations(tag, ({ many }) => ({ 19 | assetLinks: many(assetToTag), 20 | })) 21 | -------------------------------------------------------------------------------- /src/lib/db/schema/category/category.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { asset } from '../asset/asset' 4 | import { gameToCategory } from '../game/gameToCategory' 5 | import { v7 as uuidv7 } from 'uuid' 6 | 7 | export const category = sqliteTable('category', { 8 | id: text('id') 9 | .primaryKey() 10 | .$defaultFn(() => uuidv7()), 11 | name: text('name').notNull(), 12 | slug: text('slug').notNull().unique(), 13 | }) 14 | 15 | export const categorySlugIdx = index('category_slug_idx').on(category.slug) 16 | export const categoryNameIdx = index('category_name_idx').on(category.name) 17 | 18 | export const categoryRelations = relations(category, ({ many }) => ({ 19 | assets: many(asset), 20 | gameToCategories: many(gameToCategory), 21 | })) 22 | -------------------------------------------------------------------------------- /src/lib/db/schema/game/game.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { asset } from '../asset/asset' 4 | import { gameToCategory } from './gameToCategory' 5 | import { v7 as uuidv7 } from 'uuid' 6 | 7 | export const game = sqliteTable('game', { 8 | id: text('id') 9 | .primaryKey() 10 | .notNull() 11 | .$defaultFn(() => uuidv7()), 12 | slug: text('slug').notNull().unique(), 13 | name: text('name').notNull(), 14 | lastUpdated: integer('last_updated', { mode: 'timestamp' }).notNull(), 15 | assetCount: integer('asset_count').notNull().default(0), 16 | }) 17 | 18 | export const gameSlugIdx = index('game_slug_idx').on(game.slug) 19 | export const gameNameIdx = index('game_name_idx').on(game.name) 20 | 21 | export const gameRelations = relations(game, ({ many }) => ({ 22 | assets: many(asset), 23 | gameToCategories: many(gameToCategory), 24 | })) 25 | -------------------------------------------------------------------------------- /src/lib/db/schema/game/gameToCategory.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, primaryKey, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { game } from './game' 4 | import { category } from '../category/category' 5 | 6 | export const gameToCategory = sqliteTable( 7 | 'game_to_category', 8 | { 9 | gameId: text('game_id') 10 | .notNull() 11 | .references(() => game.id, { onDelete: 'cascade' }), 12 | categoryId: text('category_id') 13 | .notNull() 14 | .references(() => category.id, { onDelete: 'cascade' }), 15 | }, 16 | table => ({ 17 | pk: primaryKey({ columns: [table.gameId, table.categoryId] }), 18 | }), 19 | ) 20 | 21 | export const gameToCategoryGameIdx = index('game_to_category_game_idx').on(gameToCategory.gameId) 22 | export const gameToCategoryCategoryIdx = index('game_to_category_category_idx').on(gameToCategory.categoryId) 23 | 24 | export const gameToCategoryRelations = relations(gameToCategory, ({ one }) => ({ 25 | game: one(game, { 26 | fields: [gameToCategory.gameId], 27 | references: [game.id], 28 | }), 29 | category: one(category, { 30 | fields: [gameToCategory.categoryId], 31 | references: [category.id], 32 | }), 33 | })) 34 | -------------------------------------------------------------------------------- /src/lib/db/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from './asset/asset' 2 | export * from './asset/assetLink' 3 | export * from './asset/assetToTag' 4 | export * from './asset/downloadHistory' 5 | export * from './asset/savedAsset' 6 | export * from './asset/tag' 7 | 8 | export * from './category/category' 9 | 10 | export * from './game/game' 11 | export * from './game/gameToCategory' 12 | 13 | export * from './user/user' 14 | -------------------------------------------------------------------------------- /src/lib/db/schema/user/user.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core' 2 | import { relations } from 'drizzle-orm' 3 | import { savedAsset } from '../asset/savedAsset' 4 | import { v7 as uuidv7 } from 'uuid' 5 | import { downloadHistory } from '../asset/downloadHistory' 6 | 7 | export const user = sqliteTable('user', { 8 | id: text('id') 9 | .primaryKey() 10 | .$defaultFn(() => uuidv7()), 11 | name: text('name').notNull().unique(), 12 | displayName: text('display_name'), 13 | email: text('email').notNull().unique(), 14 | emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), 15 | image: text('image'), 16 | createdAt: integer('created_at', { mode: 'timestamp' }) 17 | .notNull() 18 | .$defaultFn(() => new Date()), 19 | updatedAt: integer('updated_at', { mode: 'timestamp' }) 20 | .notNull() 21 | .$defaultFn(() => new Date()), 22 | role: text('role', { enum: ['user', 'admin', 'contributor'] }) 23 | .notNull() 24 | .default('user'), 25 | }) 26 | 27 | export const session = sqliteTable('session', { 28 | id: text('id') 29 | .primaryKey() 30 | .$defaultFn(() => uuidv7()), 31 | expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), 32 | token: text('token').notNull().unique(), 33 | userId: text('user_id') 34 | .notNull() 35 | .references(() => user.id, { onDelete: 'cascade' }), 36 | ipAddress: text('ip_address'), 37 | userAgent: text('user_agent'), 38 | createdAt: integer('created_at', { mode: 'timestamp' }) 39 | .notNull() 40 | .$defaultFn(() => new Date()), 41 | updatedAt: integer('updated_at', { mode: 'timestamp' }) 42 | .notNull() 43 | .$defaultFn(() => new Date()), 44 | }) 45 | 46 | export const account = sqliteTable('account', { 47 | id: text('id') 48 | .primaryKey() 49 | .$defaultFn(() => uuidv7()), 50 | accountId: text('account_id').notNull(), 51 | providerId: text('provider_id').notNull(), 52 | userId: text('user_id') 53 | .notNull() 54 | .references(() => user.id, { onDelete: 'cascade' }), 55 | accessToken: text('access_token'), 56 | refreshToken: text('refresh_token'), 57 | idToken: text('id_token'), 58 | accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }), 59 | refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }), 60 | scope: text('scope'), 61 | password: text('password'), 62 | createdAt: integer('created_at', { mode: 'timestamp' }) 63 | .notNull() 64 | .$defaultFn(() => new Date()), 65 | updatedAt: integer('updated_at', { mode: 'timestamp' }) 66 | .notNull() 67 | .$defaultFn(() => new Date()), 68 | }) 69 | 70 | export const verification = sqliteTable('verification', { 71 | id: text('id') 72 | .primaryKey() 73 | .$defaultFn(() => uuidv7()), 74 | identifier: text('identifier').notNull(), 75 | value: text('value').notNull(), 76 | expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), 77 | createdAt: integer('created_at', { mode: 'timestamp' }) 78 | .notNull() 79 | .$defaultFn(() => new Date()), 80 | updatedAt: integer('updated_at', { mode: 'timestamp' }) 81 | .notNull() 82 | .$defaultFn(() => new Date()), 83 | }) 84 | 85 | export const userEmailIdx = index('user_email_idx').on(user.email) 86 | export const userUsernameIdx = index('user_username_idx').on(user.name) 87 | export const sessionTokenIdx = index('session_token_idx').on(session.token) 88 | export const sessionUserIdx = index('session_user_idx').on(session.userId) 89 | export const accountUserIdx = index('account_user_idx').on(account.userId) 90 | export const verificationIdentifierIdx = index('verification_identifier_idx').on(verification.identifier) 91 | 92 | export const accountUserProviderIdx = index('account_user_provider_idx').on(account.userId, account.providerId) 93 | export const accountProviderAccountIdx = index('account_provider_account_idx').on(account.providerId, account.accountId) 94 | 95 | export const sessionExpiryIdx = index('session_expiry_idx').on(session.expiresAt) 96 | export const sessionUserTokenIdx = index('session_user_token_idx').on(session.userId, session.token) 97 | 98 | export const userRelations = relations(user, ({ many }) => ({ 99 | sessions: many(session), 100 | accounts: many(account), 101 | savedAssets: many(savedAsset), 102 | downloadHistory: many(downloadHistory), 103 | })) 104 | 105 | export const sessionRelations = relations(session, ({ one }) => ({ 106 | user: one(user, { 107 | fields: [session.userId], 108 | references: [user.id], 109 | }), 110 | })) 111 | 112 | export const accountRelations = relations(account, ({ one }) => ({ 113 | user: one(user, { 114 | fields: [account.userId], 115 | references: [user.id], 116 | }), 117 | })) 118 | -------------------------------------------------------------------------------- /src/lib/handler.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono } from '@hono/zod-openapi' 2 | import type { Env, AuthVariables } from './auth/middleware' 3 | 4 | export type AppHandler = OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }> 5 | export type { Env, AuthVariables } 6 | -------------------------------------------------------------------------------- /src/lib/response-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | 3 | export const GenericResponses = { 4 | 400: { 5 | description: 'Bad Request', 6 | content: { 7 | 'application/json': { 8 | schema: z.object({ 9 | success: z.boolean(), 10 | message: z.string(), 11 | }), 12 | }, 13 | }, 14 | }, 15 | 401: { 16 | description: 'Unauthorized', 17 | content: { 18 | 'application/json': { 19 | schema: z.object({ 20 | success: z.boolean(), 21 | message: z.string(), 22 | }), 23 | }, 24 | }, 25 | }, 26 | 403: { 27 | description: 'Forbidden', 28 | content: { 29 | 'application/json': { 30 | schema: z.object({ 31 | success: z.boolean(), 32 | message: z.string(), 33 | }), 34 | }, 35 | }, 36 | }, 37 | 404: { 38 | description: 'Not Found', 39 | content: { 40 | 'application/json': { 41 | schema: z.object({ 42 | success: z.boolean(), 43 | message: z.string(), 44 | }), 45 | }, 46 | }, 47 | }, 48 | 500: { 49 | description: 'Internal Server Error', 50 | content: { 51 | 'application/json': { 52 | schema: z.object({ 53 | success: z.boolean(), 54 | message: z.string(), 55 | }), 56 | }, 57 | }, 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /src/routes/asset/approval-queue.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { createRoute } from '@hono/zod-openapi' 5 | import { GenericResponses } from '~/lib/response-schemas' 6 | import { asset, user, game, category, tag, assetToTag } from '~/lib/db/schema' 7 | import { eq, inArray, desc } from 'drizzle-orm' 8 | import { requireAuth } from '~/lib/auth/middleware' 9 | 10 | const responseSchema = z.object({ 11 | success: z.boolean(), 12 | assets: z.array( 13 | z.object({ 14 | id: z.string(), 15 | name: z.string(), 16 | gameId: z.string(), 17 | categoryId: z.string(), 18 | extension: z.string(), 19 | status: z.string(), 20 | uploadedBy: z.object({ 21 | id: z.string(), 22 | username: z.string().nullable(), 23 | image: z.string().nullable(), 24 | }), 25 | game: z.object({ 26 | id: z.string(), 27 | slug: z.string(), 28 | name: z.string(), 29 | lastUpdated: z.string(), 30 | assetCount: z.number(), 31 | }), 32 | category: z.object({ 33 | id: z.string(), 34 | name: z.string(), 35 | slug: z.string(), 36 | }), 37 | tags: z.array( 38 | z.object({ 39 | id: z.string(), 40 | name: z.string(), 41 | slug: z.string(), 42 | color: z.string().nullable(), 43 | }), 44 | ), 45 | }), 46 | ), 47 | }) 48 | 49 | const approvalQueueRoute = createRoute({ 50 | path: '/approval-queue', 51 | method: 'get', 52 | summary: 'List all assets pending approval', 53 | description: 'List all assets with status pending. Admin only.', 54 | tags: ['Asset'], 55 | responses: { 56 | 200: { 57 | description: 'Success', 58 | content: { 59 | 'application/json': { 60 | schema: responseSchema, 61 | }, 62 | }, 63 | }, 64 | ...GenericResponses, 65 | }, 66 | }) 67 | 68 | const paramsSchema = z.object({ 69 | id: z.string().openapi({ 70 | param: { 71 | description: 'The asset ID', 72 | in: 'path', 73 | name: 'id', 74 | required: true, 75 | }, 76 | example: 'asset_123', 77 | }), 78 | }) 79 | 80 | const approveRoute = createRoute({ 81 | path: '/{id}/approve', 82 | method: 'post', 83 | summary: 'Approve asset', 84 | request: { 85 | params: paramsSchema, 86 | }, 87 | description: 'Approve an asset. Admin only.', 88 | tags: ['Asset'], 89 | responses: { 90 | 200: { 91 | description: 'Success', 92 | content: { 'application/json': { schema: z.object({ success: z.boolean() }) } }, 93 | }, 94 | ...GenericResponses, 95 | }, 96 | }) 97 | 98 | const denyRoute = createRoute({ 99 | path: '/{id}/deny', 100 | method: 'post', 101 | summary: 'Deny asset', 102 | request: { 103 | params: paramsSchema, 104 | }, 105 | description: 'Deny an asset. Admin only.', 106 | tags: ['Asset'], 107 | responses: { 108 | 200: { 109 | description: 'Success', 110 | content: { 'application/json': { schema: z.object({ success: z.boolean() }) } }, 111 | }, 112 | ...GenericResponses, 113 | }, 114 | }) 115 | 116 | export const AssetApprovalQueueRoute = (handler: AppHandler) => { 117 | handler.use('/approval-queue', requireAuth) 118 | handler.openapi(approvalQueueRoute, async ctx => { 119 | const currentUser = ctx.get('user') 120 | if (!currentUser) { 121 | return ctx.json({ success: false, message: 'Admin access required' }, 403) 122 | } 123 | 124 | if (currentUser.role !== 'admin') { 125 | return ctx.json({ success: false, message: 'Admin access required' }, 403) 126 | } 127 | 128 | const { drizzle } = getConnection(ctx.env) 129 | 130 | const [allGames, allCategories, allTags, pendingAssets] = await Promise.all([ 131 | drizzle.select().from(game), 132 | drizzle.select().from(category), 133 | drizzle.select().from(tag), 134 | drizzle 135 | .select({ 136 | id: asset.id, 137 | name: asset.name, 138 | downloadCount: asset.downloadCount, 139 | viewCount: asset.viewCount, 140 | size: asset.size, 141 | extension: asset.extension, 142 | status: asset.status, 143 | createdAt: asset.createdAt, 144 | gameId: asset.gameId, 145 | categoryId: asset.categoryId, 146 | isSuggestive: asset.isSuggestive, 147 | uploadedBy: asset.uploadedBy, 148 | }) 149 | .from(asset) 150 | .innerJoin(user, eq(asset.uploadedBy, user.id)) 151 | .where(eq(asset.status, 'pending')) 152 | .orderBy(desc(asset.createdAt)), 153 | ]) 154 | 155 | const gameMap = Object.fromEntries(allGames.map(g => [g.id, g])) 156 | const categoryMap = Object.fromEntries(allCategories.map(c => [c.id, c])) 157 | const tagMap = Object.fromEntries(allTags.map(t => [t.id, t])) 158 | 159 | const assetIds = pendingAssets.map(a => a.id) 160 | const assetTags = 161 | assetIds.length > 0 162 | ? await drizzle 163 | .select({ 164 | assetId: assetToTag.assetId, 165 | tagId: assetToTag.tagId, 166 | }) 167 | .from(assetToTag) 168 | .where(inArray(assetToTag.assetId, assetIds)) 169 | : [] 170 | 171 | const uploaderIds = pendingAssets.map(a => a.uploadedBy) 172 | const uploaders = await drizzle 173 | .select({ 174 | id: user.id, 175 | username: user.name, 176 | image: user.image, 177 | }) 178 | .from(user) 179 | .where(inArray(user.id, uploaderIds)) 180 | 181 | const uploaderMap = Object.fromEntries(uploaders.map(u => [u.id, u])) 182 | 183 | const tagsByAsset = assetTags.reduce( 184 | (acc, tagLink) => { 185 | if (!acc[tagLink.assetId]) { 186 | acc[tagLink.assetId] = [] 187 | } 188 | const tag = tagMap[tagLink.tagId] 189 | if (tag) { 190 | acc[tagLink.assetId]!.push({ 191 | id: tag.id, 192 | name: tag.name, 193 | slug: tag.slug, 194 | color: tag.color, 195 | }) 196 | } 197 | return acc 198 | }, 199 | {} as Record, 200 | ) 201 | 202 | const formattedAssets = pendingAssets.map(a => { 203 | const gameInfo = gameMap[a.gameId] 204 | const categoryInfo = categoryMap[a.categoryId] 205 | 206 | return { 207 | id: a.id, 208 | name: a.name, 209 | status: a.status, 210 | gameId: a.gameId, 211 | categoryId: a.categoryId, 212 | extension: a.extension, 213 | uploadedBy: uploaderMap[a.uploadedBy]!, 214 | game: { 215 | id: a.gameId, 216 | slug: gameInfo?.slug || 'unknown', 217 | name: gameInfo?.name || 'Unknown', 218 | lastUpdated: gameInfo?.lastUpdated || new Date(), 219 | assetCount: gameInfo?.assetCount || 0, 220 | }, 221 | category: { 222 | id: a.categoryId, 223 | name: categoryInfo?.name || 'Unknown', 224 | slug: categoryInfo?.slug || 'unknown', 225 | }, 226 | tags: tagsByAsset[a.id] || [], 227 | } 228 | }) 229 | 230 | return ctx.json({ success: true, assets: formattedAssets }, 200) 231 | }) 232 | } 233 | 234 | export const AssetApproveRoute = (handler: AppHandler) => { 235 | handler.use('/:id/approve', requireAuth) 236 | handler.openapi(approveRoute, async ctx => { 237 | const currentUser = ctx.get('user') 238 | if (!currentUser || currentUser.role !== 'admin') { 239 | return ctx.json({ success: false, message: 'Admin access required' }, 403) 240 | } 241 | 242 | const id = ctx.req.param('id') 243 | const { drizzle } = getConnection(ctx.env) 244 | 245 | const [foundAsset] = await drizzle.select().from(asset).where(eq(asset.id, id)) 246 | 247 | if (!foundAsset) { 248 | return ctx.json({ success: false, message: 'Asset not found' }, 404) 249 | } 250 | 251 | await drizzle.update(asset).set({ status: 'approved' }).where(eq(asset.id, id)) 252 | 253 | const file = await ctx.env.CDN.get(`limbo/${foundAsset.id}.${foundAsset.extension}`) 254 | 255 | if (!file) { 256 | return ctx.json({ success: false, message: 'Asset file not found' }, 404) 257 | } 258 | 259 | await ctx.env.CDN.put(`asset/${foundAsset.id}.${foundAsset.extension}`, file.body) 260 | await ctx.env.CDN.delete(`limbo/${foundAsset.id}.${foundAsset.extension}`) 261 | 262 | return ctx.json({ success: true }, 200) 263 | }) 264 | } 265 | 266 | export const AssetDenyRoute = (handler: AppHandler) => { 267 | handler.use('/:id/deny', requireAuth) 268 | handler.openapi(denyRoute, async ctx => { 269 | const currentUser = ctx.get('user') 270 | if (!currentUser || currentUser.role !== 'admin') { 271 | return ctx.json({ success: false, message: 'Admin access required' }, 403) 272 | } 273 | 274 | const { drizzle } = getConnection(ctx.env) 275 | const id = ctx.req.param('id') 276 | 277 | const [foundAsset] = await drizzle.select().from(asset).where(eq(asset.id, id)) 278 | 279 | if (!foundAsset) { 280 | return ctx.json({ success: false, message: 'Asset not found' }, 404) 281 | } 282 | 283 | await drizzle.delete(asset).where(eq(asset.id, id)) 284 | 285 | await ctx.env.CDN.delete(`limbo/${foundAsset.id}.${foundAsset.extension}`) 286 | 287 | return ctx.json({ success: true }, 200) 288 | }) 289 | } 290 | -------------------------------------------------------------------------------- /src/routes/asset/history.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { createRoute } from '@hono/zod-openapi' 5 | import { GenericResponses } from '~/lib/response-schemas' 6 | import { downloadHistory, downloadHistoryToAsset } from '~/lib/db/schema/asset/downloadHistory' 7 | import { asset } from '~/lib/db/schema/asset/asset' 8 | import { desc, eq, inArray, sql, asc } from 'drizzle-orm' 9 | import { requireAuth } from '~/lib/auth/middleware' 10 | 11 | const postBodySchema = z.object({ 12 | assetIds: z 13 | .array(z.string().min(1)) 14 | .min(1) 15 | .openapi({ 16 | description: 'Array of asset IDs to add to download history', 17 | example: ['asset1', 'asset2'], 18 | }), 19 | }) 20 | 21 | const postResponseSchema = z.object({ 22 | success: z.boolean(), 23 | historyId: z.string(), 24 | }) 25 | 26 | const getResponseSchema = z.object({ 27 | success: z.boolean(), 28 | histories: z.array( 29 | z.object({ 30 | id: z.string(), 31 | assetIds: z.array(z.string()), 32 | createdAt: z.string(), 33 | }), 34 | ), 35 | }) 36 | 37 | const postRoute = createRoute({ 38 | path: '/history', 39 | method: 'post', 40 | summary: 'Create a new download history batch', 41 | description: 'Save a batch of downloaded asset IDs for the authenticated user.', 42 | tags: ['Asset'], 43 | request: { body: { content: { 'application/json': { schema: postBodySchema } } } }, 44 | responses: { 45 | 200: { description: 'Success', content: { 'application/json': { schema: postResponseSchema } } }, 46 | ...GenericResponses, 47 | }, 48 | }) 49 | 50 | const getRoute = createRoute({ 51 | path: '/history', 52 | method: 'get', 53 | summary: 'Get all download history batches for the user', 54 | description: 'Fetch all download history batches for the authenticated user.', 55 | tags: ['Asset'], 56 | responses: { 57 | 200: { description: 'Success', content: { 'application/json': { schema: getResponseSchema } } }, 58 | ...GenericResponses, 59 | }, 60 | }) 61 | 62 | export const AssetDownloadHistoryPostRoute = (handler: AppHandler) => { 63 | handler.use('/history', requireAuth) 64 | handler.openapi(postRoute, async ctx => { 65 | const { assetIds } = ctx.req.valid('json') 66 | const user = ctx.get('user') 67 | if (!user) { 68 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 69 | } 70 | const { drizzle } = getConnection(ctx.env) 71 | 72 | const assets = await drizzle.select().from(asset).where(inArray(asset.id, assetIds)) 73 | 74 | if (assets.length !== assetIds.length) { 75 | return ctx.json({ success: false, message: 'Invalid asset IDs' }, 400) 76 | } 77 | 78 | let historyId: string | undefined 79 | await drizzle.transaction(async tx => { 80 | const [countResult] = await tx 81 | .select({ count: sql`count(*)` }) 82 | .from(downloadHistory) 83 | .where(eq(downloadHistory.userId, user.id)) 84 | 85 | const currentCount = countResult?.count || 0 86 | 87 | if (currentCount >= 500) { 88 | const toDelete = currentCount - 499 89 | 90 | const oldestEntries = await tx 91 | .select({ id: downloadHistory.id }) 92 | .from(downloadHistory) 93 | .where(eq(downloadHistory.userId, user.id)) 94 | .orderBy(asc(downloadHistory.createdAt)) 95 | .limit(toDelete) 96 | 97 | if (oldestEntries.length > 0) { 98 | const idsToDelete = oldestEntries.map(e => e.id) 99 | 100 | await tx 101 | .delete(downloadHistoryToAsset) 102 | .where(inArray(downloadHistoryToAsset.downloadHistoryId, idsToDelete)) 103 | 104 | await tx.delete(downloadHistory).where(inArray(downloadHistory.id, idsToDelete)) 105 | } 106 | } 107 | 108 | const [history] = await tx 109 | .insert(downloadHistory) 110 | .values({ userId: user.id }) 111 | .returning({ historyId: downloadHistory.id }) 112 | 113 | historyId = history?.historyId 114 | 115 | if (!historyId) { 116 | throw new Error('Failed to create download history') 117 | } 118 | 119 | await tx.insert(downloadHistoryToAsset).values( 120 | assetIds.map(assetId => ({ 121 | downloadHistoryId: historyId!, 122 | assetId, 123 | })), 124 | ) 125 | }) 126 | 127 | return ctx.json({ success: true, historyId: historyId! }, 200) 128 | }) 129 | } 130 | 131 | export const AssetDownloadHistoryGetRoute = (handler: AppHandler) => { 132 | handler.use('/history', requireAuth) 133 | handler.openapi(getRoute, async ctx => { 134 | const user = ctx.get('user') 135 | if (!user) { 136 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 137 | } 138 | const { drizzle } = getConnection(ctx.env) 139 | 140 | const histories = await drizzle 141 | .select() 142 | .from(downloadHistory) 143 | .where(eq(downloadHistory.userId, user.id)) 144 | .orderBy(desc(downloadHistory.createdAt)) 145 | 146 | if (!histories) { 147 | return ctx.json({ success: true, histories: [] }, 200) 148 | } 149 | 150 | const result: { id: string; createdAt: string; assetIds: string[] }[] = [] 151 | 152 | for (const h of histories) { 153 | const links = await drizzle 154 | .select() 155 | .from(downloadHistoryToAsset) 156 | .where(eq(downloadHistoryToAsset.downloadHistoryId, h.id)) 157 | 158 | result.push({ 159 | id: h.id, 160 | createdAt: h.createdAt.toISOString(), 161 | assetIds: links.map(l => l.assetId), 162 | }) 163 | } 164 | 165 | return ctx.json({ success: true, histories: result }, 200) 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /src/routes/asset/id.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { and, eq } from 'drizzle-orm' 5 | import { asset, assetToTag, category, game, tag, user } from '~/lib/db/schema' 6 | import { createRoute } from '@hono/zod-openapi' 7 | import { GenericResponses } from '~/lib/response-schemas' 8 | import { cache } from 'hono/cache' 9 | 10 | const paramsSchema = z.object({ 11 | id: z.string().openapi({ 12 | param: { 13 | description: 'The asset ID', 14 | in: 'path', 15 | name: 'id', 16 | required: true, 17 | }, 18 | example: 'asset_123', 19 | }), 20 | }) 21 | 22 | const responseSchema = z.object({ 23 | success: z.boolean(), 24 | asset: z.object({ 25 | id: z.string(), 26 | name: z.string(), 27 | downloadCount: z.number(), 28 | viewCount: z.number(), 29 | size: z.number(), 30 | extension: z.string(), 31 | createdAt: z.string(), 32 | isSuggestive: z.boolean(), 33 | uploadedBy: z.object({ 34 | id: z.string(), 35 | username: z.string().nullable(), 36 | image: z.string().nullable(), 37 | }), 38 | game: z.object({ 39 | id: z.string(), 40 | slug: z.string(), 41 | name: z.string(), 42 | lastUpdated: z.string(), 43 | assetCount: z.number(), 44 | }), 45 | category: z.object({ 46 | id: z.string(), 47 | name: z.string(), 48 | slug: z.string(), 49 | }), 50 | tags: z.array( 51 | z.object({ 52 | id: z.string(), 53 | name: z.string(), 54 | slug: z.string(), 55 | color: z.string().nullable(), 56 | }), 57 | ), 58 | }), 59 | }) 60 | 61 | const openRoute = createRoute({ 62 | path: '/{id}', 63 | method: 'get', 64 | summary: 'Get asset by ID', 65 | description: 'Get detailed information about a specific asset by its ID.', 66 | tags: ['Asset'], 67 | request: { 68 | params: paramsSchema, 69 | }, 70 | responses: { 71 | 200: { 72 | description: 'Success', 73 | content: { 74 | 'application/json': { 75 | schema: responseSchema, 76 | }, 77 | }, 78 | }, 79 | ...GenericResponses, 80 | }, 81 | }) 82 | 83 | export const AssetIdRoute = (handler: AppHandler) => { 84 | handler.use( 85 | '/{id}', 86 | cache({ 87 | cacheName: 'asset-by-id', 88 | cacheControl: 'max-age=28800, s-maxage=28800', 89 | }), 90 | ) 91 | 92 | handler.openapi(openRoute, async ctx => { 93 | const { id } = ctx.req.valid('param') 94 | const { drizzle } = getConnection(ctx.env) 95 | 96 | try { 97 | const [allGames, allCategories, allTags, assetResult] = await Promise.all([ 98 | drizzle.select().from(game), 99 | drizzle.select().from(category), 100 | drizzle.select().from(tag), 101 | drizzle 102 | .select({ 103 | id: asset.id, 104 | name: asset.name, 105 | downloadCount: asset.downloadCount, 106 | viewCount: asset.viewCount, 107 | size: asset.size, 108 | extension: asset.extension, 109 | createdAt: asset.createdAt, 110 | gameId: asset.gameId, 111 | categoryId: asset.categoryId, 112 | isSuggestive: asset.isSuggestive, 113 | uploadedBy: asset.uploadedBy, 114 | }) 115 | .from(asset) 116 | .where(eq(asset.id, id)) 117 | .limit(1), 118 | ]) 119 | 120 | const gameMap = Object.fromEntries(allGames.map(g => [g.id, g])) 121 | const categoryMap = Object.fromEntries(allCategories.map(c => [c.id, c])) 122 | const tagMap = Object.fromEntries(allTags.map(t => [t.id, t])) 123 | 124 | if (assetResult.length === 0) { 125 | return ctx.json( 126 | { 127 | success: false, 128 | message: 'Asset not found', 129 | }, 130 | 404, 131 | ) 132 | } 133 | 134 | const assetData = assetResult[0]! 135 | 136 | const assetTags = await drizzle 137 | .select({ 138 | tagId: assetToTag.tagId, 139 | }) 140 | .from(assetToTag) 141 | .where(eq(assetToTag.assetId, id)) 142 | 143 | const uploader = await drizzle 144 | .select({ 145 | id: user.id, 146 | username: user.name, 147 | image: user.image, 148 | }) 149 | .from(user) 150 | .where(eq(user.id, assetData.uploadedBy)) 151 | .then(rows => rows[0] || { id: assetData.uploadedBy, username: null, image: null }) 152 | 153 | const gameInfo = gameMap[assetData.gameId] 154 | const categoryInfo = categoryMap[assetData.categoryId] 155 | 156 | const formattedAsset = { 157 | id: assetData.id, 158 | name: assetData.name, 159 | downloadCount: assetData.downloadCount, 160 | viewCount: assetData.viewCount, 161 | size: assetData.size, 162 | extension: assetData.extension, 163 | createdAt: assetData.createdAt.toISOString(), 164 | isSuggestive: assetData.isSuggestive, 165 | uploadedBy: uploader, 166 | game: { 167 | id: assetData.gameId, 168 | slug: gameInfo?.slug || 'unknown', 169 | name: gameInfo?.name || 'Unknown', 170 | lastUpdated: gameInfo?.lastUpdated.toISOString() || new Date().toISOString(), 171 | assetCount: gameInfo?.assetCount || 0, 172 | }, 173 | category: { 174 | id: assetData.categoryId, 175 | name: categoryInfo?.name || 'Unknown', 176 | slug: categoryInfo?.slug || 'unknown', 177 | }, 178 | tags: assetTags 179 | .map(tagLink => { 180 | const tag = tagMap[tagLink.tagId] 181 | return tag 182 | ? { 183 | id: tag.id, 184 | name: tag.name, 185 | slug: tag.slug, 186 | color: tag.color, 187 | } 188 | : null 189 | }) 190 | .filter((tag): tag is NonNullable => tag !== null), 191 | } 192 | 193 | return ctx.json( 194 | { 195 | success: true, 196 | asset: formattedAsset, 197 | }, 198 | 200, 199 | ) 200 | } catch (error) { 201 | console.error('Asset fetch error:', error) 202 | return ctx.json( 203 | { 204 | success: false, 205 | message: 'Failed to fetch asset', 206 | }, 207 | 500, 208 | ) 209 | } 210 | }) 211 | } 212 | -------------------------------------------------------------------------------- /src/routes/asset/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthVariables, Env } from '~/lib/handler' 2 | import { OpenAPIHono } from '@hono/zod-openapi' 3 | import { authMiddleware } from '~/lib/auth/middleware' 4 | import { AssetSearchRoute } from './search' 5 | import { AssetIdRoute } from './id' 6 | import { AssetDownloadHistoryPostRoute, AssetDownloadHistoryGetRoute } from './history' 7 | import { AssetApprovalQueueRoute, AssetApproveRoute, AssetDenyRoute } from './approval-queue' 8 | import { AssetUploadRoute } from './upload' 9 | 10 | export const AssetHandler = new OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }>() 11 | 12 | AssetHandler.use('*', authMiddleware) 13 | 14 | AssetSearchRoute(AssetHandler) 15 | AssetDownloadHistoryPostRoute(AssetHandler) 16 | AssetDownloadHistoryGetRoute(AssetHandler) 17 | AssetApprovalQueueRoute(AssetHandler) 18 | AssetApproveRoute(AssetHandler) 19 | AssetDenyRoute(AssetHandler) 20 | AssetIdRoute(AssetHandler) 21 | AssetUploadRoute(AssetHandler) 22 | -------------------------------------------------------------------------------- /src/routes/asset/search.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { like, and, eq, inArray, sql, desc, asc, type SQL } from 'drizzle-orm' 5 | import { asset, assetToTag, category, game, tag, user } from '~/lib/db/schema' 6 | import { createRoute } from '@hono/zod-openapi' 7 | import { GenericResponses } from '~/lib/response-schemas' 8 | import { cache } from 'hono/cache' 9 | 10 | const querySchema = z.object({ 11 | name: z 12 | .string() 13 | .optional() 14 | .openapi({ 15 | param: { 16 | description: 'Search assets by name (partial match).', 17 | in: 'query', 18 | name: 'name', 19 | required: false, 20 | }, 21 | }), 22 | tags: z 23 | .string() 24 | .optional() 25 | .openapi({ 26 | param: { 27 | description: 'Comma-separated list of tag slugs to filter by.', 28 | in: 'query', 29 | name: 'tags', 30 | required: false, 31 | }, 32 | example: 'fanmade,official,4k', 33 | }), 34 | games: z 35 | .string() 36 | .optional() 37 | .openapi({ 38 | param: { 39 | description: 'Comma-separated list of game slugs to filter by.', 40 | in: 'query', 41 | name: 'games', 42 | required: false, 43 | }, 44 | example: 'genshin-impact,honkai-star-rail', 45 | }), 46 | categories: z 47 | .string() 48 | .optional() 49 | .openapi({ 50 | param: { 51 | description: 'Comma-separated list of category slugs to filter by.', 52 | in: 'query', 53 | name: 'categories', 54 | required: false, 55 | }, 56 | example: 'character-sheets,splash-art', 57 | }), 58 | offset: z 59 | .string() 60 | .optional() 61 | .openapi({ 62 | param: { 63 | description: 'Number of results to skip for pagination (starts at 0).', 64 | in: 'query', 65 | name: 'offset', 66 | required: false, 67 | }, 68 | example: '0', 69 | }), 70 | sortBy: z 71 | .enum(['viewCount', 'downloadCount', 'uploadDate', 'name']) 72 | .optional() 73 | .openapi({ 74 | param: { 75 | description: 'Field to sort by.', 76 | in: 'query', 77 | name: 'sortBy', 78 | required: false, 79 | }, 80 | example: 'uploadDate', 81 | }), 82 | sortOrder: z 83 | .enum(['asc', 'desc']) 84 | .optional() 85 | .openapi({ 86 | param: { 87 | description: 'Sort order (ascending or descending).', 88 | in: 'query', 89 | name: 'sortOrder', 90 | required: false, 91 | }, 92 | example: 'desc', 93 | }), 94 | }) 95 | 96 | const responseSchema = z.object({ 97 | success: z.boolean(), 98 | assets: z.array( 99 | z.object({ 100 | id: z.string(), 101 | name: z.string(), 102 | gameId: z.string(), 103 | gameName: z.string(), 104 | gameSlug: z.string(), 105 | categoryId: z.string(), 106 | categoryName: z.string(), 107 | categorySlug: z.string(), 108 | downloadCount: z.number(), 109 | viewCount: z.number(), 110 | size: z.number(), 111 | extension: z.string(), 112 | createdAt: z.string(), 113 | isSuggestive: z.boolean(), 114 | tags: z.array( 115 | z.object({ 116 | id: z.string(), 117 | name: z.string(), 118 | slug: z.string(), 119 | color: z.string().nullable(), 120 | }), 121 | ), 122 | uploadedBy: z.object({ 123 | id: z.string(), 124 | username: z.string().nullable(), 125 | image: z.string().nullable(), 126 | }), 127 | }), 128 | ), 129 | pagination: z.object({ 130 | offset: z.number(), 131 | hasNext: z.boolean(), 132 | }), 133 | }) 134 | 135 | const openRoute = createRoute({ 136 | path: '/search', 137 | method: 'get', 138 | summary: 'Search assets', 139 | description: 'Search assets by name, tags, games, and categories.', 140 | tags: ['Asset'], 141 | request: { 142 | query: querySchema, 143 | }, 144 | responses: { 145 | 200: { 146 | description: 'Success', 147 | content: { 148 | 'application/json': { 149 | schema: responseSchema, 150 | }, 151 | }, 152 | }, 153 | ...GenericResponses, 154 | }, 155 | }) 156 | 157 | export const AssetSearchRoute = (handler: AppHandler) => { 158 | handler.use( 159 | '/search', 160 | cache({ 161 | cacheName: 'asset-search-all', 162 | cacheControl: 'max-age=600, s-maxage=600', 163 | }), 164 | ) 165 | 166 | handler.openapi(openRoute, async ctx => { 167 | const query = ctx.req.valid('query') 168 | 169 | const { drizzle } = getConnection(ctx.env) 170 | 171 | const offset = query.offset ? parseInt(query.offset) : 0 172 | const sortBy = query.sortBy || 'uploadDate' 173 | const sortOrder = query.sortOrder || 'desc' 174 | 175 | if (offset < 0) { 176 | return ctx.json( 177 | { 178 | success: false, 179 | message: 'Offset must be 0 or greater', 180 | }, 181 | 400, 182 | ) 183 | } 184 | 185 | const tagSlugs = query.tags 186 | ? query.tags 187 | .split(',') 188 | .map(t => t.trim()) 189 | .filter(Boolean) 190 | : [] 191 | const gameSlugs = query.games 192 | ? query.games 193 | .split(',') 194 | .map(g => g.trim()) 195 | .filter(Boolean) 196 | : [] 197 | const categorySlugs = query.categories 198 | ? query.categories 199 | .split(',') 200 | .map(c => c.trim()) 201 | .filter(Boolean) 202 | : [] 203 | 204 | try { 205 | const [allGames, allCategories, allTags] = await Promise.all([ 206 | drizzle.select().from(game), 207 | drizzle.select().from(category), 208 | drizzle.select().from(tag), 209 | ]) 210 | 211 | const gameMap = Object.fromEntries(allGames.map(g => [g.id, g])) 212 | const categoryMap = Object.fromEntries(allCategories.map(c => [c.id, c])) 213 | const tagMap = Object.fromEntries(allTags.map(t => [t.id, t])) 214 | const gameSlugMap = Object.fromEntries(allGames.map(g => [g.slug, g.id])) 215 | const categorySlugMap = Object.fromEntries(allCategories.map(c => [c.slug, c.id])) 216 | const tagSlugMap = Object.fromEntries(allTags.map(t => [t.slug, t.id])) 217 | 218 | const conditions: SQL[] = [] 219 | 220 | if (query.name) { 221 | conditions.push(like(asset.name, `%${query.name}%`)) 222 | } 223 | 224 | if (gameSlugs.length > 0) { 225 | const gameIds = gameSlugs.map(slug => gameSlugMap[slug]).filter((id): id is string => id !== undefined) 226 | if (gameIds.length > 0) { 227 | conditions.push(inArray(asset.gameId, gameIds)) 228 | } 229 | } 230 | 231 | if (categorySlugs.length > 0) { 232 | const categoryIds = categorySlugs 233 | .map(slug => categorySlugMap[slug]) 234 | .filter((id): id is string => id !== undefined) 235 | if (categoryIds.length > 0) { 236 | conditions.push(inArray(asset.categoryId, categoryIds)) 237 | } 238 | } 239 | 240 | if (tagSlugs.length > 0) { 241 | const tagIds = tagSlugs.map(slug => tagSlugMap[slug]).filter((id): id is string => id !== undefined) 242 | if (tagIds.length > 0) { 243 | const tagSubquery = drizzle 244 | .select({ assetId: assetToTag.assetId }) 245 | .from(assetToTag) 246 | .where(inArray(assetToTag.tagId, tagIds)) 247 | .groupBy(assetToTag.assetId) 248 | .having(sql`COUNT(DISTINCT ${assetToTag.tagId}) = ${tagIds.length}`) 249 | 250 | conditions.push(sql`${asset.id} IN (${tagSubquery})`) 251 | } 252 | } 253 | 254 | const sortColumn = { 255 | viewCount: asset.viewCount, 256 | downloadCount: asset.downloadCount, 257 | uploadDate: asset.createdAt, 258 | name: asset.name, 259 | }[sortBy]! 260 | 261 | const sortDirection = sortOrder === 'asc' ? asc : desc 262 | 263 | let baseQuery = drizzle 264 | .select({ 265 | id: asset.id, 266 | name: asset.name, 267 | gameId: asset.gameId, 268 | categoryId: asset.categoryId, 269 | downloadCount: asset.downloadCount, 270 | viewCount: asset.viewCount, 271 | size: asset.size, 272 | extension: asset.extension, 273 | createdAt: asset.createdAt, 274 | isSuggestive: asset.isSuggestive, 275 | uploadedBy: asset.uploadedBy, 276 | }) 277 | .from(asset) 278 | .where(and(conditions.length > 0 ? and(...conditions) : undefined, eq(asset.status, 'approved'))) 279 | .orderBy(sortDirection(sortColumn)) 280 | 281 | const assets = await baseQuery.limit(21).offset(offset) 282 | 283 | const hasNext = assets.length > 20 284 | const finalAssets = hasNext ? assets.slice(0, 20) : assets 285 | 286 | const assetIds = finalAssets.map(a => a.id) 287 | const assetTags = 288 | assetIds.length > 0 289 | ? await drizzle 290 | .select({ 291 | assetId: assetToTag.assetId, 292 | tagId: assetToTag.tagId, 293 | }) 294 | .from(assetToTag) 295 | .where(inArray(assetToTag.assetId, assetIds)) 296 | : [] 297 | 298 | const tagsByAsset = assetTags.reduce( 299 | (acc, tagLink) => { 300 | if (!acc[tagLink.assetId]) { 301 | acc[tagLink.assetId] = [] 302 | } 303 | const tag = tagMap[tagLink.tagId] 304 | if (tag) { 305 | acc[tagLink.assetId]!.push({ 306 | id: tag.id, 307 | name: tag.name, 308 | slug: tag.slug, 309 | color: tag.color, 310 | }) 311 | } 312 | return acc 313 | }, 314 | {} as Record, 315 | ) 316 | 317 | const uploaderIds = finalAssets.map(a => a.uploadedBy) 318 | const uploaders = 319 | uploaderIds.length > 0 320 | ? await drizzle 321 | .select({ 322 | id: user.id, 323 | username: user.name, 324 | image: user.image, 325 | }) 326 | .from(user) 327 | .where(inArray(user.id, uploaderIds)) 328 | : [] 329 | const uploaderMap = Object.fromEntries(uploaders.map(u => [u.id, u])) 330 | 331 | const formattedAssets = finalAssets.map(asset => { 332 | const gameInfo = gameMap[asset.gameId] 333 | const categoryInfo = categoryMap[asset.categoryId] 334 | 335 | return { 336 | ...asset, 337 | gameName: gameInfo?.name || 'Unknown', 338 | gameSlug: gameInfo?.slug || 'unknown', 339 | categoryName: categoryInfo?.name || 'Unknown', 340 | categorySlug: categoryInfo?.slug || 'unknown', 341 | createdAt: asset.createdAt.toISOString(), 342 | tags: tagsByAsset[asset.id] || [], 343 | uploadedBy: uploaderMap[asset.uploadedBy] || { id: asset.uploadedBy, username: null, image: null }, 344 | } 345 | }) 346 | 347 | return ctx.json( 348 | { 349 | success: true, 350 | assets: formattedAssets, 351 | pagination: { 352 | offset, 353 | hasNext, 354 | }, 355 | }, 356 | 200, 357 | ) 358 | } catch (error) { 359 | console.error('Asset search error:', error) 360 | return ctx.json( 361 | { 362 | success: false, 363 | message: 'Failed to search assets', 364 | }, 365 | 500, 366 | ) 367 | } 368 | }) 369 | } 370 | -------------------------------------------------------------------------------- /src/routes/asset/upload.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { createRoute } from '@hono/zod-openapi' 5 | import { GenericResponses } from '~/lib/response-schemas' 6 | import { asset } from '~/lib/db/schema/asset/asset' 7 | import { assetToTag } from '~/lib/db/schema/asset/assetToTag' 8 | import { tag } from '~/lib/db/schema/asset/tag' 9 | import { game } from '~/lib/db/schema/game/game' 10 | import { category } from '~/lib/db/schema' 11 | import { gameToCategory } from '~/lib/db/schema/game/gameToCategory' 12 | import { and, eq, inArray } from 'drizzle-orm' 13 | import { requireAuth } from '~/lib/auth/middleware' 14 | import { v7 as uuidv7 } from 'uuid' 15 | import type { Context } from 'hono' 16 | 17 | const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg'] as const 18 | const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg'] as const 19 | const MAX_FILE_SIZE = 10 * 1024 * 1024 20 | 21 | type FileValidationResult = { valid: true; extension: string } | { valid: false; message: string } 22 | 23 | type User = { 24 | id: string 25 | name: string 26 | image: string | null 27 | role: 'admin' | 'contributor' | 'user' 28 | displayName?: string | null 29 | } 30 | 31 | type UploadedAsset = { 32 | id: string 33 | name: string 34 | status: 'approved' | 'pending' 35 | uploadedBy: { 36 | id: string 37 | username: string | null 38 | image: string | null 39 | } 40 | } 41 | 42 | const formSchema = z.object({ 43 | name: z.string().min(1, 'Name is required').max(255, 'Name too long'), 44 | gameId: z.string().uuid('Invalid game ID'), 45 | categoryId: z.string().uuid('Invalid category ID'), 46 | isSuggestive: z.string().optional().default('false'), 47 | tags: z.string().optional(), 48 | file: z.any(), 49 | }) 50 | 51 | const responseSchema = z.object({ 52 | success: z.boolean(), 53 | asset: z.object({ 54 | id: z.string(), 55 | name: z.string(), 56 | status: z.string(), 57 | uploadedBy: z.object({ 58 | id: z.string(), 59 | username: z.string().nullable(), 60 | image: z.string().nullable(), 61 | }), 62 | }), 63 | }) 64 | 65 | function parseTags(tagsRaw?: string): string[] { 66 | if (!tagsRaw?.trim()) return [] 67 | 68 | return tagsRaw 69 | .split(',') 70 | .map(tag => tag.trim()) 71 | .filter(Boolean) 72 | .slice(0, 20) 73 | } 74 | 75 | function validateFile(file: unknown): FileValidationResult { 76 | if (!file || typeof file !== 'object' || !file) { 77 | return { valid: false, message: 'File is required' } 78 | } 79 | 80 | const fileObj = file as any 81 | 82 | if (!fileObj.type || !fileObj.name || typeof fileObj.size !== 'number') { 83 | return { valid: false, message: 'Invalid file object' } 84 | } 85 | 86 | if (!ALLOWED_MIME_TYPES.includes(fileObj.type)) { 87 | return { 88 | valid: false, 89 | message: `Invalid file type. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`, 90 | } 91 | } 92 | 93 | if (fileObj.size > MAX_FILE_SIZE) { 94 | return { valid: false, message: 'File size exceeds 10MB limit' } 95 | } 96 | 97 | const fileName = String(fileObj.name) 98 | const extension = fileName.split('.').pop()?.toLowerCase() 99 | 100 | if (!extension) { 101 | return { 102 | valid: false, 103 | message: 'File must have an extension', 104 | } 105 | } 106 | 107 | if (!ALLOWED_EXTENSIONS.includes(extension as any)) { 108 | return { 109 | valid: false, 110 | message: `Invalid file extension. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`, 111 | } 112 | } 113 | 114 | return { valid: true, extension } 115 | } 116 | 117 | function determineAssetStatus(userRole: string): 'approved' | 'pending' { 118 | return userRole === 'admin' ? 'approved' : 'pending' 119 | } 120 | 121 | function getStoragePath(assetId: string, extension: string, isAdmin: boolean): string { 122 | const folder = isAdmin ? 'asset' : 'limbo' 123 | return `${folder}/${assetId}.${extension}` 124 | } 125 | 126 | class AssetUploadService { 127 | constructor( 128 | private drizzle: any, 129 | private env: any, 130 | ) {} 131 | 132 | async validateCategoryGameLink(gameId: string, categoryId: string, userRole: string): Promise { 133 | const [existingLink] = await this.drizzle 134 | .select() 135 | .from(gameToCategory) 136 | .where(and(eq(gameToCategory.gameId, gameId), eq(gameToCategory.categoryId, categoryId))) 137 | .limit(1) 138 | 139 | if (existingLink) return true 140 | 141 | if (userRole === 'admin') { 142 | await this.drizzle.insert(gameToCategory).values({ 143 | gameId, 144 | categoryId, 145 | }) 146 | return true 147 | } 148 | 149 | return false 150 | } 151 | 152 | async uploadFileToStorage(file: File, path: string): Promise { 153 | try { 154 | const arrayBuffer = await file.arrayBuffer() 155 | const uploadedFile = await this.env.CDN.put(path, arrayBuffer, { 156 | httpMetadata: { 157 | contentType: file.type || 'application/octet-stream', 158 | }, 159 | }) 160 | 161 | if (!uploadedFile) { 162 | console.error('Upload failed - no result returned from CDN.put') 163 | return false 164 | } 165 | 166 | return true 167 | } catch (error) { 168 | console.error('Failed to upload file to storage:', error) 169 | return null 170 | } 171 | } 172 | 173 | async createAsset(data: { 174 | id: string 175 | name: string 176 | gameId: string 177 | categoryId: string 178 | uploadedBy: string 179 | isSuggestive: boolean 180 | size: number 181 | extension: string 182 | status: 'approved' | 'pending' 183 | }) { 184 | const [createdAsset] = await this.drizzle 185 | .insert(asset) 186 | .values({ 187 | ...data, 188 | createdAt: new Date(), 189 | downloadCount: 0, 190 | viewCount: 0, 191 | hash: 'placeholder', 192 | }) 193 | .returning() 194 | 195 | return createdAsset 196 | } 197 | 198 | async attachTags(assetId: string, tagIds: string[]): Promise { 199 | if (tagIds.length === 0) return 200 | 201 | const validTags = await this.drizzle.select({ id: tag.id }).from(tag).where(inArray(tag.id, tagIds)) 202 | 203 | const validTagIds = validTags.map(t => t.id) 204 | 205 | if (validTagIds.length > 0) { 206 | await this.drizzle.insert(assetToTag).values( 207 | validTagIds.map(tagId => ({ 208 | assetId, 209 | tagId, 210 | })), 211 | ) 212 | } 213 | } 214 | } 215 | 216 | const uploadRoute = createRoute({ 217 | path: '/upload', 218 | method: 'post', 219 | summary: 'Upload a new asset', 220 | description: 'Upload a new asset (PNG or JPEG). Admins auto-approve, contributors go to approval queue.', 221 | tags: ['Asset'], 222 | request: { 223 | body: { 224 | content: { 225 | 'multipart/form-data': { 226 | schema: formSchema, 227 | }, 228 | }, 229 | }, 230 | }, 231 | responses: { 232 | 200: { 233 | description: 'Asset uploaded successfully', 234 | content: { 235 | 'application/json': { 236 | schema: responseSchema, 237 | }, 238 | }, 239 | }, 240 | ...GenericResponses, 241 | }, 242 | }) 243 | 244 | export const AssetUploadRoute = (handler: AppHandler) => { 245 | handler.use('/upload', requireAuth) 246 | 247 | handler.openapi(uploadRoute, async (ctx: Context) => { 248 | try { 249 | const { drizzle } = getConnection(ctx.env) 250 | const user = ctx.get('user') as User | undefined 251 | 252 | if (!user) { 253 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 254 | } 255 | 256 | if (!['admin', 'contributor'].includes(user.role)) { 257 | return ctx.json( 258 | { 259 | success: false, 260 | message: 'Insufficient permissions. Admin or contributor role required.', 261 | }, 262 | 403, 263 | ) 264 | } 265 | 266 | const form = await ctx.req.formData() 267 | const formData = Object.fromEntries(form.entries()) 268 | 269 | const parseResult = formSchema.safeParse(formData) 270 | if (!parseResult.success) { 271 | return ctx.json( 272 | { 273 | success: false, 274 | message: 275 | 'Invalid form data: ' + 276 | Object.entries(parseResult.error.flatten().fieldErrors) 277 | .map(([key, value]) => `${key}: ${value?.join(', ')}`) 278 | .join(', '), 279 | }, 280 | 400, 281 | ) 282 | } 283 | 284 | const { name, gameId, categoryId, isSuggestive, tags: tagsRaw, file } = parseResult.data 285 | 286 | const fileValidation = validateFile(file) 287 | if (!fileValidation.valid) { 288 | return ctx.json( 289 | { 290 | success: false, 291 | message: fileValidation.message, 292 | }, 293 | 400, 294 | ) 295 | } 296 | 297 | const uploadService = new AssetUploadService(drizzle, ctx.env) 298 | 299 | const isValidCategoryGame = await uploadService.validateCategoryGameLink(gameId, categoryId, user.role) 300 | 301 | if (!isValidCategoryGame) { 302 | return ctx.json( 303 | { 304 | success: false, 305 | message: 'Invalid category-game combination', 306 | }, 307 | 400, 308 | ) 309 | } 310 | 311 | const assetId = uuidv7() 312 | const status = determineAssetStatus(user.role) 313 | const storagePath = getStoragePath(assetId, fileValidation.extension, user.role === 'admin') 314 | 315 | const fileUploaded = await uploadService.uploadFileToStorage(file as File, storagePath) 316 | 317 | if (!fileUploaded) { 318 | return ctx.json( 319 | { 320 | success: false, 321 | message: 'Failed to upload file to storage', 322 | }, 323 | 500, 324 | ) 325 | } 326 | 327 | await uploadService.createAsset({ 328 | id: assetId, 329 | name, 330 | gameId, 331 | categoryId, 332 | uploadedBy: user.id, 333 | isSuggestive: isSuggestive === 'true', 334 | size: (file as any).size || 0, 335 | extension: fileValidation.extension, 336 | status, 337 | }) 338 | 339 | const tagIds = parseTags(tagsRaw) 340 | await uploadService.attachTags(assetId, tagIds) 341 | 342 | const response: UploadedAsset = { 343 | id: assetId, 344 | name, 345 | status, 346 | uploadedBy: { 347 | id: user.id, 348 | username: user.name, 349 | image: user.image, 350 | }, 351 | } 352 | 353 | return ctx.json({ success: true, asset: response }, 200) 354 | } catch (error) { 355 | console.error('Asset upload error:', error) 356 | return ctx.json( 357 | { 358 | success: false, 359 | message: 'Internal server error occurred during upload', 360 | }, 361 | 500, 362 | ) 363 | } 364 | }) 365 | } 366 | -------------------------------------------------------------------------------- /src/routes/category/all.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { eq } from 'drizzle-orm' 5 | import { game, category } from '~/lib/db/schema' 6 | import { createRoute } from '@hono/zod-openapi' 7 | import { GenericResponses } from '~/lib/response-schemas' 8 | import { cache } from 'hono/cache' 9 | 10 | const responseSchema = z.object({ 11 | success: z.boolean(), 12 | categories: z.array( 13 | z.object({ 14 | id: z.string(), 15 | name: z.string(), 16 | slug: z.string(), 17 | }), 18 | ), 19 | }) 20 | 21 | const openRoute = createRoute({ 22 | path: '/all', 23 | method: 'get', 24 | summary: 'Get all categories', 25 | description: 'Get all categories with their linked games.', 26 | tags: ['Category'], 27 | responses: { 28 | 200: { 29 | description: 'Success', 30 | content: { 31 | 'application/json': { 32 | schema: responseSchema, 33 | }, 34 | }, 35 | }, 36 | ...GenericResponses, 37 | }, 38 | }) 39 | 40 | export const CategoryAllRoute = (handler: AppHandler) => { 41 | handler.use( 42 | '/all', 43 | cache({ 44 | cacheName: 'category-all', 45 | cacheControl: 'max-age=300, s-maxage=300', 46 | }), 47 | ) 48 | 49 | handler.openapi(openRoute, async ctx => { 50 | const { drizzle } = getConnection(ctx.env) 51 | 52 | try { 53 | const categories = await drizzle.select().from(category) 54 | 55 | return ctx.json( 56 | { 57 | success: true, 58 | categories: categories || [], 59 | }, 60 | 200, 61 | ) 62 | } catch (error) { 63 | console.error('Category list error:', error) 64 | return ctx.json( 65 | { 66 | success: false, 67 | message: 'Failed to fetch categories', 68 | }, 69 | 500, 70 | ) 71 | } 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/routes/category/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthVariables, Env } from '~/lib/handler' 2 | import { OpenAPIHono } from '@hono/zod-openapi' 3 | import { CategoryAllRoute } from './all' 4 | import { CategorySlugRoute } from './slug' 5 | 6 | export const CategoryHandler = new OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }>() 7 | 8 | CategoryAllRoute(CategoryHandler) 9 | CategorySlugRoute(CategoryHandler) 10 | -------------------------------------------------------------------------------- /src/routes/category/slug.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { eq } from 'drizzle-orm' 5 | import { category, game } from '~/lib/db/schema' 6 | import { createRoute } from '@hono/zod-openapi' 7 | import { GenericResponses } from '~/lib/response-schemas' 8 | import { cache } from 'hono/cache' 9 | 10 | const paramsSchema = z.object({ 11 | slug: z.string().openapi({ 12 | param: { 13 | description: 'The category slug', 14 | in: 'path', 15 | name: 'slug', 16 | required: true, 17 | }, 18 | example: 'splash-art', 19 | }), 20 | }) 21 | 22 | const responseSchema = z.object({ 23 | success: z.boolean(), 24 | category: z.object({ 25 | id: z.string(), 26 | name: z.string(), 27 | slug: z.string(), 28 | }), 29 | }) 30 | 31 | const openRoute = createRoute({ 32 | path: '/{slug}', 33 | method: 'get', 34 | summary: 'Get category by slug', 35 | description: 'Get a specific category by its slug with linked games.', 36 | tags: ['Category'], 37 | request: { 38 | params: paramsSchema, 39 | }, 40 | responses: { 41 | 200: { 42 | description: 'Success', 43 | content: { 44 | 'application/json': { 45 | schema: responseSchema, 46 | }, 47 | }, 48 | }, 49 | ...GenericResponses, 50 | }, 51 | }) 52 | 53 | export const CategorySlugRoute = (handler: AppHandler) => { 54 | handler.use( 55 | '/{slug}', 56 | cache({ 57 | cacheName: 'category-slug', 58 | cacheControl: 'max-age=43200, s-maxage=43200', 59 | }), 60 | ) 61 | 62 | handler.openapi(openRoute, async ctx => { 63 | const { slug } = ctx.req.valid('param') 64 | const { drizzle } = getConnection(ctx.env) 65 | 66 | try { 67 | const [categoryResult] = await drizzle.select().from(category).where(eq(category.slug, slug)).limit(1) 68 | 69 | if (!categoryResult) { 70 | return ctx.json( 71 | { 72 | success: false, 73 | message: 'Category not found', 74 | }, 75 | 404, 76 | ) 77 | } 78 | 79 | return ctx.json( 80 | { 81 | success: true, 82 | category: categoryResult, 83 | }, 84 | 200, 85 | ) 86 | } catch (error) { 87 | console.error('Category fetch error:', error) 88 | return ctx.json( 89 | { 90 | success: false, 91 | message: 'Failed to fetch category', 92 | }, 93 | 500, 94 | ) 95 | } 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /src/routes/game/all.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { eq } from 'drizzle-orm' 5 | import { category, game } from '~/lib/db/schema' 6 | import { createRoute } from '@hono/zod-openapi' 7 | import { GenericResponses } from '~/lib/response-schemas' 8 | import { cache } from 'hono/cache' 9 | 10 | const responseSchema = z.object({ 11 | success: z.boolean(), 12 | games: z.array( 13 | z.object({ 14 | id: z.string(), 15 | slug: z.string(), 16 | name: z.string(), 17 | lastUpdated: z.string(), 18 | assetCount: z.number(), 19 | categories: z.array( 20 | z.object({ 21 | id: z.string(), 22 | slug: z.string(), 23 | name: z.string(), 24 | }), 25 | ), 26 | }), 27 | ), 28 | }) 29 | 30 | const openRoute = createRoute({ 31 | path: '/all', 32 | method: 'get', 33 | summary: 'Get all games', 34 | description: 'Get all games with their linked categories.', 35 | tags: ['Game'], 36 | responses: { 37 | 200: { 38 | description: 'Success', 39 | content: { 40 | 'application/json': { 41 | schema: responseSchema, 42 | }, 43 | }, 44 | }, 45 | ...GenericResponses, 46 | }, 47 | }) 48 | 49 | export const GameAllRoute = (handler: AppHandler) => { 50 | handler.use( 51 | '/all', 52 | cache({ 53 | cacheName: 'game-all', 54 | cacheControl: 'max-age=300, s-maxage=300', 55 | }), 56 | ) 57 | 58 | handler.openapi(openRoute, async ctx => { 59 | const { drizzle } = getConnection(ctx.env) 60 | 61 | try { 62 | const games = await drizzle.query.game.findMany({ 63 | with: { 64 | gameToCategories: { 65 | with: { 66 | category: true, 67 | }, 68 | }, 69 | }, 70 | }) 71 | 72 | if (!games) { 73 | return ctx.json( 74 | { 75 | success: true, 76 | games: [], 77 | }, 78 | 200, 79 | ) 80 | } 81 | 82 | const formattedGames = games.map(g => ({ 83 | id: g.id, 84 | slug: g.slug, 85 | name: g.name, 86 | lastUpdated: g.lastUpdated.toISOString(), 87 | assetCount: g.assetCount, 88 | categories: g.gameToCategories.map(gtc => ({ 89 | id: gtc.category.id, 90 | slug: gtc.category.slug, 91 | name: gtc.category.name, 92 | })), 93 | })) 94 | 95 | return ctx.json( 96 | { 97 | success: true, 98 | games: formattedGames || [], 99 | }, 100 | 200, 101 | ) 102 | } catch (error) { 103 | return ctx.json( 104 | { 105 | success: false, 106 | message: 'Failed to fetch games', 107 | }, 108 | 500, 109 | ) 110 | } 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /src/routes/game/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthVariables, Env } from '~/lib/handler' 2 | import { OpenAPIHono } from '@hono/zod-openapi' 3 | import { GameAllRoute } from './all' 4 | import { GameSlugRoute } from './slug' 5 | 6 | export const GameHandler = new OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }>() 7 | 8 | GameAllRoute(GameHandler) 9 | GameSlugRoute(GameHandler) 10 | -------------------------------------------------------------------------------- /src/routes/game/slug.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { eq } from 'drizzle-orm' 5 | import { game } from '~/lib/db/schema' 6 | import { createRoute } from '@hono/zod-openapi' 7 | import { GenericResponses } from '~/lib/response-schemas' 8 | import { cache } from 'hono/cache' 9 | 10 | const paramsSchema = z.object({ 11 | slug: z.string().openapi({ 12 | param: { 13 | description: 'The game slug', 14 | in: 'path', 15 | name: 'slug', 16 | required: true, 17 | }, 18 | example: 'genshin-impact', 19 | }), 20 | }) 21 | 22 | const responseSchema = z.object({ 23 | success: z.boolean(), 24 | game: z.object({ 25 | id: z.string(), 26 | slug: z.string(), 27 | name: z.string(), 28 | lastUpdated: z.string(), 29 | assetCount: z.number(), 30 | categories: z.array( 31 | z.object({ 32 | id: z.string(), 33 | slug: z.string(), 34 | name: z.string(), 35 | }), 36 | ), 37 | }), 38 | }) 39 | 40 | const openRoute = createRoute({ 41 | path: '/{slug}', 42 | method: 'get', 43 | summary: 'Get game by slug', 44 | description: 'Get a specific game by its slug with linked categories.', 45 | tags: ['Game'], 46 | request: { 47 | params: paramsSchema, 48 | }, 49 | responses: { 50 | 200: { 51 | description: 'Success', 52 | content: { 53 | 'application/json': { 54 | schema: responseSchema, 55 | }, 56 | }, 57 | }, 58 | ...GenericResponses, 59 | }, 60 | }) 61 | 62 | export const GameSlugRoute = (handler: AppHandler) => { 63 | handler.use( 64 | '/{slug}', 65 | cache({ 66 | cacheName: 'game-slug', 67 | cacheControl: 'max-age=43200, s-maxage=43200', 68 | }), 69 | ) 70 | 71 | handler.openapi(openRoute, async ctx => { 72 | const { slug } = ctx.req.valid('param') 73 | const { drizzle } = getConnection(ctx.env) 74 | 75 | try { 76 | const gameData = await drizzle.query.game.findFirst({ 77 | where: eq(game.slug, slug), 78 | with: { 79 | gameToCategories: { 80 | with: { 81 | category: true, 82 | }, 83 | }, 84 | }, 85 | }) 86 | 87 | if (!gameData) { 88 | return ctx.json( 89 | { 90 | success: false, 91 | message: 'Game not found', 92 | }, 93 | 404, 94 | ) 95 | } 96 | 97 | const formattedGame = { 98 | id: gameData.id, 99 | slug: gameData.slug, 100 | name: gameData.name, 101 | lastUpdated: gameData.lastUpdated.toISOString(), 102 | assetCount: gameData.assetCount, 103 | categories: gameData.gameToCategories.map(gtc => ({ 104 | id: gtc.category.id, 105 | slug: gtc.category.slug, 106 | name: gtc.category.name, 107 | })), 108 | } 109 | 110 | return ctx.json( 111 | { 112 | success: true, 113 | game: formattedGame, 114 | }, 115 | 200, 116 | ) 117 | } catch (error) { 118 | console.error('Game fetch error:', error) 119 | return ctx.json( 120 | { 121 | success: false, 122 | message: 'Failed to fetch game', 123 | }, 124 | 500, 125 | ) 126 | } 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /src/routes/tag/all.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { getConnection } from '~/lib/db/connection' 4 | import { tag } from '~/lib/db/schema' 5 | import { createRoute } from '@hono/zod-openapi' 6 | import { GenericResponses } from '~/lib/response-schemas' 7 | import { cache } from 'hono/cache' 8 | 9 | const responseSchema = z.object({ 10 | success: z.boolean(), 11 | tags: z.array( 12 | z.object({ 13 | id: z.string(), 14 | name: z.string(), 15 | slug: z.string(), 16 | color: z.string().nullable(), 17 | }), 18 | ), 19 | }) 20 | 21 | const openRoute = createRoute({ 22 | path: '/all', 23 | method: 'get', 24 | summary: 'Get all tags', 25 | description: 'Get all available tags.', 26 | tags: ['Tag'], 27 | responses: { 28 | 200: { 29 | description: 'Success', 30 | content: { 31 | 'application/json': { 32 | schema: responseSchema, 33 | }, 34 | }, 35 | }, 36 | ...GenericResponses, 37 | }, 38 | }) 39 | 40 | export const TagAllRoute = (handler: AppHandler) => { 41 | handler.use( 42 | '/all', 43 | cache({ 44 | cacheName: 'tag-all', 45 | cacheControl: 'max-age=43200, s-maxage=43200', 46 | }), 47 | ) 48 | 49 | handler.openapi(openRoute, async ctx => { 50 | const { drizzle } = getConnection(ctx.env) 51 | 52 | try { 53 | const tags = await drizzle.select().from(tag) 54 | 55 | return ctx.json( 56 | { 57 | success: true, 58 | tags: tags || [], 59 | }, 60 | 200, 61 | ) 62 | } catch (error) { 63 | console.error('Tag list error:', error) 64 | return ctx.json( 65 | { 66 | success: false, 67 | message: 'Failed to fetch tags', 68 | }, 69 | 500, 70 | ) 71 | } 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/routes/tag/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthVariables, Env } from '~/lib/handler' 2 | import { OpenAPIHono } from '@hono/zod-openapi' 3 | import { TagAllRoute } from './all' 4 | 5 | export const TagHandler = new OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }>() 6 | 7 | TagAllRoute(TagHandler) 8 | -------------------------------------------------------------------------------- /src/routes/user/download-history.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { createRoute } from '@hono/zod-openapi' 4 | import { GenericResponses } from '~/lib/response-schemas' 5 | import { requireAuth } from '~/lib/auth/middleware' 6 | import { getConnection } from '~/lib/db/connection' 7 | import { eq, desc, inArray, sql, and } from 'drizzle-orm' 8 | import { asset, assetToTag, category, game, tag, user, downloadHistory, downloadHistoryToAsset } from '~/lib/db/schema' 9 | 10 | const querySchema = z.object({ 11 | page: z 12 | .string() 13 | .optional() 14 | .default('1') 15 | .transform(val => parseInt(val, 10)), 16 | limit: z 17 | .string() 18 | .optional() 19 | .default('20') 20 | .transform(val => Math.min(50, Math.max(1, parseInt(val, 10)))), 21 | }) 22 | 23 | const responseSchema = z.object({ 24 | success: z.boolean(), 25 | downloadHistory: z.array( 26 | z.object({ 27 | historyId: z.string(), 28 | downloadedAt: z.string(), 29 | assets: z.array( 30 | z.object({ 31 | id: z.string(), 32 | name: z.string(), 33 | gameId: z.string(), 34 | gameName: z.string(), 35 | gameSlug: z.string(), 36 | categoryId: z.string(), 37 | categoryName: z.string(), 38 | categorySlug: z.string(), 39 | downloadCount: z.number(), 40 | viewCount: z.number(), 41 | size: z.number(), 42 | extension: z.string(), 43 | createdAt: z.string(), 44 | isSuggestive: z.boolean(), 45 | tags: z.array( 46 | z.object({ 47 | id: z.string(), 48 | name: z.string(), 49 | slug: z.string(), 50 | color: z.string().nullable(), 51 | }), 52 | ), 53 | uploadedBy: z.object({ 54 | id: z.string(), 55 | username: z.string().nullable(), 56 | image: z.string().nullable(), 57 | }), 58 | }), 59 | ), 60 | }), 61 | ), 62 | pagination: z.object({ 63 | page: z.number(), 64 | limit: z.number(), 65 | total: z.number(), 66 | totalPages: z.number(), 67 | hasNext: z.boolean(), 68 | hasPrev: z.boolean(), 69 | }), 70 | }) 71 | 72 | const openRoute = createRoute({ 73 | path: '/download-history', 74 | method: 'get', 75 | summary: 'Get download history', 76 | description: 'Get the download history for the current user with pagination (max 500 total entries stored).', 77 | tags: ['User'], 78 | request: { 79 | query: querySchema, 80 | }, 81 | responses: { 82 | 200: { 83 | description: 'Download history retrieved successfully', 84 | content: { 85 | 'application/json': { 86 | schema: responseSchema, 87 | }, 88 | }, 89 | }, 90 | ...GenericResponses, 91 | }, 92 | }) 93 | 94 | export const UserDownloadHistoryRoute = (handler: AppHandler) => { 95 | handler.use('/download-history', requireAuth) 96 | 97 | handler.openapi(openRoute, async ctx => { 98 | const currentUser = ctx.get('user') 99 | if (!currentUser) { 100 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 101 | } 102 | const { drizzle } = getConnection(ctx.env) 103 | const { page, limit } = ctx.req.valid('query') // Fixed at 100 per page 104 | 105 | try { 106 | const [allGames, allCategories, allTags] = await Promise.all([ 107 | drizzle 108 | .select({ 109 | id: game.id, 110 | name: game.name, 111 | slug: game.slug, 112 | lastUpdated: game.lastUpdated, 113 | assetCount: game.assetCount, 114 | }) 115 | .from(game), 116 | drizzle 117 | .select({ 118 | id: category.id, 119 | name: category.name, 120 | slug: category.slug, 121 | }) 122 | .from(category), 123 | drizzle 124 | .select({ 125 | id: tag.id, 126 | name: tag.name, 127 | slug: tag.slug, 128 | color: tag.color, 129 | }) 130 | .from(tag), 131 | ]) 132 | 133 | const gameMap = Object.fromEntries(allGames.map(g => [g.id, g])) 134 | const categoryMap = Object.fromEntries(allCategories.map(c => [c.id, c])) 135 | const tagMap = Object.fromEntries(allTags.map(t => [t.id, t])) 136 | 137 | const [countResult] = await drizzle 138 | .select({ count: sql`count(*)` }) 139 | .from(downloadHistory) 140 | .where(eq(downloadHistory.userId, currentUser.id)) 141 | 142 | const total = countResult?.count || 0 143 | const totalPages = Math.ceil(total / limit) 144 | const offset = (page - 1) * limit 145 | 146 | const historyBatches = await drizzle 147 | .select({ 148 | historyId: downloadHistory.id, 149 | downloadedAt: downloadHistory.createdAt, 150 | }) 151 | .from(downloadHistory) 152 | .where(eq(downloadHistory.userId, currentUser.id)) 153 | .orderBy(desc(downloadHistory.createdAt)) 154 | .limit(limit) 155 | .offset(offset) 156 | 157 | if (historyBatches.length === 0) { 158 | return ctx.json( 159 | { 160 | success: true, 161 | downloadHistory: [], 162 | pagination: { 163 | page, 164 | limit, 165 | total, 166 | totalPages, 167 | hasNext: page < totalPages, 168 | hasPrev: page > 1, 169 | }, 170 | }, 171 | 200, 172 | ) 173 | } 174 | 175 | const historyIds = historyBatches.map(h => h.historyId) 176 | const assetLinks = await drizzle 177 | .select({ 178 | historyId: downloadHistoryToAsset.downloadHistoryId, 179 | assetId: downloadHistoryToAsset.assetId, 180 | }) 181 | .from(downloadHistoryToAsset) 182 | .where(inArray(downloadHistoryToAsset.downloadHistoryId, historyIds)) 183 | 184 | const assetsByHistory = assetLinks.reduce( 185 | (acc, link) => { 186 | if (!acc[link.historyId]) { 187 | acc[link.historyId] = [] 188 | } 189 | acc[link.historyId]!.push(link.assetId) 190 | return acc 191 | }, 192 | {} as Record, 193 | ) 194 | 195 | const allAssetIds = [...new Set(assetLinks.map(l => l.assetId))] 196 | 197 | if (allAssetIds.length === 0) { 198 | return ctx.json( 199 | { 200 | success: true, 201 | downloadHistory: historyBatches.map(h => ({ 202 | historyId: h.historyId, 203 | downloadedAt: h.downloadedAt.toISOString(), 204 | assets: [], 205 | })), 206 | pagination: { 207 | page, 208 | limit, 209 | total, 210 | totalPages, 211 | hasNext: page < totalPages, 212 | hasPrev: page > 1, 213 | }, 214 | }, 215 | 200, 216 | ) 217 | } 218 | 219 | const assets = await drizzle 220 | .select({ 221 | id: asset.id, 222 | name: asset.name, 223 | gameId: asset.gameId, 224 | categoryId: asset.categoryId, 225 | downloadCount: asset.downloadCount, 226 | viewCount: asset.viewCount, 227 | size: asset.size, 228 | extension: asset.extension, 229 | createdAt: asset.createdAt, 230 | isSuggestive: asset.isSuggestive, 231 | uploadedBy: asset.uploadedBy, 232 | }) 233 | .from(asset) 234 | .where(inArray(asset.id, allAssetIds)) 235 | 236 | const assetTags = await drizzle 237 | .select({ 238 | assetId: assetToTag.assetId, 239 | tagId: assetToTag.tagId, 240 | }) 241 | .from(assetToTag) 242 | .where(inArray(assetToTag.assetId, allAssetIds)) 243 | 244 | const tagsByAsset = assetTags.reduce( 245 | (acc, link) => { 246 | if (!acc[link.assetId]) { 247 | acc[link.assetId] = [] 248 | } 249 | const tagData = tagMap[link.tagId] 250 | if (tagData) { 251 | acc[link.assetId]!.push({ 252 | id: tagData.id, 253 | name: tagData.name, 254 | slug: tagData.slug, 255 | color: tagData.color, 256 | }) 257 | } 258 | return acc 259 | }, 260 | {} as Record, 261 | ) 262 | 263 | const uploaderIds = [...new Set(assets.map(a => a.uploadedBy))] 264 | const uploaders = await drizzle 265 | .select({ 266 | id: user.id, 267 | username: user.name, 268 | image: user.image, 269 | }) 270 | .from(user) 271 | .where(inArray(user.id, uploaderIds)) 272 | 273 | const uploaderMap = Object.fromEntries(uploaders.map(u => [u.id, u])) 274 | 275 | const assetMap = Object.fromEntries(assets.map(a => [a.id, a])) 276 | 277 | const formattedHistory = historyBatches.map(batch => ({ 278 | historyId: batch.historyId, 279 | downloadedAt: batch.downloadedAt.toISOString(), 280 | assets: (assetsByHistory[batch.historyId] || []) 281 | .map(assetId => { 282 | const assetData = assetMap[assetId] 283 | if (!assetData) { 284 | return null 285 | } 286 | const gameData = gameMap[assetData.gameId] 287 | const categoryData = categoryMap[assetData.categoryId] 288 | return { 289 | id: assetData.id, 290 | name: assetData.name, 291 | gameId: assetData.gameId, 292 | gameName: gameData?.name || 'Unknown', 293 | gameSlug: gameData?.slug || '', 294 | categoryId: assetData.categoryId, 295 | categoryName: categoryData?.name || 'Unknown', 296 | categorySlug: categoryData?.slug || '', 297 | downloadCount: assetData.downloadCount, 298 | viewCount: assetData.viewCount, 299 | size: assetData.size, 300 | extension: assetData.extension, 301 | createdAt: assetData.createdAt.toISOString(), 302 | isSuggestive: assetData.isSuggestive, 303 | tags: tagsByAsset[assetData.id] || [], 304 | uploadedBy: uploaderMap[assetData.uploadedBy] || { 305 | id: assetData.uploadedBy, 306 | username: null, 307 | image: null, 308 | }, 309 | } 310 | }) 311 | .filter((asset): asset is NonNullable => asset !== null), 312 | })) 313 | 314 | return ctx.json( 315 | { 316 | success: true, 317 | downloadHistory: formattedHistory, 318 | pagination: { 319 | page, 320 | limit, 321 | total, 322 | totalPages, 323 | hasNext: page < totalPages, 324 | hasPrev: page > 1, 325 | }, 326 | }, 327 | 200, 328 | ) 329 | } catch (error: any) { 330 | console.error('Download history error:', error) 331 | return ctx.json( 332 | { 333 | success: false, 334 | message: error?.message || 'Failed to get download history', 335 | }, 336 | 500, 337 | ) 338 | } 339 | }) 340 | } 341 | -------------------------------------------------------------------------------- /src/routes/user/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthVariables, Env } from '~/lib/handler' 2 | import { OpenAPIHono } from '@hono/zod-openapi' 3 | import { authMiddleware } from '~/lib/auth/middleware' 4 | import { UserSavedAssetsListRoute } from './saved-assets-list' 5 | import { UserSaveAssetRoute } from './save-asset' 6 | import { UserUnsaveAssetRoute } from './unsave-asset' 7 | import { UserSavedAssetsIdRoute } from './saved-asset-id' 8 | import { UserUpdateAttributesRoute } from './update-attributes' 9 | import { UserDownloadHistoryRoute } from './download-history' 10 | 11 | export const UserHandler = new OpenAPIHono<{ Bindings: Env; Variables: AuthVariables }>() 12 | 13 | UserHandler.use('*', authMiddleware) 14 | 15 | UserSavedAssetsListRoute(UserHandler) 16 | UserSaveAssetRoute(UserHandler) 17 | UserUnsaveAssetRoute(UserHandler) 18 | UserSavedAssetsIdRoute(UserHandler) 19 | UserUpdateAttributesRoute(UserHandler) 20 | UserDownloadHistoryRoute(UserHandler) 21 | -------------------------------------------------------------------------------- /src/routes/user/save-asset.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { createRoute } from '@hono/zod-openapi' 4 | import { GenericResponses } from '~/lib/response-schemas' 5 | import { requireAuth } from '~/lib/auth/middleware' 6 | import { getConnection } from '~/lib/db/connection' 7 | import { eq, and } from 'drizzle-orm' 8 | import { asset, savedAsset } from '~/lib/db/schema' 9 | 10 | const paramsSchema = z.object({ 11 | id: z.string().openapi({ 12 | param: { 13 | name: 'id', 14 | in: 'path', 15 | }, 16 | description: 'ID of the asset to save', 17 | example: 'asset_123', 18 | }), 19 | }) 20 | 21 | const responseSchema = z.object({ 22 | success: z.boolean(), 23 | message: z.string(), 24 | savedAsset: z 25 | .object({ 26 | id: z.string(), 27 | assetId: z.string(), 28 | savedAt: z.string(), 29 | }) 30 | .optional(), 31 | }) 32 | 33 | const openRoute = createRoute({ 34 | path: '/saved-assets/{id}', 35 | method: 'post', 36 | summary: 'Save asset', 37 | description: "Save an asset to the current user's collection.", 38 | tags: ['User'], 39 | request: { 40 | params: paramsSchema, 41 | }, 42 | responses: { 43 | 201: { 44 | description: 'Asset saved successfully', 45 | content: { 46 | 'application/json': { 47 | schema: responseSchema, 48 | }, 49 | }, 50 | }, 51 | ...GenericResponses, 52 | }, 53 | }) 54 | 55 | export const UserSaveAssetRoute = (handler: AppHandler) => { 56 | handler.use('/saved-assets/*', requireAuth) 57 | 58 | handler.openapi(openRoute, async ctx => { 59 | const { id: assetId } = ctx.req.valid('param') 60 | const currentUser = ctx.get('user') 61 | if (!currentUser) { 62 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 63 | } 64 | const { drizzle } = getConnection(ctx.env) 65 | 66 | try { 67 | const assetExists = await drizzle.select().from(asset).where(eq(asset.id, assetId)).limit(1) 68 | 69 | if (assetExists.length === 0) { 70 | return ctx.json( 71 | { 72 | success: false, 73 | message: 'Asset not found', 74 | }, 75 | 404, 76 | ) 77 | } 78 | 79 | const existingSave = await drizzle 80 | .select() 81 | .from(savedAsset) 82 | .where(and(eq(savedAsset.userId, currentUser.id), eq(savedAsset.assetId, assetId))) 83 | .limit(1) 84 | 85 | if (existingSave.length > 0) { 86 | return ctx.json( 87 | { 88 | success: false, 89 | message: 'Asset already saved', 90 | }, 91 | 400, 92 | ) 93 | } 94 | 95 | const newSavedAsset = await drizzle 96 | .insert(savedAsset) 97 | .values({ 98 | id: `saved_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, 99 | userId: currentUser.id, 100 | assetId: assetId, 101 | }) 102 | .returning() 103 | 104 | const saved = newSavedAsset[0]! 105 | 106 | return ctx.json( 107 | { 108 | success: true, 109 | message: 'Asset saved successfully', 110 | savedAsset: { 111 | id: saved.id, 112 | assetId: saved.assetId, 113 | savedAt: saved.createdAt.toISOString(), 114 | }, 115 | }, 116 | 201, 117 | ) 118 | } catch (error: any) { 119 | console.error('Save asset error:', error) 120 | return ctx.json( 121 | { 122 | success: false, 123 | message: error?.message || 'Failed to save asset', 124 | }, 125 | 500, 126 | ) 127 | } 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /src/routes/user/saved-asset-id.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { createRoute } from '@hono/zod-openapi' 4 | import { GenericResponses } from '~/lib/response-schemas' 5 | import { requireAuth } from '~/lib/auth/middleware' 6 | import { getConnection } from '~/lib/db/connection' 7 | import { eq, and } from 'drizzle-orm' 8 | import { savedAsset } from '~/lib/db/schema' 9 | 10 | const responseSchema = z.object({ 11 | success: z.boolean(), 12 | savedAsset: z.boolean(), 13 | }) 14 | 15 | const paramsSchema = z.object({ 16 | id: z.string().openapi({ 17 | param: { 18 | name: 'id', 19 | in: 'path', 20 | }, 21 | description: 'ID of the asset to check', 22 | example: 'asset_123', 23 | }), 24 | }) 25 | 26 | const openRoute = createRoute({ 27 | path: '/check-saved-asset/{id}', 28 | method: 'get', 29 | summary: 'Check if a user has saved an asset', 30 | description: 'Check if a user has saved an asset by the current user and the asset id.', 31 | tags: ['User'], 32 | request: { 33 | params: paramsSchema, 34 | }, 35 | responses: { 36 | 200: { 37 | description: 'Saved asset retrieved successfully', 38 | content: { 39 | 'application/json': { 40 | schema: responseSchema, 41 | }, 42 | }, 43 | }, 44 | ...GenericResponses, 45 | }, 46 | }) 47 | 48 | export const UserSavedAssetsIdRoute = (handler: AppHandler) => { 49 | handler.use('/check-saved-asset/*', requireAuth) 50 | 51 | handler.openapi(openRoute, async ctx => { 52 | const currentUser = ctx.get('user') 53 | 54 | if (!currentUser) { 55 | return ctx.json( 56 | { 57 | success: false, 58 | message: 'Unauthorized', 59 | }, 60 | 401, 61 | ) 62 | } 63 | 64 | const { drizzle } = getConnection(ctx.env) 65 | 66 | const { id: assetId } = ctx.req.valid('param') 67 | 68 | try { 69 | const [savedAssetResponse] = await drizzle 70 | .select() 71 | .from(savedAsset) 72 | .where(and(eq(savedAsset.userId, currentUser.id), eq(savedAsset.assetId, assetId))) 73 | .limit(1) 74 | 75 | return ctx.json( 76 | { 77 | success: true, 78 | savedAsset: savedAssetResponse ? true : false, 79 | }, 80 | 200, 81 | ) 82 | } catch (error: any) { 83 | return ctx.json( 84 | { 85 | success: false, 86 | message: error?.message || 'Failed to get saved asset', 87 | }, 88 | 500, 89 | ) 90 | } 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /src/routes/user/saved-assets-list.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { createRoute } from '@hono/zod-openapi' 4 | import { GenericResponses } from '~/lib/response-schemas' 5 | import { requireAuth } from '~/lib/auth/middleware' 6 | import { getConnection } from '~/lib/db/connection' 7 | import { eq, desc, inArray, like, and, sql, asc, type SQL } from 'drizzle-orm' 8 | import { asset, assetToTag, category, game, tag, savedAsset, user } from '~/lib/db/schema' 9 | 10 | const querySchema = z.object({ 11 | offset: z 12 | .string() 13 | .optional() 14 | .default('0') 15 | .transform(val => Math.max(0, parseInt(val, 10))), 16 | search: z.string().optional(), 17 | games: z.string().optional().describe('Comma-separated list of game slugs to filter by'), 18 | categories: z.string().optional().describe('Comma-separated list of category slugs to filter by'), 19 | tags: z.string().optional().describe('Comma-separated list of tag slugs to filter by'), 20 | sortBy: z.enum(['savedAt', 'viewCount', 'downloadCount', 'uploadDate', 'name']).optional().default('savedAt'), 21 | sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), 22 | }) 23 | 24 | const responseSchema = z.object({ 25 | success: z.boolean(), 26 | savedAssets: z.array( 27 | z.object({ 28 | id: z.string(), 29 | name: z.string(), 30 | gameId: z.string(), 31 | gameName: z.string(), 32 | gameSlug: z.string(), 33 | categoryId: z.string(), 34 | categoryName: z.string(), 35 | categorySlug: z.string(), 36 | downloadCount: z.number(), 37 | viewCount: z.number(), 38 | size: z.number(), 39 | extension: z.string(), 40 | createdAt: z.string(), 41 | isSuggestive: z.boolean(), 42 | tags: z.array( 43 | z.object({ 44 | id: z.string(), 45 | name: z.string(), 46 | slug: z.string(), 47 | color: z.string().nullable(), 48 | }), 49 | ), 50 | uploadedBy: z.object({ 51 | id: z.string(), 52 | username: z.string().nullable(), 53 | image: z.string().nullable(), 54 | }), 55 | }), 56 | ), 57 | pagination: z.object({ 58 | offset: z.number(), 59 | hasNext: z.boolean(), 60 | }), 61 | }) 62 | 63 | const openRoute = createRoute({ 64 | path: '/saved-assets', 65 | method: 'get', 66 | summary: 'Get saved assets', 67 | description: 'Get assets saved by the current user with pagination and search.', 68 | tags: ['User'], 69 | request: { 70 | query: querySchema, 71 | }, 72 | responses: { 73 | 200: { 74 | description: 'Saved assets retrieved successfully', 75 | content: { 76 | 'application/json': { 77 | schema: responseSchema, 78 | }, 79 | }, 80 | }, 81 | ...GenericResponses, 82 | }, 83 | }) 84 | 85 | export const UserSavedAssetsListRoute = (handler: AppHandler) => { 86 | handler.use('/saved-assets', requireAuth) 87 | 88 | handler.openapi(openRoute, async ctx => { 89 | const currentUser = ctx.get('user') 90 | if (!currentUser) { 91 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 92 | } 93 | const { drizzle } = getConnection(ctx.env) 94 | const { offset, search, games, categories, tags, sortBy, sortOrder } = ctx.req.valid('query') 95 | 96 | try { 97 | const [allGames, allCategories, allTags] = await Promise.all([ 98 | drizzle 99 | .select({ 100 | id: game.id, 101 | name: game.name, 102 | slug: game.slug, 103 | lastUpdated: game.lastUpdated, 104 | assetCount: game.assetCount, 105 | }) 106 | .from(game), 107 | drizzle 108 | .select({ 109 | id: category.id, 110 | name: category.name, 111 | slug: category.slug, 112 | }) 113 | .from(category), 114 | drizzle 115 | .select({ 116 | id: tag.id, 117 | name: tag.name, 118 | slug: tag.slug, 119 | color: tag.color, 120 | }) 121 | .from(tag), 122 | ]) 123 | 124 | const gameMap = Object.fromEntries(allGames.map(g => [g.id, g])) 125 | const categoryMap = Object.fromEntries(allCategories.map(c => [c.id, c])) 126 | const tagMap = Object.fromEntries(allTags.map(t => [t.id, t])) 127 | 128 | const conditions: SQL[] = [eq(savedAsset.userId, currentUser.id)] 129 | 130 | if (search) { 131 | conditions.push(like(asset.name, `%${search}%`)) 132 | } 133 | 134 | if (games) { 135 | const gamesSlugs = games 136 | .split(',') 137 | .map(s => s.trim()) 138 | .filter(Boolean) 139 | if (gamesSlugs.length > 0) { 140 | const gameIds = allGames.filter(g => gamesSlugs.includes(g.slug)).map(g => g.id) 141 | if (gameIds.length > 0) { 142 | conditions.push(inArray(asset.gameId, gameIds)) 143 | } 144 | } 145 | } 146 | 147 | if (categories) { 148 | const categorySlugs = categories 149 | .split(',') 150 | .map(s => s.trim()) 151 | .filter(Boolean) 152 | if (categorySlugs.length > 0) { 153 | const categoryIds = allCategories.filter(c => categorySlugs.includes(c.slug)).map(c => c.id) 154 | if (categoryIds.length > 0) { 155 | conditions.push(inArray(asset.categoryId, categoryIds)) 156 | } 157 | } 158 | } 159 | 160 | let tagFilteredAssetIds: string[] | null = null 161 | if (tags) { 162 | const tagSlugs = tags 163 | .split(',') 164 | .map(s => s.trim()) 165 | .filter(Boolean) 166 | if (tagSlugs.length > 0) { 167 | const tagIds = await drizzle.select({ id: tag.id }).from(tag).where(inArray(tag.slug, tagSlugs)) 168 | 169 | if (tagIds.length > 0) { 170 | const tagIdList = tagIds.map(t => t.id) 171 | const taggedAssets = await drizzle 172 | .select({ assetId: assetToTag.assetId }) 173 | .from(assetToTag) 174 | .where(inArray(assetToTag.tagId, tagIdList)) 175 | .groupBy(assetToTag.assetId) 176 | .having(sql`count(distinct ${assetToTag.tagId}) = ${tagIdList.length}`) 177 | 178 | tagFilteredAssetIds = taggedAssets.map(ta => ta.assetId) 179 | if (tagFilteredAssetIds.length === 0) { 180 | return ctx.json( 181 | { 182 | success: true, 183 | savedAssets: [], 184 | pagination: { 185 | offset, 186 | hasNext: false, 187 | }, 188 | }, 189 | 200, 190 | ) 191 | } 192 | conditions.push(inArray(asset.id, tagFilteredAssetIds)) 193 | } 194 | } 195 | } 196 | 197 | let orderByClause 198 | switch (sortBy) { 199 | case 'viewCount': 200 | orderByClause = sortOrder === 'asc' ? asc(asset.viewCount) : desc(asset.viewCount) 201 | break 202 | case 'downloadCount': 203 | orderByClause = sortOrder === 'asc' ? asc(asset.downloadCount) : desc(asset.downloadCount) 204 | break 205 | case 'uploadDate': 206 | orderByClause = sortOrder === 'asc' ? asc(asset.createdAt) : desc(asset.createdAt) 207 | break 208 | case 'name': 209 | orderByClause = sortOrder === 'asc' ? asc(asset.name) : desc(asset.name) 210 | break 211 | case 'savedAt': 212 | default: 213 | orderByClause = sortOrder === 'asc' ? asc(savedAsset.createdAt) : desc(savedAsset.createdAt) 214 | break 215 | } 216 | 217 | const savedAssets = await drizzle 218 | .select({ 219 | id: asset.id, 220 | name: asset.name, 221 | gameId: asset.gameId, 222 | categoryId: asset.categoryId, 223 | downloadCount: asset.downloadCount, 224 | viewCount: asset.viewCount, 225 | size: asset.size, 226 | extension: asset.extension, 227 | createdAt: asset.createdAt, 228 | isSuggestive: asset.isSuggestive, 229 | uploadedBy: asset.uploadedBy, 230 | savedAt: savedAsset.createdAt, 231 | }) 232 | .from(asset) 233 | .innerJoin(savedAsset, eq(asset.id, savedAsset.assetId)) 234 | .where(and(...conditions)) 235 | .orderBy(orderByClause) 236 | .limit(21) 237 | .offset(offset) 238 | 239 | const hasNext = savedAssets.length > 20 240 | const finalAssets = hasNext ? savedAssets.slice(0, 20) : savedAssets 241 | 242 | const assetTags = 243 | finalAssets.length > 0 244 | ? await drizzle 245 | .select({ 246 | assetId: assetToTag.assetId, 247 | tagId: assetToTag.tagId, 248 | }) 249 | .from(assetToTag) 250 | .where( 251 | inArray( 252 | assetToTag.assetId, 253 | finalAssets.map(savedAsset => savedAsset.id), 254 | ), 255 | ) 256 | : [] 257 | 258 | const tagsByAsset = assetTags.reduce( 259 | (acc, link) => { 260 | if (!acc[link.assetId]) { 261 | acc[link.assetId] = [] 262 | } 263 | const tagData = tagMap[link.tagId] 264 | if (tagData) { 265 | acc[link.assetId]!.push({ 266 | id: tagData.id, 267 | name: tagData.name, 268 | slug: tagData.slug, 269 | color: tagData.color, 270 | }) 271 | } 272 | return acc 273 | }, 274 | {} as Record, 275 | ) 276 | 277 | const uploaderIds = finalAssets.map(a => a.uploadedBy) 278 | const uploaders = 279 | uploaderIds.length > 0 280 | ? await drizzle 281 | .select({ 282 | id: user.id, 283 | username: user.name, 284 | image: user.image, 285 | }) 286 | .from(user) 287 | .where(inArray(user.id, uploaderIds)) 288 | : [] 289 | const uploaderMap = Object.fromEntries(uploaders.map(u => [u.id, u])) 290 | 291 | const formattedAssets = finalAssets.map(savedAsset => { 292 | const gameData = gameMap[savedAsset.gameId] 293 | const categoryData = categoryMap[savedAsset.categoryId] 294 | return { 295 | ...savedAsset, 296 | gameName: gameData?.name || 'Unknown', 297 | gameSlug: gameData?.slug || '', 298 | categoryName: categoryData?.name || 'Unknown', 299 | categorySlug: categoryData?.slug || '', 300 | createdAt: savedAsset.createdAt.toISOString(), 301 | tags: tagsByAsset[savedAsset.id] || [], 302 | uploadedBy: uploaderMap[savedAsset.uploadedBy] || { 303 | id: savedAsset.uploadedBy, 304 | username: null, 305 | image: null, 306 | }, 307 | } 308 | }) 309 | 310 | return ctx.json( 311 | { 312 | success: true, 313 | savedAssets: formattedAssets, 314 | pagination: { 315 | offset, 316 | hasNext, 317 | }, 318 | }, 319 | 200, 320 | ) 321 | } catch (error: any) { 322 | console.error('Saved assets list error:', error) 323 | return ctx.json( 324 | { 325 | success: false, 326 | message: error?.message || 'Failed to get saved assets', 327 | }, 328 | 500, 329 | ) 330 | } 331 | }) 332 | } 333 | -------------------------------------------------------------------------------- /src/routes/user/unsave-asset.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { createRoute } from '@hono/zod-openapi' 4 | import { GenericResponses } from '~/lib/response-schemas' 5 | import { requireAuth } from '~/lib/auth/middleware' 6 | import { getConnection } from '~/lib/db/connection' 7 | import { eq, and } from 'drizzle-orm' 8 | import { savedAsset } from '~/lib/db/schema' 9 | 10 | const paramsSchema = z.object({ 11 | assetId: z.string().openapi({ 12 | param: { 13 | description: 'ID of the asset to unsave', 14 | in: 'path', 15 | name: 'assetId', 16 | required: true, 17 | }, 18 | example: 'asset_123', 19 | }), 20 | }) 21 | 22 | const responseSchema = z.object({ 23 | success: z.boolean(), 24 | message: z.string(), 25 | }) 26 | 27 | const openRoute = createRoute({ 28 | path: '/saved-assets/{assetId}', 29 | method: 'delete', 30 | summary: 'Unsave asset', 31 | description: "Remove an asset from the current user's saved collection.", 32 | tags: ['User'], 33 | request: { 34 | params: paramsSchema, 35 | }, 36 | responses: { 37 | 200: { 38 | description: 'Asset unsaved successfully', 39 | content: { 40 | 'application/json': { 41 | schema: responseSchema, 42 | }, 43 | }, 44 | }, 45 | ...GenericResponses, 46 | }, 47 | }) 48 | 49 | export const UserUnsaveAssetRoute = (handler: AppHandler) => { 50 | handler.use('/saved-assets/*', requireAuth) 51 | 52 | handler.openapi(openRoute, async ctx => { 53 | const { assetId } = ctx.req.valid('param') 54 | const currentUser = ctx.get('user') 55 | if (!currentUser) { 56 | return ctx.json({ success: false, message: 'Unauthorized' }, 401) 57 | } 58 | const { drizzle } = getConnection(ctx.env) 59 | 60 | try { 61 | const existingSave = await drizzle 62 | .select() 63 | .from(savedAsset) 64 | .where(and(eq(savedAsset.userId, currentUser.id), eq(savedAsset.assetId, assetId))) 65 | .limit(1) 66 | 67 | if (existingSave.length === 0) { 68 | return ctx.json( 69 | { 70 | success: false, 71 | message: 'Asset not found in saved collection', 72 | }, 73 | 404, 74 | ) 75 | } 76 | 77 | await drizzle 78 | .delete(savedAsset) 79 | .where(and(eq(savedAsset.userId, currentUser.id), eq(savedAsset.assetId, assetId))) 80 | 81 | return ctx.json( 82 | { 83 | success: true, 84 | message: 'Asset unsaved successfully', 85 | }, 86 | 200, 87 | ) 88 | } catch (error: any) { 89 | console.error('Unsave asset error:', error) 90 | return ctx.json( 91 | { 92 | success: false, 93 | message: error?.message || 'Failed to unsave asset', 94 | }, 95 | 500, 96 | ) 97 | } 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /src/routes/user/update-attributes.ts: -------------------------------------------------------------------------------- 1 | import { z } from '@hono/zod-openapi' 2 | import { AppHandler } from '~/lib/handler' 3 | import { createRoute } from '@hono/zod-openapi' 4 | import { GenericResponses } from '~/lib/response-schemas' 5 | import { requireAuth } from '~/lib/auth/middleware' 6 | import { eq } from 'drizzle-orm' 7 | import { getConnection } from '~/lib/db/connection' 8 | import { user } from '~/lib/db/schema' 9 | 10 | const requestSchema = z.object({ 11 | displayName: z 12 | .string() 13 | .min(1) 14 | .max(16) 15 | .optional() 16 | .transform(val => val?.trim() || null), 17 | }) 18 | 19 | const responseSchema = z.object({ 20 | success: z.boolean(), 21 | message: z.string(), 22 | user: z 23 | .object({ 24 | id: z.string(), 25 | name: z.string(), 26 | displayName: z.string().nullable(), 27 | email: z.string(), 28 | image: z.string().nullable(), 29 | }) 30 | .optional(), 31 | }) 32 | 33 | const updateAttributesRoute = createRoute({ 34 | path: '/update-attributes', 35 | method: 'patch', 36 | summary: 'Update user attributes', 37 | description: 'Update user-customizable attributes like display name.', 38 | tags: ['User'], 39 | request: { 40 | body: { 41 | content: { 42 | 'application/json': { 43 | schema: requestSchema, 44 | }, 45 | }, 46 | }, 47 | }, 48 | responses: { 49 | 200: { 50 | description: 'User attributes updated successfully', 51 | content: { 52 | 'application/json': { 53 | schema: responseSchema, 54 | }, 55 | }, 56 | }, 57 | ...GenericResponses, 58 | }, 59 | }) 60 | 61 | export const UserUpdateAttributesRoute = (handler: AppHandler) => { 62 | handler.use('/update-attributes', requireAuth) 63 | 64 | handler.openapi(updateAttributesRoute, async ctx => { 65 | const authUser = ctx.get('user') 66 | 67 | if (!authUser) { 68 | return ctx.json( 69 | { 70 | success: false, 71 | message: 'Unauthorized', 72 | }, 73 | 401, 74 | ) 75 | } 76 | 77 | const body = ctx.req.valid('json') 78 | const { drizzle } = getConnection(ctx.env) 79 | 80 | try { 81 | const updateData: any = { 82 | updatedAt: new Date(), 83 | } 84 | 85 | if (body.displayName !== undefined) { 86 | updateData.displayName = body.displayName 87 | } 88 | 89 | const [updatedUser] = await drizzle.update(user).set(updateData).where(eq(user.id, authUser.id)).returning({ 90 | id: user.id, 91 | name: user.name, 92 | displayName: user.displayName, 93 | email: user.email, 94 | image: user.image, 95 | }) 96 | 97 | if (!updatedUser) { 98 | return ctx.json( 99 | { 100 | success: false, 101 | message: 'Failed to update user attributes', 102 | }, 103 | 500, 104 | ) 105 | } 106 | 107 | return ctx.json( 108 | { 109 | success: true, 110 | message: 'User attributes updated successfully', 111 | user: updatedUser, 112 | }, 113 | 200, 114 | ) 115 | } catch (error: any) { 116 | console.error('Update user attributes error:', error) 117 | return ctx.json( 118 | { 119 | success: false, 120 | message: error?.message || 'Failed to update user attributes', 121 | }, 122 | 500, 123 | ) 124 | } 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /src/scripts/db/migrate.mts: -------------------------------------------------------------------------------- 1 | import { drizzle as drizzleORM } from 'drizzle-orm/libsql' 2 | import { migrate } from 'drizzle-orm/libsql/migrator' 3 | import { createClient } from '@libsql/client' 4 | import dotenv from 'dotenv' 5 | 6 | dotenv.config({ path: '.dev.vars' }) 7 | 8 | const { 9 | TURSO_DATABASE_AUTH_TOKEN, 10 | TURSO_DATABASE_URL, 11 | ENVIRONMENT, 12 | TURSO_DEV_DATABASE_URL = 'http://127.0.0.1:8080', 13 | } = process.env 14 | 15 | const isDev = ENVIRONMENT === 'DEV' 16 | const DATABASE_URL = isDev ? TURSO_DEV_DATABASE_URL : TURSO_DATABASE_URL 17 | const AUTH_TOKEN = isDev ? undefined : TURSO_DATABASE_AUTH_TOKEN 18 | 19 | async function main() { 20 | logMigrationDetails() 21 | 22 | if (!DATABASE_URL) { 23 | throw new Error('DATABASE_URL is not defined!') 24 | } else if (!AUTH_TOKEN && !isDev) { 25 | throw new Error('AUTH_TOKEN is not defined!') 26 | } 27 | 28 | await delayBeforeMigration(isDev ? 1000 : 10000) 29 | 30 | const client = createDatabaseClient(DATABASE_URL, AUTH_TOKEN) 31 | const db = drizzleORM(client) 32 | 33 | console.log('[MIGRATION] Migrating database...') 34 | await migrate(db, { migrationsFolder: './src/lib/db/migrations' }) 35 | console.log('[MIGRATION] Migrations complete!') 36 | 37 | client.close() 38 | } 39 | 40 | function logMigrationDetails() { 41 | console.log('[MIGRATION] DEV:', isDev) 42 | console.log(`[MIGRATION] URL: ${DATABASE_URL}`) 43 | } 44 | 45 | function delayBeforeMigration(waitTime: number) { 46 | console.log(`[MIGRATION] Waiting ${waitTime}ms until migration...`) 47 | return new Promise(resolve => setTimeout(resolve, waitTime)) 48 | } 49 | 50 | function createDatabaseClient(url: string, authToken: string | undefined) { 51 | console.log('[MIGRATION] Connecting to the database client...') 52 | const client = createClient({ url, authToken }) 53 | console.log('[MIGRATION] Connected to the database client & initialized drizzle-orm instance') 54 | return client 55 | } 56 | 57 | main().catch(err => { 58 | console.error(`[MIGRATION] Error: ${err}`) 59 | process.exit(1) 60 | }) 61 | -------------------------------------------------------------------------------- /src/scripts/db/seed.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { drizzle as drizzleORM } from 'drizzle-orm/libsql' 3 | import { createClient } from '@libsql/client' 4 | import { faker } from '@faker-js/faker' 5 | import { eq } from 'drizzle-orm' 6 | import type { InferInsertModel } from 'drizzle-orm' 7 | import { user, game, category, asset, tag, assetToTag, savedAsset } from '~/lib/db/schema' 8 | 9 | type UserInsert = InferInsertModel 10 | type GameInsert = InferInsertModel 11 | type CategoryInsert = InferInsertModel 12 | type AssetInsert = InferInsertModel 13 | type TagInsert = InferInsertModel 14 | type AssetToTagInsert = InferInsertModel 15 | type SavedAssetInsert = InferInsertModel 16 | 17 | dotenv.config({ path: '.dev.vars' }) 18 | 19 | if (process.env.ENVIRONMENT !== 'DEV') { 20 | console.error('This script can only be run in development mode') 21 | process.exit(1) 22 | } 23 | 24 | const client = createClient({ 25 | url: process.env.TURSO_DATABASE_URL!, 26 | authToken: process.env.TURSO_DATABASE_AUTH_TOKEN!, 27 | }) 28 | 29 | const db = drizzleORM(client) 30 | 31 | const pickRandom = (arr: T[], count: number = 1): T[] => { 32 | const shuffled = [...arr].sort(() => 0.5 - Math.random()) 33 | return shuffled.slice(0, count) 34 | } 35 | 36 | async function seed() { 37 | console.log('Starting database seeding...') 38 | 39 | try { 40 | console.log('Creating users...') 41 | const usersToInsert: UserInsert[] = Array.from({ length: 10 }, () => ({ 42 | name: faker.person.fullName(), 43 | username: faker.internet.userName().toLowerCase(), 44 | email: faker.internet.email().toLowerCase(), 45 | emailVerified: faker.datatype.boolean(), 46 | image: faker.image.avatar(), 47 | createdAt: faker.date.past({ years: 2 }), 48 | updatedAt: new Date(), 49 | })) 50 | const users = await db.insert(user).values(usersToInsert).returning() 51 | console.log(`Created ${users.length} users`) 52 | 53 | console.log('Creating games...') 54 | const gamesToInsert: GameInsert[] = [ 55 | { 56 | slug: 'genshin-impact', 57 | name: 'Genshin Impact', 58 | lastUpdated: faker.date.recent({ days: 30 }), 59 | assetCount: 0, 60 | }, 61 | { 62 | slug: 'honkai-impact-3rd', 63 | name: 'Honkai Impact: 3rd', 64 | lastUpdated: faker.date.recent({ days: 30 }), 65 | assetCount: 0, 66 | }, 67 | { 68 | slug: 'honkai-star-rail', 69 | name: 'Honkai Star Rail', 70 | lastUpdated: faker.date.recent({ days: 30 }), 71 | assetCount: 0, 72 | }, 73 | ] 74 | const games = await db.insert(game).values(gamesToInsert).returning() 75 | console.log(`Created ${games.length} games`) 76 | 77 | console.log('Creating categories...') 78 | const categoriesToInsert: CategoryInsert[] = [ 79 | { 80 | name: 'Character Sheets', 81 | slug: 'character-sheets', 82 | }, 83 | { 84 | name: 'Splash Art', 85 | slug: 'splash-art', 86 | }, 87 | { 88 | name: 'Emotes', 89 | slug: 'emotes', 90 | }, 91 | ] 92 | const categories = await db.insert(category).values(categoriesToInsert).returning() 93 | console.log(`Created ${categories.length} categories`) 94 | 95 | console.log('Creating tags...') 96 | const tagNames = ['fanmade', 'official', 'high-quality', 'unedited'] 97 | const tagsToInsert: TagInsert[] = tagNames.map(name => ({ 98 | name: name.charAt(0).toUpperCase() + name.slice(1), 99 | slug: name.toLowerCase().replace(/\s+/g, '-'), 100 | color: faker.color.rgb(), 101 | })) 102 | const tags = await db.insert(tag).values(tagsToInsert).returning() 103 | console.log(`Created ${tags.length} tags`) 104 | 105 | console.log('Creating assets...') 106 | const fileExtensions = ['.png', '.jpg', '.jpeg'] 107 | const assetsToInsert: AssetInsert[] = [] 108 | 109 | for (let i = 0; i < 50; i++) { 110 | const randomGame = pickRandom(games, 1)[0] 111 | const randomCategory = pickRandom(categories, 1)[0] 112 | const randomUser = pickRandom(users, 1)[0] 113 | const randomExtension = pickRandom(fileExtensions, 1)[0] 114 | 115 | if (!randomGame || !randomCategory || !randomUser || !randomExtension) continue 116 | 117 | assetsToInsert.push({ 118 | name: faker.lorem.words({ min: 2, max: 5 }), 119 | gameId: randomGame.id, 120 | categoryId: randomCategory.id, 121 | createdAt: faker.date.past({ years: 1 }), 122 | uploadedBy: randomUser.id, 123 | downloadCount: faker.number.int({ min: 0, max: 10000 }), 124 | viewCount: faker.number.int({ min: 0, max: 50000 }), 125 | hash: faker.string.alphanumeric(32), 126 | status: 'approved', 127 | isSuggestive: faker.datatype.boolean(), 128 | size: faker.number.int({ min: 100000, max: 10000000 }), 129 | extension: randomExtension, 130 | }) 131 | } 132 | const assets = await db.insert(asset).values(assetsToInsert).returning() 133 | console.log(`Created ${assets.length} assets`) 134 | 135 | console.log('Linking assets to tags...') 136 | const assetToTagLinks: AssetToTagInsert[] = [] 137 | for (const assetItem of assets) { 138 | const randomTags = pickRandom(tags, faker.number.int({ min: 1, max: 5 })) 139 | for (const tagItem of randomTags) { 140 | assetToTagLinks.push({ 141 | assetId: assetItem.id, 142 | tagId: tagItem.id, 143 | }) 144 | } 145 | } 146 | if (assetToTagLinks.length > 0) { 147 | await db.insert(assetToTag).values(assetToTagLinks) 148 | } 149 | console.log(`Created ${assetToTagLinks.length} asset-tag links`) 150 | 151 | console.log('Creating saved assets...') 152 | const savedAssetsToInsert: SavedAssetInsert[] = [] 153 | for (const userItem of users) { 154 | const randomAssets = pickRandom(assets, faker.number.int({ min: 0, max: 10 })) 155 | for (const assetItem of randomAssets) { 156 | savedAssetsToInsert.push({ 157 | userId: userItem.id, 158 | assetId: assetItem.id, 159 | createdAt: faker.date.recent({ days: 30 }), 160 | }) 161 | } 162 | } 163 | if (savedAssetsToInsert.length > 0) { 164 | await db.insert(savedAsset).values(savedAssetsToInsert) 165 | } 166 | console.log(`Created ${savedAssetsToInsert.length} saved assets`) 167 | 168 | console.log('Updating game statistics...') 169 | for (const gameItem of games) { 170 | const gameAssets = assets.filter(a => a.gameId === gameItem.id) 171 | await db 172 | .update(game) 173 | .set({ 174 | assetCount: gameAssets.length, 175 | lastUpdated: new Date(), 176 | }) 177 | .where(eq(game.id, gameItem.id)) 178 | } 179 | console.log('Updated game statistics') 180 | 181 | console.log('\nDatabase seeding completed successfully!') 182 | console.log('\nSummary:') 183 | console.log(` Users: ${users.length}`) 184 | console.log(` Games: ${games.length}`) 185 | console.log(` Categories: ${categories.length}`) 186 | console.log(` Tags: ${tags.length}`) 187 | console.log(` Assets: ${assets.length}`) 188 | console.log(` Asset-Tag Links: ${assetToTagLinks.length}`) 189 | console.log(` Saved Assets: ${savedAssetsToInsert.length}`) 190 | } catch (error) { 191 | console.error('Error during seeding:', error) 192 | process.exit(1) 193 | } finally { 194 | client.close() 195 | } 196 | } 197 | 198 | seed() 199 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "target": "es2021", 5 | "module": "es2022", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "lib": ["es2021"], 9 | "baseUrl": "./", 10 | "paths": { 11 | "~/*": ["src/*"] 12 | }, 13 | "strictNullChecks": true, 14 | "noUncheckedIndexedAccess": true, 15 | "types": ["@cloudflare/workers-types", "node"], 16 | "moduleDetection": "force" 17 | }, 18 | "include": ["src/**/*.ts", "src/scripts/db/seed.ts", "src/scripts/db/migrate.mts"] 19 | } 20 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "api-skowt-cc", 4 | "main": "src/index.ts", 5 | "compatibility_date": "2025-07-18", 6 | // "compatibility_flags": [ 7 | // "nodejs_compat" 8 | // ], 9 | // "vars": { 10 | // "MY_VAR": "my-variable" 11 | // }, 12 | // "kv_namespaces": [ 13 | // { 14 | // "binding": "MY_KV_NAMESPACE", 15 | // "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 16 | // } 17 | // ], 18 | "r2_buckets": [ 19 | { 20 | "binding": "CDN", 21 | "bucket_name": "cdn-asset-site", 22 | "preview_bucket_name": "cdn-asset-site", 23 | }, 24 | ], 25 | "durable_objects": { 26 | "bindings": [ 27 | { 28 | "name": "RATE_LIMITER", 29 | "class_name": "DurableObjectRateLimiter", 30 | }, 31 | ], 32 | }, 33 | "migrations": [ 34 | { 35 | "tag": "v1", 36 | "new_classes": ["DurableObjectRateLimiter"], 37 | }, 38 | ], 39 | // "d1_databases": [ 40 | // { 41 | // "binding": "MY_DB", 42 | // "database_name": "my-database", 43 | // "database_id": "" 44 | // } 45 | // ], 46 | // "ai": { 47 | // "binding": "AI" 48 | // }, 49 | // "observability": { 50 | // "enabled": true, 51 | // "head_sampling_rate": 1 52 | // } 53 | } 54 | --------------------------------------------------------------------------------