This is the headless storefront behind msk-scripts.de. It sells FiveM resources through the Tebex Headless API and doubles as the management platform for our Discord Ticket Bot and Giveaway Bot. It also runs the MSK image gallery and its CDN. Built on Next.js 16, React 19, TypeScript, Tailwind CSS and MariaDB.
Live: msk-scripts.de
| Framework | Next.js 16.3 (App Router, Turbopack build) |
| Language | TypeScript 6.0 (strict mode) |
| UI | React 19.3 |
| Styling | Tailwind CSS 4 (CSS-first, @theme tokens in app/globals.css) |
| Theming | Light + Dark via next-themes |
| Fonts | Inter + JetBrains Mono, self-hosted through @fontsource-variable |
| State | Zustand 5 (persisted to localStorage) |
| Data fetching | SWR 2 |
| Database | MariaDB / MySQL (via mysql2), versioned migrations in database/migrations/ |
| Payments | Tebex Headless API (shop) and Stripe (Ticket Bot subscriptions) |
| nodemailer over SMTP (order confirmations, trial reminders, legal receipts) | |
| Icons | lucide-react (brand marks are local inline SVG, see components/icons/) |
| Image processing | sharp, re-encodes uploaded transcript attachments and gallery submissions |
| Cookies (client) | js-cookie |
| Auth | CFX.re (FiveM) and Discord OAuth via Tebex for purchases, separate Discord OAuth apps for the dashboards and /admin |
| Tests | Vitest 5 with @vitest/coverage-v8 (725 tests under tests/) |
| Linting | ESLint 10 flat config with eslint-config-next 16, run through the ESLint CLI |
| Runtime | Node.js 24 (local, CI and production) |
| Server | Debian, Apache2 reverse proxy, systemd |
| Bot process manager | PM2 (pm2-musiker15.service) |
| CI/CD | GitHub Actions: CI gate plus a server-side git deploy on push to main |
The shop side:
- Full shopping cart with persistent state that survives a page reload
- FiveM (CFX.re) login through Tebex, plus Discord OAuth so roles get assigned after a purchase
- Gift packages with an optional recipient Discord ID
- Coupon codes (apply and remove)
- Catalog filters, per-package badges, tags and custom descriptions
- A "custom packages" section for things that don't live on Tebex (Discord bots, GitHub repos and so on)
- Measured shop figures on the homepage (hidden rather than shown stale when the nightly snapshot is too old) and a release feed read from the public
MSK-Scripts/VERSIONSrepository - Live resource statistics page (
/resources) powered by fivestats.io: server counts, ranking and a 7-day trend chart for every MSK resource - English at the root and German under
/de/, with canonical and hreflang tags on every public page and a generated sitemap - Legal pages in English and German, written in plain Markdown so they can be edited without touching code
- The legally required forms that work without an account: contract withdrawal (
/vertrag-widerrufen), contract cancellation (/vertrag-kuendigen) and a DSA content report (/report), each with an email receipt - Configurable news popup, with an optional copyable coupon code
The Ticket Bot platform:
- Public statistics page (
/ticketbot/stats), with an allowlist viaSTATS_IGNORED_API_KEYS - Verify flow over Discord OAuth: API key issuance and account-scoped ownership
- Ownership follows Discord: a guild whose owner lost Administrator or Manage Server gets a 14-day grace period, then disappears from their dashboard and can be claimed by the new admin team
- Transcript hosting with attachment support, backed by MariaDB. Uploads are hardened with an extension allowlist,
<uuid>.<ext>filenames andsharpimage re-encoding - Custom domain per guild for transcripts, with DNS validation and Let's Encrypt SSL
- Stripe subscriptions: in-app checkout, a 14-day free trial for new customers, the Stripe customer portal for self-service cancellation, and webhook-driven tier assignment
- Account dashboard that manages every server behind a single login (guild switcher), including subscriptions, API keys, domains and transcripts
- Self-service bot hosting for paid tiers:
- The customer enters three values (token, client ID, client secret), the rest is provisioned in the background
- Every hosted bot gets its own dashboard address (
tickets-<hex>.msk-scripts.de), optionally on the customer's own domain - Start / stop / restart / update (git pull) through PM2
- Live log console streaming the PM2 error log over Server-Sent Events (
tail -F) - An authenticated reverse proxy to the bot's own dashboard as a fallback for the owner
The Giveaway Bot platform:
- Free, invite-based Discord giveaways: button entry (no privileged intents), restart-safe scheduling, weighted bonus entries, eligibility rules (roles, account age, membership), pause and resume, templates, and winner reroll
- Web dashboard to create and manage giveaways and per-server settings straight from the browser (Discord login, open to server managers and members holding the configured giveaway manager role)
- A shareable public results page per finished giveaway, plus a live, anonymous stats page (
/giveaway/stats, EN/DE) - Multilingual bot (EN / DE / FR / ES) with per-guild branding
The image gallery:
- Public gallery (
/images) of GTA V vehicles, peds, weapons and items plus MSK brand assets, with full-text search - Images served from
cdn.msk-scripts.dein three sizes (PNG original, 400 px and 160 px WebP), with CORS open so FiveM NUIs can load them - Community submissions (
/images/upload) through Discord login: files are re-encoded bysharpon arrival, kept in a quarantine outside any web root, and published only after moderation
The admin dashboard (/admin):
- Discord login with its own permission system (the owner plus a team with per-permission grants, every write recorded in an audit log)
- Tebex store management through the Plugin API: payments (including free grants and refunds), player lookup, coupons, gift cards, bans and package edits
- Ticket Bot API keys and tier overrides
- Image gallery management: edit, move and delete images, moderate submissions, run a file/database sync check
And on the infrastructure side:
- Security headers, rate limiting, path traversal protection and signed session cookies
- Nonce-based CSP through the Next.js proxy (
'strict-dynamic', nounsafe-inlineorunsafe-evalinscript-srcorstyle-src,default-src 'none') - Apache2 reverse proxy with HSTS (2 years plus preload) and all security headers set in one place
- Apache fallback page for 502/503 errors (
under-construction.html, server-only) - Server-side git deploy via GitHub Actions on push to
main: CI-gated, applies database migrations, health-checked and rollback-capable (see docs/DEPLOYMENT.md)
The tree lists directories and the files worth knowing about, not every file.
app/ Next.js App Router pages & API routes
├── api/
│ ├── admin/ Admin dashboard backend (auth, payments, lookup, coupons,
│ │ giftcards, bans, packages, catalog, api-keys, images,
│ │ image-uploads, team, audit)
│ ├── auth/discord-verify/ Discord OAuth for the verify flow (+ callback/)
│ ├── basket/ Tebex basket API proxy (private key stays server-side)
│ ├── bot-control/ Start / stop / restart / update a hosted bot via PM2
│ ├── bot-dashboard/open/ Handoff token for the proxied bot dashboard
│ ├── bot-hosting/ Self-service hosting: provision, status, env, domain,
│ │ dashboard-host, deactivate
│ ├── bot-logs/ Last 100 lines of the PM2 error log (one-shot)
│ ├── bot-logs-stream/ Server-Sent Events, real-time PM2 log stream
│ ├── dashboard/logout/ Clear the dashboard session cookie
│ ├── debug/ Debug route (returns 404 in production)
│ ├── discord/ Discord online member count (+ health/)
│ ├── domain/ Transcript custom domain set / remove / validate
│ ├── giveaway/ Giveaway dashboard: auth, data, action, verify, logout
│ ├── giveaway-result/ Public giveaway result pages: publish, delete
│ ├── giveaway-stats/ Live giveaway stats (read-only giveaway bot DB)
│ ├── images/ Public gallery API (list, categories, single image) + upload/
│ ├── legal/ Withdrawal, cancellation and DSA report forms
│ ├── packages/ Package list endpoint
│ ├── resource-stats/ Live fivestats.io resource stats (key stays server-side)
│ ├── stats/ Public Ticket Bot statistics
│ ├── stripe/ checkout/ (subscription + trial) and portal/ (manage / cancel)
│ ├── transcript/ upload/ and url/ (authenticated via API key)
│ ├── transcripts/ Dashboard transcript list for the owner's guild
│ ├── verify/ Verify status / complete / check-guild / redirect-dashboard
│ └── webhook/stripe/ Stripe webhook handler (signature-verified)
├── account/ User account page
├── admin/ Admin dashboard (one *Tab.tsx per area)
├── auth/discord/ Discord OAuth callback (purchase flow)
├── botproxy/ Reverse proxy route for the hosted bot dashboard
├── cart/ Cart page
├── categories/[id]/ Category pages
├── checkout/ Post-payment redirect handler
├── giveaway/ Giveaway Bot landing page + verify/, dashboard/, stats/, g/[token]/
├── images/ Gallery: overview, [category]/, [category]/[name]/, upload/
├── llms.txt/ /llms.txt: map of the site for language models, built by lib/llms.ts
├── login/ Login page
├── packages/ Package list + [id]/ detail pages
├── report/ DSA content report form
├── resources/ Resource statistics page (fivestats.io)
├── sitemap.xml/ Sitemap route (+ sitemap.xsl/, sitemap-images.xml/)
├── terms/ Terms, imprint/, privacy/, avv/ (data processing), widerruf/
├── ticketbot/ Ticket Bot landing page + verify/, dashboard/, stats/
├── vertrag-kuendigen/ Contract cancellation form (§ 312k BGB)
└── vertrag-widerrufen/ Contract withdrawal form (§ 356a BGB)
components/
├── bots/ Ticket Bot and Giveaway Bot landing pages, comparison table
├── cart/ Slide-in cart drawer
├── home/ Homepage sections (Hero, ProofLine, Catalog, Bots, HowItWorks, ...)
├── i18n/ LangProvider, LanguageDropdown, LocaleLink (keeps the /de/ prefix)
├── icons/ Local brand marks (GitHub and others)
├── images/ Gallery cards, search, copy-URL button, brand notice
├── layout/ Header, Footer, payment marks
├── legal/ Legal-text renderer
├── packages/ PackageCard, PackagePrice, AddToCartButton, PackageGallery
├── search/ Command-palette search (⌘K)
├── theme/ ThemeProvider + ThemeToggle
├── ui/ Button, Card, Badge, Container, Input, Skeleton, NewsPopup
└── BotConfigEditor.tsx Hosted-bot panel: bot control and live log console. The name
is historic, the config editor moved into the bot itself
content/
├── custom-packages.ts Non-Tebex packages (Discord bots, GitHub, etc.)
├── resource-stats.ts Resource list for the /resources page
├── ticketbot-copy.ts, giveaway-copy.ts, ticketbot-compare-copy.ts Landing page texts (EN + DE)
└── legal/ Editable Markdown legal texts, one file per language
database/
├── schema.sql Full schema for a fresh database
├── migrations/ NNN-name.sql files, applied once each by deploy.sh (see README there)
└── seed.dev.sql Invented data for the local development database
lib/ Server and shared logic, grouped by area:
├── tebex.ts, tebexPlugin.ts, price.ts, useCart.ts Shop and Tebex APIs
├── session.ts, dashboardSession.ts, giveawaySession.ts,
│ adminSession.ts, uploadSession.ts Signed session cookies (HMAC-SHA256)
├── dashboardAuth.ts, guildScope.ts, guildAccess.ts,
│ discordGuilds.ts, discordPermissions.ts Guild ownership and Discord rights
├── adminApi.ts, adminAuth.ts, adminPerms.ts, adminAudit.ts Admin route wrapper and permissions
├── stripe.ts, tiers.ts, mail.ts, emails/ Subscriptions, tier limits, mail
├── botProvision.ts, botEnv.ts, dashboardHost.ts,
│ ionosDns.ts, customDomain.ts, hostedBot.ts, pm2.ts Bot hosting and domains
├── images.ts, adminImages.ts, imageUploads.ts,
│ imagePipeline.ts, imageSyncCheck.ts Image gallery and CDN
├── giveawayControl.ts, giveawayDb.ts, giveawayStats.ts, giveawayManager.ts Giveaway Bot
├── legalForms.ts Withdrawal, cancellation and DSA report handling
├── i18n.ts, lang.ts, serverLang.ts Translations and /de/ routing
├── seo.ts, pageSeo.ts, botSeo.ts, jsonLd.ts, sitemap.ts, llms.ts Metadata, structured data, /llms.txt
├── rateLimit.ts, sanitize.ts, db.ts Rate limiting, HTML sanitizing, database pool
└── config.ts Shop configuration (featured packages, badges, news popup)
store/ Zustand stores (cart, sale prices)
types/ TypeScript types for the Tebex API
tests/ Vitest suite
scripts/ Deployed with the repo to /opt/msk-shop/scripts (kept root:root)
├── deploy.sh Server-side deploy: checkout, migrations, build, restart, health check
├── msk-cron.sh Wrapper for the cron jobs below (logs even when loading the env fails)
├── cleanup.js Expired transcripts, lapsed memberships, archived bots (daily)
├── stripe-reconcile.js Re-syncs subscription state with Stripe (daily)
├── tebex-stats.js Nightly snapshot of the shop figures shown on the homepage
├── vhost-create.sh, vhost-delete.sh Apache vhost + SSL for transcript custom domains
├── bot-vhost-create.sh, bot-vhost-delete.sh Apache vhost for hosted bot dashboards
├── bot-provision.js Installs and starts a hosted bot (spawned, not a cron)
├── image-ingest.js, image-sync-check.js, image-label-import.js,
│ image-pedshot-prepare.js, labels/ Image CDN tooling
└── repair-transcript-images.js One-off repair for expired Discord image links
docs/
├── DEPLOYMENT.md Server setup + deploy runbook (German)
├── TEBEX_API_REFERENCE.md Tebex HTTP API reference
└── ADMIN_DASHBOARD_PLAN.md Original plan for /admin
proxy.ts Next.js proxy (called middleware.ts before Next 16): generates a
per-request nonce and sets every security header
docker-compose.dev.yml Local MariaDB for development
msk-shop.service systemd unit (port 3005, user musiker15)
This file is the single source of truth for all the limits. The tiers are basic, premium, premium_plus and business.
| Limit | basic | premium | premium_plus | business |
|---|---|---|---|---|
| Transcript max. | 10 MB | 50 MB | 100 MB | 200 MB |
| Attachments max. | none | 100 MB | 200 MB | 500 MB |
| Storage retention | 30 days | 180 days | 365 days | 10 years |
| Custom domain | no | yes | yes | yes |
| Bot hosting | no | yes | yes | yes |
| Attachment downloads | no | yes | yes | yes |
| Remove branding | no | yes | yes | yes |
| Uploads / hour | 30 | 60 | 120 | 300 |
getExpiresAt(tier) derives the expiry date from storageDays.
Every paid tier carries every perk, so code that gates on one asks
tier !== 'basic' instead of listing tiers. Enumerating them is how business
lost the dashboard shortcut in the verify flow when it was introduced.
The Ticket Bot, Giveaway, admin, gallery and legal-form features use MariaDB / MySQL. The Tebex storefront itself needs no database; every database-backed view falls back gracefully when it is unreachable.
# Create the database and run the schema
mysql -u root -p -e "CREATE DATABASE your_db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u root -p your_db_name < database/schema.sqlTables created by database/schema.sql:
| Table | Purpose |
|---|---|
schema_migrations |
Which files from database/migrations/ have been applied |
ticketbot_guilds |
Guild registrations: API key, tier, owner, Discord access check, Stripe IDs, custom domain, hosting state |
ticketbot_customers |
One row per person (Discord user to Stripe customer) plus free-trial eligibility |
ticketbot_hosting_jobs |
Progress of a self-service bot installation |
ticketbot_transcripts |
Ticket transcript metadata + expiry |
ticketbot_attachments |
File attachments for paid-tier transcripts |
ticketbot_rate_limits |
Per-API-key request rate limiting (hourly window) |
giveaway_results |
Public giveaway result pages (usernames only, no Discord IDs) |
msk_admin_team, msk_admin_audit |
Admin dashboard members, permissions and audit log |
msk_shop_stats |
Nightly shop figures for the homepage |
msk_image_categories, msk_images |
Image gallery categories and image metadata (files live on the CDN) |
msk_image_uploads |
Moderation queue for community submissions |
msk_withdrawals, msk_cancellations, msk_content_reports |
Submissions of the three legal forms |
A fresh database gets everything from schema.sql. Existing databases are updated through database/migrations/NNN-name.sql, which scripts/deploy.sh applies once each, in order, and records in schema_migrations. Migrations must be additive, because they run while the previous version is still serving. The rules are in database/migrations/README.md.
Most of the shop configuration lives in lib/config.ts:
// Which Tebex packages show up on the homepage
export const FEATURED_PACKAGE_IDS = [5301828, 6446947, 6372865]
// One or more badges per package
// Variants: 'esx' | 'qb' | 'standalone' | 'js' | 'ts' | 'lua' | 'py' | 'discord' | 'fivem'
export const PACKAGE_BADGES: Record<number, Badge[]> = {
5301828: [{ label: 'ESX', variant: 'esx' }, { label: 'Lua', variant: 'lua' }],
6446947: [{ label: 'ESX', variant: 'esx' }, { label: 'QBCore', variant: 'qb' }, { label: 'Lua', variant: 'lua' }],
}
// Short description shown on package cards
export const PACKAGE_DESCRIPTIONS: Record<number, string> = {
5301828: 'Realistic handcuffs with animations, props, drag and more.',
}
// Tags shown on package cards
export const PACKAGE_TAGS: Record<number, string[]> = {
5301828: ['msk_core', 'pma-voice'],
}
// News popup, shown on every full page load
export const NEWS_POPUP = {
enabled: true,
title: 'Discord Ticket Bot',
text: 'Get your API Key now and create a ticket system for your community!',
button: { label: 'Get API Key', href: '/ticketbot/verify' },
secondButton: { label: 'Dashboard', href: '/ticketbot/dashboard' },
coupon: null, // or e.g. 'NEWSHOP20', renders a copyable coupon field
}
// Site metadata
export const SITE_CONFIG = {
name: 'MSK Scripts Shop',
tagline: 'High quality FiveM resources & Discord bots for your server',
discord: 'https://discord.gg/5hHSBRHvJE',
github: 'https://github.com/MSK-Scripts',
docs: 'https://docu.msk-scripts.de',
}A few more things worth knowing:
- Custom packages (the non-Tebex ones) live in
content/custom-packages.ts. - Resource statistics are configured in
content/resource-stats.ts: each entry maps aresource_name(the exact FiveM folder name) to a free resource (linked to GitHub) or a paid one (linked to its two Tebex variants). - Legal pages are plain Markdown in
content/legal/*.md, one file per language. - SEO titles and descriptions for pages, packages and categories live in
lib/pageSeo.tsandlib/config.ts, in both languages.
The deploy is done on the server, not by shipping build artifacts. The repo lives as a full git clone at /opt/msk-shop, and scripts/deploy.sh builds and restarts the app there. The full setup and runbook is in docs/DEPLOYMENT.md.
Push → CI (lint · typecheck · test · audit · build) ── green ──▶ Deploy (workflow_run)
└─ SSH (ForceCommand) ─▶ deploy.sh <sha>
- CI (
.github/workflows/ci.yml, nameCI) runslint,typecheck(tsc --noEmit),test, an audit of the production dependency tree (npm audit --omit=dev --audit-level=high) andbuildon every push and PR tomain. - Deploy (
.github/workflows/deploy.yml) fires viaworkflow_runonly once CI is green onmain. You can also run it manually with an optionalcommit_sha, which is how rollbacks work. - The Action SSHes in and runs
deploy.sh, which does the following on the server:git checkout <sha>→ database migrations →npm ci→npm run build(loading/opt/msk-shop/.env.local) →systemctl restart msk-shop→ a health check (curl :3005, aborts on failure) → deploy tag. The script pulls the latestdeploy.shfrommainbefore every run, so it updates itself.
A note on security: the Action's SSH key is pinned with ForceCommand, so it can only ever run deploy.sh, and known-hosts checking is strict. Because the build happens on the server, the NEXT_PUBLIC_* and TEBEX_PRIVATE_KEY values have to be present in /opt/msk-shop/.env.local (Next.js reads .env.local at build time). Every other server-side secret lives there too.
| Secret | Value |
|---|---|
DEPLOY_SSH_KEY |
Private key of the Action deploy key (ed25519) |
DEPLOY_HOST |
Server IP or hostname |
DEPLOY_HOST_FINGERPRINT |
ssh-keyscan -t ed25519 [-p <port>] <host> output |
DEPLOY_USER (optional, default root) |
SSH user |
DEPLOY_PORT (optional, default 22) |
SSH port |
NEXT_PUBLIC_TEBEX_PUBLIC_TOKEN |
Tebex public token (CI build) |
NEXT_PUBLIC_TEBEX_PROJECT_ID |
Tebex project ID (CI build) |
NEXT_PUBLIC_BASE_URL |
https://www.msk-scripts.de (CI build) |
TEBEX_PRIVATE_KEY |
Tebex private key (CI build) |
There are a few more workflows in the repo: codeql.yml (code scanning), code-coverage.yml (Vitest coverage upload), mirror.yml (mirrors the repo to Codeberg), dependency-review.yml and secret-scan.yml. Dependabot (.github/dependabot.yml) groups minor and patch updates into one weekly PR and keeps vitest with @vitest/* in a group of their own, because they only install together.
- Node.js 24.x
- npm
- MariaDB or MySQL
- Apache2 with
mod_proxy,mod_ssl,mod_rewrite,mod_headers - A Let's Encrypt SSL certificate (Certbot)
- Debian / Ubuntu with systemd
- PM2 (only if you use hosted bot management)
# 1. Clone
cd /opt
git clone https://github.com/MSK-Scripts/msk-shop.git msk-shop
cd msk-shop
# 2. Environment variables
cp .env.example .env.local
nano .env.local
# 3. Database
mysql -u root -p -e "CREATE DATABASE your_db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u root -p your_db_name < database/schema.sql
# 4. Install & build
npm ci
npm run build
# 5. Permissions (the service runs as user "musiker15" on port 3005)
chown -R musiker15:musiker15 /opt/msk-shop
chmod -R u+w /opt/msk-shop/.next
# 6. systemd service
cp msk-shop.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable msk-shop
systemctl start msk-shop
# 7. Apache2
a2enmod proxy proxy_http rewrite ssl headers
# Copy the Apache config, see msk-shop.conf and msk-shop_ssl.conf
systemctl reload apache2The most important .env.local values. .env.example has the complete list with a comment for every variable (giveaway bot, bot hosting, IONOS DNS, admin dashboard, image CDN and more):
# Tebex
NEXT_PUBLIC_TEBEX_PUBLIC_TOKEN=your_public_token
NEXT_PUBLIC_TEBEX_PROJECT_ID=your_project_id
TEBEX_PRIVATE_KEY=your_private_key
NEXT_PUBLIC_BASE_URL=https://www.msk-scripts.de
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=your_db_user
DB_PASSWORD='your_db_password'
DB_NAME=your_db_name
# Session
SESSION_SECRET=<openssl rand -hex 32>
# Discord OAuth (verify flow, scopes: identify, guilds)
DISCORD_VERIFY_CLIENT_ID=your_client_id
DISCORD_VERIFY_CLIENT_SECRET=your_client_secret
# Stripe (Ticket Bot subscriptions)
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRICE_PREMIUM=price_xxx
STRIPE_PRICE_PREMIUM_PLUS=price_xxx
STRIPE_PRICE_BUSINESS=price_xxx
# Mail (skipped with a log line when unset). Quote values containing < or >
SMTP_HOST=mail.example.com
SMTP_PORT=465
SMTP_USER=noreply@msk-scripts.de
SMTP_PASS=your_smtp_password
MAIL_FROM="MSK Scripts <noreply@msk-scripts.de>"
# Transcripts (served by Apache under /transcripts)
TRANSCRIPT_BASE_PATH=/var/www/html/transcripts
# DNS validation & SSL
SERVER_PUBLIC_IP=your.server.ip
ADMIN_EMAIL=info@msk-scripts.de
# Hosted bot management, one subfolder per guild: {BOT_CONFIG_BASE_PATH}/{guild_id}/
BOT_CONFIG_BASE_PATH=/opt/customer_ticketbots
# Public stats, comma-separated API keys to keep out of /ticketbot/stats
STATS_IGNORED_API_KEYS=key1,key2,key3
# Resource statistics (the /resources page)
FIVESTATS_API_KEY=your_fivestats_api_keyNever commit
.env.local. It is listed in.gitignore.
For local development without a server database there is docker-compose.dev.yml, see the last section of docs/DEPLOYMENT.md.
On the production server this is fully automated: a push to main runs CI and then scripts/deploy.sh (see docs/DEPLOYMENT.md). Prefer running that script by hand over the steps below, because it also applies pending database migrations. The steps are the fallback for a server without the migration step.
cd /opt/msk-shop
git pull
npm ci
npm run build
chown -R musiker15:musiker15 /opt/msk-shop
chmod -R u+w /opt/msk-shop/.next
systemctl restart msk-shop
# preferred: /opt/msk-shop/scripts/deploy.sh# Next.js service logs
journalctl -u msk-shop -f
# Deploy log
tail -f /var/log/msk-shop-deploy.log
# Apache error log
tail -f /var/log/apache2/msk-shop-error.log
# Restart
systemctl restart msk-shop
systemctl reload apache2
# Fix permission errors (EACCES on .next/)
chown -R musiker15:musiker15 /opt/msk-shop
chmod -R u+w /opt/msk-shop/.next
systemctl restart msk-shop
# Test the database connection
mysql -u your_db_user -p your_db_name -e "SHOW TABLES;"
# Hosted bot PM2 status
systemctl status pm2-musiker15
sudo -u musiker15 pm2 listThe short version, the full write-up is in SECURITY.md:
- The Tebex private key (
TEBEX_PRIVATE_KEY) never reaches the client. Every mutation goes through a Next.js API route. - Session cookies are signed with
SESSION_SECRET(HMAC-SHA256, constant-time comparison),HttpOnlyandSecure. There is no fallback secret, a missingSESSION_SECRETfails at runtime. - Rate limiting: in-memory per IP (
lib/rateLimit.ts, keyed on the rightmostX-Forwarded-Forentry) on basket, domain, bot control, log and admin routes, plus a database-side per-API-key limit inticketbot_rate_limits. - Markdown file reads use an allowlist, so there is no path traversal.
- Transcript attachment uploads use a strict extension allowlist (no
html,svgorphp), store files as<uuid>.<ext>so an attacker-chosen name can't reach the web root, and re-encode image attachments throughsharp. Gallery submissions are re-encoded on arrival and kept outside any web root until a moderator approves them. - Redirect URLs are always built server-side from
NEXT_PUBLIC_BASE_URL. - All security headers are set in one place, in
proxy.ts(per request), so the Apache vhost never sends duplicates. The file was calledmiddleware.tsuntil Next 16 renamed the convention:- A CSP with a fresh cryptographic nonce per request plus
'strict-dynamic'. No'unsafe-inline'or'unsafe-eval'inscript-src, and no'unsafe-inline'instyle-srceither (Next.js attaches the nonce to its inline<style>tags automatically).style-src-attr 'unsafe-inline'covers Reactstyle={{}}attributes, which Mozilla Observatory does not score. default-src 'none'(deny by default), with every used resource directive listed explicitly.- HSTS
max-age=63072000; includeSubDomains; preload(2 years). X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin, aPermissions-Policylocking down camera, microphone, geolocation, payment and usb, plusCross-Origin-Opener-PolicyandCross-Origin-Resource-Policy: same-originandX-DNS-Prefetch-Control: on.- The Apache vhost has to
Header always unsetany duplicates from/etc/apache2/conf-enabled/security.conf, otherwise the headers arrive twice and Mozilla Observatory refuses to parse them.
- A CSP with a fresh cryptographic nonce per request plus
- The debug route (
/api/debug) returns 404 in production. - The Stripe webhook is verified with the Stripe signature (
constructEvent, raw body) and the handlers are idempotent. No card data is received or stored, only the Stripe customer and subscription IDs. - Dashboard access is account-scoped: every guild-scoped route re-checks
WHERE guild_id = ? AND discord_user_id = ?against the signed session (lib/dashboardAuth.ts) and refuses guilds whose owner lost their Discord rights past the grace period. - Admin routes run through one wrapper (
lib/adminApi.ts): session, a per-route permission loaded live from the database, rate limit, anOrigincheck on every mutation, and an audit log entry for every write. - OAuth flows use a random
statetoken for CSRF protection.
Everything is Tailwind CSS v4 (CSS-first). The design tokens are @theme variables (--color-*) in app/globals.css, and tailwind.config.ts only holds the content paths. The .dark scope (set by next-themes) overrides the tokens. MSK green (--color-primary) is the brand accent, #27762e in light mode and #60b02f in dark mode, both sampled from the logo. The design system is documented in DESIGN.md.
Fonts are Inter (--font-sans) and JetBrains Mono (--font-mono), fully self-hosted through @fontsource-variable (imported in app/layout.tsx, no next/font/google, so nothing gets fetched from Google).
There is a backward-compat layer in globals.css that keeps the old msk-* utility classes (msk-btn-primary, msk-card, msk-input, msk-badge, msk-label and friends) and the legacy token aliases (bg, surface, border, accent, text and so on) mapped onto the new --color-* tokens. That way code that hasn't been migrated yet (mainly BotConfigEditor) still renders correctly in both themes.
A couple of theme-specific styles also live in globals.css:
.tebex-descriptionmakes the Tebex HTML (dangerouslySetInnerHTML) readable..legal-contentrenders the Markdown legal pages (h1 to h3, lists, tables, code).
This is not an open-source project. The source is readable, not free to use.
It runs under the MSK Source Available License (MSK-SAL) v1.1: English · Deutsch. The German version is the binding one (§ 12.4).
What that means in practice:
| ✅ Allowed | Reading the code, running it locally for private study, opening issues, submitting pull requests, security research along SECURITY.md |
| ❌ Not allowed | Copying or reusing the code, derivative works, hosting it publicly, any commercial use, redistribution, recreating the design of the protected components |
| ⚖️ Untouched | The statutory rights under § 69d and § 69e UrhG, which no contract can take away (§ 5 of the license) |
Two things the license does not cover: the npm dependencies, which keep their
own licenses, and the media served from cdn.msk-scripts.de, whose origin is
recorded per image in the database (§ 8).
Names and logos are not licensed either. A copyright license is not a trademark license (§ 7).
Contributing? Read Rights in your contribution first. Opening a pull request grants MSK Scripts an exclusive right of use in it, while your copyright and your right to attribution stay with you.
For commercial licenses, collaborations or white-label requests, write to
info@msk-scripts.de with the subject MSK License Request.