Skip to content
Merged
62 changes: 43 additions & 19 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,27 +1,51 @@
# --- Server-side secrets (not exposed to client)
# --- Server-side runtime config (not exposed to client)

# Admin Credentials (defaults to admin / 123)
NUXT_ADMIN_USERNAME=
NUXT_ADMIN_PASSWORD=
# Admin login username.
# Default from nuxt.config.ts: admin
NUXT_ADMIN_USERNAME=admin

# JWT Secret for Admin Authentication (defaults to tryUJ0zQbstPbTOrezme+Fv+KndzDNRx5lmSeelr2ial2/2yV8HqLeQ2felJafqf)
NUXT_JWT_SECRET=
# Admin login password.
# Default from nuxt.config.ts: 123
NUXT_ADMIN_PASSWORD=123

# --- Public configuration (available on both client and server)
# Private internal port for the embedded Drizzle Studio worker.
# Default from nuxt.config.ts: 64983
NUXT_DRIZZLE_STUDIO_INTERNAL_PORT=64983

# WebSocket URL for client connections (defaults to current host)
# NUXT_PUBLIC_WS_URL=ws://localhost:3000
# JWT secret used for admin authentication.
# Default from nuxt.config.ts: tryUJ0zQbstPbTOrezme+Fv+KndzDNRx5lmSeelr2ial2/2yV8HqLeQ2felJafqf
NUXT_JWT_SECRET=tryUJ0zQbstPbTOrezme+Fv+KndzDNRx5lmSeelr2ial2/2yV8HqLeQ2felJafqf
Comment thread
toddeTV marked this conversation as resolved.

# API URL for client requests (defaults to current host)
# NUXT_PUBLIC_API_URL=http://localhost:3000
# --- Public runtime config (available on client and server)

# Server Configuration
# NUXT_PUBLIC_HOST=0.0.0.0
# NUXT_PUBLIC_PORT=3000
# Base API URL used by the client.
# Default from nuxt.config.ts: empty string, which keeps same-origin requests.
NUXT_PUBLIC_API_URL=

# Show debug (defaults to false / false)
# NUXT_PUBLIC_DEBUG_SHOW_WEBSOCKET_CONNECTIONS_IN_FRONTEND=false
# NUXT_PUBLIC_DEBUG_SHOW_CONSOLE_OUTPUTS=false
# Toggle console debug output in shared logging helpers.
# Default from nuxt.config.ts: false
NUXT_PUBLIC_DEBUG_SHOW_CONSOLE_OUTPUTS=false

# Emoji-Sent Cooldown in Milliseconds (defaults to 1500)
NUXT_PUBLIC_EMOJI_COOLDOWN_MS=
# Show websocket connection debug info in the frontend debug panel.
# Default from nuxt.config.ts: false
NUXT_PUBLIC_DEBUG_SHOW_WEBSOCKET_CONNECTIONS_IN_FRONTEND=false

# Cooldown in milliseconds between emoji submissions.
# Default from nuxt.config.ts: 1500
NUXT_PUBLIC_EMOJI_COOLDOWN_MS=1500

# Public host hint used by the app when building URLs.
# Default from nuxt.config.ts: 0.0.0.0
NUXT_PUBLIC_HOST=0.0.0.0

# Public port hint used by the app when building URLs.
# Default from nuxt.config.ts: 3000
NUXT_PUBLIC_PORT=3000

# Public version string exposed to the client.
# Default from nuxt.config.ts: current package version.
NUXT_PUBLIC_VERSION=1.0.0-rc.0

# Base websocket URL used by the client.
# Default from nuxt.config.ts: empty string, which keeps same-origin websocket resolution.
NUXT_PUBLIC_WS_URL=
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Perfect for:
- **`/admin/results`** - Live results display
- **`/admin/leaderboard`** - Player leaderboard
- **`/admin/emojis`** - Floating emoji overlay
- **`/admin/database`** - Embedded Drizzle Studio for database inspection

