dev #5

Merged
admin merged 3 commits from dev into main 2026-04-16 13:23:54 +02:00
11 changed files with 280 additions and 12 deletions

View File

@@ -3,7 +3,7 @@ name: Code Analysis and Production Deploy
on: on:
pull_request: pull_request:
push: push:
branches: [ main ] branches: [ main, dev ]
jobs: jobs:
analyze: analyze:
@@ -31,6 +31,10 @@ jobs:
node -v node -v
npm -v npm -v
- name: Require package version change for main PRs
if: github.event_name == 'pull_request' && github.base_ref == 'main'
run: scripts/check-package-version-changed.sh origin/main
- name: gitleaks (Secrets Scanning) - name: gitleaks (Secrets Scanning)
run: | run: |
# Try to get the latest release asset URL # Try to get the latest release asset URL
@@ -118,4 +122,35 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && ./deploy-production.sh'" "bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test:
runs-on: ubuntu-latest
needs: analyze
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Prepare SSH
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf "%s" "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "${{ vars.PROD_PORT }}" "${{ vars.PROD_HOST }}" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"echo SSH OK"
- name: Run test deployment script
run: |
ssh -i ~/.ssh/id_ed25519 \
-o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

View File

@@ -6,6 +6,13 @@
© {{ currentYear }} Harheimer TC 1954 e.V. © {{ currentYear }} Harheimer TC 1954 e.V.
</p> </p>
<div class="flex items-center space-x-6 text-sm relative"> <div class="flex items-center space-x-6 text-sm relative">
<span
v-if="isLoggedIn && appVersion"
class="text-xs text-gray-600"
title="Version"
>
v{{ appVersion }}
</span>
<NuxtLink <NuxtLink
to="/impressum" to="/impressum"
class="text-gray-400 hover:text-primary-400 transition-colors" class="text-gray-400 hover:text-primary-400 transition-colors"
@@ -89,7 +96,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { User, ChevronUp } from 'lucide-vue-next' import { User, ChevronUp } from 'lucide-vue-next'
@@ -97,11 +104,26 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const isMemberMenuOpen = ref(false) const isMemberMenuOpen = ref(false)
const appVersion = ref('')
// Reactive auth state from store // Reactive auth state from store
const isLoggedIn = computed(() => authStore.isLoggedIn) const isLoggedIn = computed(() => authStore.isLoggedIn)
// const isAdmin = computed(() => authStore.isAdmin) // const isAdmin = computed(() => authStore.isAdmin)
const loadAppVersion = async () => {
if (!isLoggedIn.value) {
appVersion.value = ''
return
}
try {
const response = await $fetch('/api/app/version')
appVersion.value = response.version || ''
} catch (_error) {
appVersion.value = ''
}
}
const toggleMemberMenu = () => { const toggleMemberMenu = () => {
isMemberMenuOpen.value = !isMemberMenuOpen.value isMemberMenuOpen.value = !isMemberMenuOpen.value
} }
@@ -116,6 +138,10 @@ onMounted(() => {
authStore.checkAuth() authStore.checkAuth()
}) })
watch(isLoggedIn, () => {
loadAppVersion()
}, { immediate: true })
// Close menu when clicking outside // Close menu when clicking outside
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (!event.target.closest('.relative')) { if (!event.target.closest('.relative')) {

View File

@@ -208,7 +208,9 @@ git clean -fd \
# Pull latest changes # Pull latest changes
echo " Pulling latest changes..." echo " Pulling latest changes..."
if ! git pull --ff-only; then git fetch origin main
git checkout -B main origin/main
if ! git reset --hard origin/main; then
echo "ERROR: git pull fehlgeschlagen." echo "ERROR: git pull fehlgeschlagen."
echo "" echo ""
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt." echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."

View File

@@ -67,6 +67,77 @@ has_tracked_files_under() {
git ls-files "$prefix" | head -n 1 | grep -q . git ls-files "$prefix" | head -n 1 | grep -q .
} }
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
fi
}
use_project_node() {
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh"
if [ -f ".nvmrc" ]; then
echo " Using Node version from .nvmrc..."
nvm use
fi
fi
}
ensure_node_version() {
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js ist nicht im PATH."
exit 1
fi
local node_version
node_version="$(node -p 'process.versions.node')"
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
echo " Node.js $node_version"
}
sync_public_documents_to_build() {
if [ ! -d "public/documents" ]; then
echo " No public/documents directory to sync"
return 0
fi
if [ ! -d ".output/public" ]; then
echo "ERROR: .output/public fehlt, kann public/documents nicht synchronisieren."
exit 1
fi
mkdir -p ".output/public/documents"
cp -a "public/documents/." ".output/public/documents/"
echo " ✓ public/documents -> .output/public/documents synchronisiert"
local template_pdf="beitrittserklärung_template.pdf"
if [ -f "public/documents/$template_pdf" ]; then
local source_size output_size
source_size=$(stat -f%z "public/documents/$template_pdf" 2>/dev/null || stat -c%s "public/documents/$template_pdf" 2>/dev/null || echo "0")
output_size=$(stat -f%z ".output/public/documents/$template_pdf" 2>/dev/null || stat -c%s ".output/public/documents/$template_pdf" 2>/dev/null || echo "0")
if [ "$source_size" != "$output_size" ] || [ "$source_size" = "0" ]; then
echo "ERROR: .output/public/documents/$template_pdf stimmt nicht mit public/documents überein (Source: $source_size, Output: $output_size)."
exit 1
fi
echo "$template_pdf im Build verifiziert ($output_size bytes)"
fi
}
echo "0. Ensuring persistent data directories (recommended)..." echo "0. Ensuring persistent data directories (recommended)..."
# IMPORTANT: Only symlink server/data if it's not tracked by git. # IMPORTANT: Only symlink server/data if it's not tracked by git.
if has_tracked_files_under "server/data"; then if has_tracked_files_under "server/data"; then
@@ -143,7 +214,9 @@ git clean -fd \
# Pull latest changes # Pull latest changes
echo " Pulling latest changes..." echo " Pulling latest changes..."
if ! git pull --ff-only; then git fetch origin dev
git checkout -B dev origin/dev
if ! git reset --hard origin/dev; then
echo "ERROR: git pull fehlgeschlagen." echo "ERROR: git pull fehlgeschlagen."
echo "" echo ""
echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt." echo "Häufige Ursache: SSH-Key für den aktuellen User fehlt."
@@ -170,7 +243,9 @@ fi
# 3. Install dependencies # 3. Install dependencies
echo "" echo ""
echo "3. Installing dependencies..." echo "3. Installing dependencies..."
npm install use_project_node
ensure_node_version
install_dependencies
# 4. Remove old build (but keep data!) # 4. Remove old build (but keep data!)
echo "" echo ""
@@ -204,7 +279,7 @@ fi
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ]; then
echo "" echo ""
echo "WARNING: node_modules fehlt. Installiere Dependencies..." echo "WARNING: node_modules fehlt. Installiere Dependencies..."
npm install install_dependencies
fi fi
# 5. Build # 5. Build
@@ -217,7 +292,7 @@ echo " (This may take a few minutes...)"
echo " Checking dependencies..." echo " Checking dependencies..."
if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then if [ ! -f "node_modules/.package-lock.json" ] && [ ! -f "package-lock.json" ]; then
echo " WARNING: package-lock.json fehlt. Führe npm install aus..." echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install install_dependencies
fi fi
# Build mit expliziter Fehlerbehandlung und Output-Capture # Build mit expliziter Fehlerbehandlung und Output-Capture
@@ -234,6 +309,10 @@ if [ "$BUILD_EXIT_CODE" -ne 0 ]; then
exit 1 exit 1
fi fi
echo ""
echo " Synchronizing public documents into build output..."
sync_public_documents_to_build
# Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen # Prüfe auf Warnungen im Build-Output, die auf Probleme hinweisen
if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then if echo "$BUILD_OUTPUT" | grep -qi "error\|failed\|missing"; then
echo "" echo ""