## Documentation

Expand Down
94 changes: 94 additions & 0 deletions app/pages/admin/database.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
definePageMeta({
layout: 'default',
middleware: 'auth',
footer: true,
background: true,
localeSwitcher: true,
})

const { t } = useI18n()
const studioUrl = ref('')

onMounted(() => {
const currentUrl = new URL(window.location.href)
const resolvedPort = currentUrl.port || (currentUrl.protocol === 'https:' ? '443' : '80')
const query = new URLSearchParams({
host: currentUrl.hostname,
port: resolvedPort,
})

studioUrl.value = `/api/admin/drizzle-studio/app/?${query.toString()}`
})
</script>

<template>
<div class="mx-auto max-w-6xl p-5">
<UiPageTitle>{{ t('title') }}</UiPageTitle>

<p class="mb-5 text-center text-sm text-gray-600">
{{ t('description') }}
</p>

<div class="mb-5 flex flex-wrap justify-center gap-3">
<NuxtLink
class="block border-[3px] border-black bg-white px-4 py-3 text-sm uppercase transition-all duration-200
hover:translate-x-1 hover:shadow-[-5px_5px_0_#000]"
to="/admin"
>
{{ t('back') }}
</NuxtLink>

<a
v-if="studioUrl"
class="block border-[3px] border-black bg-black px-4 py-3 text-sm uppercase text-white
transition-all duration-200 hover:translate-x-1 hover:shadow-[-5px_5px_0_#000]"
:href="studioUrl"
rel="noopener noreferrer"
target="_blank"
>
{{ t('openNewTab') }}
</a>
</div>

<div class="overflow-hidden border-[3px] border-black bg-white">
<iframe
v-if="studioUrl"
class="h-[78vh] w-full bg-white"
:src="studioUrl"
:title="t('iframeTitle')"
/>

<div
v-else
class="flex h-[78vh] items-center justify-center text-sm uppercase tracking-wide text-gray-500"
>
{{ t('loading') }}
</div>
</div>
</div>
</template>

<i18n lang="yaml">
en:
title: Database
description: Browse and edit the SQLite database through a server-side Drizzle Studio proxy.
back: Back to Admin
openNewTab: Open in New Tab
iframeTitle: Drizzle Studio
loading: Loading Studio
de:
title: Datenbank
description: SQLite-Datenbank über einen serverseitigen Drizzle-Studio-Proxy durchsuchen und bearbeiten.
back: Zurück zur Admin
openNewTab: In neuem Tab öffnen
iframeTitle: Drizzle Studio
loading: Studio wird geladen
ja:
title: データベース
description: サーバー側の Drizzle Studio プロキシ経由で SQLite データベースを参照、編集します。
back: 管理画面に戻る
openNewTab: 新しいタブで開く
iframeTitle: Drizzle Studio
loading: Studio を読み込み中
</i18n>
7 changes: 7 additions & 0 deletions app/pages/admin/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const subPages = computed(() => [
{ path: '/admin/results', label: t('results'), description: t('resultsDesc') },
{ path: '/admin/leaderboard', label: t('leaderboard'), description: t('leaderboardDesc') },
{ path: '/admin/emojis', label: t('emojis'), description: t('emojisDesc') },
{ path: '/admin/database', label: t('database'), description: t('databaseDesc') },
])

const logoutError = ref(false)
Expand Down Expand Up @@ -74,6 +75,8 @@ en:
leaderboardDesc: Aggregated player scores across all published questions.
emojis: Emoji Overlay
emojisDesc: Floating emoji reactions overlay for presentations.
database: Database
databaseDesc: Open Drizzle Studio in a protected admin frame.
logout: Logout
logoutError: Logout failed. Please try again.
de:
Expand All @@ -86,6 +89,8 @@ de:
leaderboardDesc: Gesammelte Spielerpunktzahlen aller veröffentlichten Fragen.
emojis: Emoji-Overlay
emojisDesc: Schwebende Emoji-Reaktionen als Overlay für Präsentationen.
database: Datenbank
databaseDesc: Drizzle Studio in einem geschützten Admin-Frame öffnen.
logout: Abmelden
logoutError: Abmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.
ja:
Expand All @@ -98,6 +103,8 @@ ja:
leaderboardDesc: 公開された全質問の累計プレイヤースコア。
emojis: 絵文字オーバーレイ
emojisDesc: プレゼンテーション用の浮遊する絵文字リアクションオーバーレイ。
database: データベース
databaseDesc: 保護された管理フレームで Drizzle Studio を開きます。
logout: ログアウト
logoutError: ログアウトに失敗しました。もう一度お試しください。
</i18n>
14 changes: 14 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ Verify authentication token (admin only).
}
```

## Database Admin

### GET `/api/admin/drizzle-studio/app`

Load the authenticated Drizzle Studio shell used by `/admin/database`.

### GET `/api/admin/drizzle-studio/app/<asset>`

Load Drizzle Studio static assets through the authenticated proxy.

### POST `/`

Internal admin-only Drizzle Studio RPC compatibility endpoint used by the embedded frame. Treat this as internal transport, not a public integration API.

## Questions

### GET `/api/questions`
Expand Down
13 changes: 13 additions & 0 deletions docs/deployment-docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ NUXT_ADMIN_PASSWORD=<your-strong-password>
NUXT_JWT_SECRET=<paste-output-from-openssl>
```

Optional override for the embedded Drizzle Studio worker port:

```bash
# In .env:
NUXT_DRIZZLE_STUDIO_INTERNAL_PORT=64983
```

Keep this value private. The app binds the Studio worker to `127.0.0.1` inside the container and proxies it through the authenticated admin UI.

### 3. Configure Docker Compose

Open the `docker-compose.yml` file and update the Traefik `Host` rule to match your domain:
Expand All @@ -74,6 +83,8 @@ docker compose up --build -d

The application will be accessible at your configured domain. Traefik will automatically handle SSL certificate provisioning via Let's Encrypt.

After login, the admin menu includes `/admin/database`, which opens Drizzle Studio inside the app through the protected proxy.

## Data Persistence

The `docker-compose.yml` mounts one directory:
Expand All @@ -89,6 +100,7 @@ The Docker image uses a multi-stage build:
- The `build` stage installs the full toolchain and runs `nuxt build`.
- The `production` stage copies only the generated `.output` directory.
- The container starts `node .output/server/index.mjs` directly.
- The embedded Drizzle Studio worker is started lazily by Nitro on first access to `/admin/database`.

The final image does not run a second package install step. Nuxt's production build already emits the standalone server output used by the container.

Expand Down Expand Up @@ -150,6 +162,7 @@ The `docker-compose.yml` file mounts `./.data:/app/.data`. All application data
- Always set a strong `NUXT_JWT_SECRET` (at least 48 bytes of randomness).
- Change the default admin password before making the application publicly accessible.
- The `NUXT_JWT_SECRET` is the only secret passed via environment variable in `docker-compose.yml`. Admin credentials can be set in `.env` and are read by the container at startup.
- Keep `NUXT_DRIZZLE_STUDIO_INTERNAL_PORT` unset unless you need to avoid a local port clash inside the container runtime.
- Traefik handles SSL termination. Internal traffic between Traefik and the container is unencrypted (port 3000) but stays within the Docker network.

## Using a Pre-Built Image
Expand Down
1 change: 1 addition & 0 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Get the quiz application running in minutes.
- **Admin Dashboard**: http://localhost:3000/admin
- **Live Results**: http://localhost:3000/admin/results
- **Leaderboard**: http://localhost:3000/admin/leaderboard
- **Database Admin**: http://localhost:3000/admin/database

## Default Credentials

Expand Down
2 changes: 2 additions & 0 deletions docs/setup-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Set production values for:
- `NUXT_ADMIN_USERNAME`
- `NUXT_ADMIN_PASSWORD`
- `NUXT_JWT_SECRET`
- `NUXT_DRIZZLE_STUDIO_INTERNAL_PORT` when you need a non-default private Studio worker port

## Data Persistence

Expand All @@ -68,6 +69,7 @@ Set production values for:
- Check API availability with `/api/questions`.
- Read container or process logs from stdout.
- Watch WebSocket connection counts in the admin connection endpoint when needed.
- Verify `/admin/database` after login if you need database inspection in production.

## Backup Strategy

Expand Down
5 changes: 3 additions & 2 deletions docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Configuration in `nuxt.config.ts`:

## Initialization

- `initStorage()` in `server/utils/storage.ts` applies pending Drizzle migrations on first storage access.
- `server/plugins/migrations.ts` applies pending Drizzle migrations when the Nitro server starts.
- `initStorage()` in `server/utils/storage.ts` initializes the shared SQLite client after startup.
- No bundled seed data is included in the repository.
- Emoji cooldown state stays in server memory and is not part of persisted storage.

Expand All @@ -40,7 +41,7 @@ Reset all stored quiz data:
rm -rf .data/
```

The next runtime access recreates the SQLite database file.
The next server start recreates the SQLite database file.

## Production Mounts

Expand Down
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const configBase: InputConfig<NuxtConfig, ConfigLayerMeta> = {
// Private keys (only available server-side)
adminPassword: '123',
adminUsername: 'admin',
drizzleStudioInternalPort: '64983',
jwtSecret: 'tryUJ0zQbstPbTOrezme+Fv+KndzDNRx5lmSeelr2ial2/2yV8HqLeQ2felJafqf',

// Public keys (available on both client and server)
Expand Down
72 changes: 72 additions & 0 deletions server/api/admin/drizzle-studio/app/[...asset].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { verifyAdmin } from '../../../../utils/auth'

const DRIZZLE_STUDIO_APP_ORIGIN = 'https://local.drizzle.studio'
const DRIZZLE_STUDIO_ASSET_FETCH_TIMEOUT_MS = 8000
const FORWARDED_HEADERS = [
'cache-control',
'content-type',
'etag',
'last-modified',
]

export default defineEventHandler(async (event) => {
await verifyAdmin(event)

const assetPath = event.context.params?.asset

if (!assetPath) {
throw createError({
statusCode: 404,
statusMessage: 'Drizzle Studio asset not found',
})
}

const pathSegments = assetPath.split('/')

if (pathSegments.some(segment => segment === '.' || segment === '..')) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid Drizzle Studio asset path',
})
}

const requestUrl = getRequestURL(event)
const upstreamUrl = `${DRIZZLE_STUDIO_APP_ORIGIN}/${assetPath}${requestUrl.search}`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let response: Response

try {
response = await fetch(upstreamUrl, {
signal: AbortSignal.timeout(DRIZZLE_STUDIO_ASSET_FETCH_TIMEOUT_MS),
})
}
catch (error) {
const isAbort = error instanceof Error && error.name === 'AbortError'

throw createError({
statusCode: isAbort ? 504 : 502,
statusMessage: isAbort
? `Timed out loading Drizzle Studio asset: ${assetPath}`
: 'Failed to reach Drizzle Studio asset upstream',
data: error instanceof Error ? { message: error.message } : undefined,
})
}

if (!response.ok) {
throw createError({
statusCode: response.status,
statusMessage: `Failed to load Drizzle Studio asset: ${assetPath}`,
})
}

setResponseStatus(event, response.status, response.statusText)

for (const header of FORWARDED_HEADERS) {
const value = response.headers.get(header)

if (value) {
setHeader(event, header, value)
}
}

return new Uint8Array(await response.arrayBuffer())
})
Loading