View File

@@ -10,7 +10,8 @@ try {
// Helper function to create env object // Helper function to create env object
function createEnv(port) { function createEnv(port) {
return { return {
NODE_ENV: 'production', NODE_ENV: process.env.NODE_ENV || 'development',
APP_ENV: process.env.APP_ENV || 'test',
PORT: port, PORT: port,
// Secrets/Config (loaded from .env above, if present) // Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_REF="${1:-origin/main}"
BASE_BRANCH="${BASE_REF#origin/}"
git fetch --no-tags --depth=1 origin "$BASE_BRANCH"
current_version="$(node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); process.stdout.write(String(pkg.version || ""));')"
base_version="$(git show "$BASE_REF:package.json" | node -e 'let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const pkg = JSON.parse(input); process.stdout.write(String(pkg.version || "")); });')"
if [ -z "$current_version" ]; then
echo "ERROR: package.json enthält kein version-Feld."
exit 1
fi
if [ "$current_version" = "$base_version" ]; then
echo "ERROR: package.json version wurde nicht geändert."
echo "Base ($BASE_REF): $base_version"
echo "Current: $current_version"
echo "Bitte version in package.json erhöhen, bevor nach main gemerged wird."
exit 1
fi
echo "package.json version changed: $base_version -> $current_version"

View File

@@ -0,0 +1,25 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getUserFromToken } from '../../utils/auth.js'
async function readPackageVersion() {
const packageJsonPath = path.join(process.cwd(), 'package.json')
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'))
return String(packageJson.version || '')
}
export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token')
const user = token ? await getUserFromToken(token) : null
if (!user) {
throw createError({
statusCode: 401,
statusMessage: 'Nicht authentifiziert'
})
}
return {
version: await readPackageVersion()
}
})

View File

@@ -24,6 +24,12 @@ async function loadConfig() {
} }
async function collectRecipients(config) { async function collectRecipients(config) {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
if (!isProduction) {
return ['tsschulz@tsschulz.de']
}
const recipients = [] const recipients = []
// Vorstand // Vorstand

View File

@@ -41,7 +41,7 @@ async function loadConfig() {
* @returns {Array<string>} Email addresses * @returns {Array<string>} Email addresses
*/ */
function getEmailRecipients(data, config) { function getEmailRecipients(data, config) {
const isProduction = process.env.NODE_ENV === 'production' const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
if (!isProduction) { if (!isProduction) {
return ['tsschulz@tsschulz.de'] return ['tsschulz@tsschulz.de']

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup' import { createEvent, mockSuccessReadBody } from './setup'
import { readFileSync } from 'fs'
vi.mock('../server/utils/auth.js', () => { vi.mock('../server/utils/auth.js', () => {
return { return {
@@ -60,8 +61,14 @@ import logoutHandler from '../server/api/auth/logout.post.js'
import registerHandler from '../server/api/auth/register.post.js' import registerHandler from '../server/api/auth/register.post.js'
import resetPasswordHandler from '../server/api/auth/reset-password.post.js' import resetPasswordHandler from '../server/api/auth/reset-password.post.js'
import statusHandler from '../server/api/auth/status.get.js' import statusHandler from '../server/api/auth/status.get.js'
import versionHandler from '../server/api/app/version.get.js'
describe('Auth API Endpoints', () => { describe('Auth API Endpoints', () => {
afterEach(() => {
delete process.env.NODE_ENV
delete process.env.APP_ENV
})
beforeEach(() => { beforeEach(() => {
// Setze SMTP-Credentials für Tests // Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com' process.env.SMTP_USER = 'test@example.com'
@@ -171,6 +178,30 @@ describe('Auth API Endpoints', () => {
}) })
expect(nodemailer.default.createTransport).toHaveBeenCalled() expect(nodemailer.default.createTransport).toHaveBeenCalled()
}) })
it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
const event = createEvent()
mockSuccessReadBody({
name: 'Max',
email: 'max@example.com',
password: '12345678',
phone: '123',
geburtsdatum: '2000-01-01'
})
authUtils.readUsers.mockResolvedValue([])
authUtils.hashPassword.mockResolvedValue('hashed')
authUtils.writeUsers.mockResolvedValue(true)
await registerHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail).toHaveBeenNthCalledWith(1, expect.objectContaining({
to: 'tsschulz@tsschulz.de'
}))
})
}) })
describe('POST /api/auth/reset-password', () => { describe('POST /api/auth/reset-password', () => {
@@ -212,4 +243,22 @@ describe('Auth API Endpoints', () => {
expect(response.user).toMatchObject({ id: '1' }) expect(response.user).toMatchObject({ id: '1' })
}) })
}) })
describe('GET /api/app/version', () => {
it('verlangt Login', async () => {
const event = createEvent()
await expect(versionHandler(event)).rejects.toMatchObject({ statusCode: 401 })
})
it('liefert eingeloggten Benutzern die package.json-Version', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.getUserFromToken.mockResolvedValue({ id: '1', email: 'user@example.com', roles: ['mitglied'] })
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const response = await versionHandler(event)
expect(response.version).toBe(packageJson.version)
})
})
}) })

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup' import { createEvent, mockSuccessReadBody } from './setup'
import fsPromises from 'fs/promises' import fsPromises from 'fs/promises'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
@@ -26,6 +26,11 @@ import termineHandler from '../server/api/termine.get.js'
import spielplaeneHandler from '../server/api/spielplaene.get.js' import spielplaeneHandler from '../server/api/spielplaene.get.js'
describe('Öffentliche API-Endpunkte', () => { describe('Öffentliche API-Endpunkte', () => {
afterEach(() => {
delete process.env.NODE_ENV
delete process.env.APP_ENV
})
beforeEach(() => { beforeEach(() => {
// Setze SMTP-Credentials für Tests // Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com' process.env.SMTP_USER = 'test@example.com'
@@ -58,6 +63,21 @@ describe('Öffentliche API-Endpunkte', () => {
expect(response.success).toBe(true) expect(response.success).toBe(true)
expect(nodemailer.default.createTransport).toHaveBeenCalled() expect(nodemailer.default.createTransport).toHaveBeenCalled()
}) })
it('sendet in Testumgebung nicht an Vorstand-Empfänger', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
await contactHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
to: 'tsschulz@tsschulz.de'
}))
})
}) })
describe('GET /api/galerie', () => { describe('GET /api/galerie', () => {