diff --git a/.dockerignore b/.dockerignore
index ad321ff..23c61aa 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,6 +2,7 @@ _site/
.git/
.claude/
node_modules/
+images/
.sass-cache/
.jekyll-metadata
*.md
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index eafa89d..3cca3fd 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -1,15 +1,16 @@
-name: Build & Push Docker Image to GHCR
+name: Build & Push Docker Image to Gitea Registry
on:
push:
branches: [master]
env:
- REGISTRY: ghcr.io
- IMAGE_NAME: kroonk/photography
+ REGISTRY: git.mischlabs.de
+ REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/photography
jobs:
build-and-push:
+ if: ${{ github.server_url == 'https://git.mischlabs.de' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -40,16 +41,20 @@ jobs:
fi
docker --version
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ secrets.GHCR_USERNAME }}
- password: ${{ secrets.GHCR_TOKEN }}
+ - name: Log in to Gitea Package Registry
+ shell: sh
+ run: |
+ if [ -z "${{ secrets.REGISTRY_USER }}" ] || [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
+ echo "Missing registry credentials."
+ echo "Create repository secrets REGISTRY_USER and REGISTRY_TOKEN."
+ echo "REGISTRY_TOKEN must be a Gitea access token with package write permission."
+ exit 1
+ fi
+ echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push Docker image
shell: sh
run: |
- docker build -t ghcr.io/kroonk/photography:latest -t ghcr.io/kroonk/photography:${{ github.sha }} .
- docker push ghcr.io/kroonk/photography:latest
- docker push ghcr.io/kroonk/photography:${{ github.sha }}
+ docker build -t ${{ env.REGISTRY_IMAGE }}:latest -t ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} .
+ docker push ${{ env.REGISTRY_IMAGE }}:latest
+ docker push ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
diff --git a/Dockerfile b/Dockerfile
index ebd756f..aa7aee9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,19 +12,26 @@ COPY . .
RUN bundle exec jekyll build
-# === Serve-Phase: node.js backend + ImageMagick ===
-FROM node:20-alpine
+# === Node-Dependencies: native sqlite3 mit Build-Tools bauen ===
+FROM node:20-alpine AS node-deps
-# ImageMagick + Format-Delegates (JPEG/PNG/WebP), Build-Tools fuer sqlite3 native bindings
-RUN apk add --no-cache imagemagick \
- libjpeg-turbo libjpeg-turbo-dev \
- libpng libpng-dev \
- libwebp libwebp-dev \
- python3 make g++ build-base sqlite-dev
+RUN apk add --no-cache python3 make g++ sqlite-dev
WORKDIR /app
COPY package*.json ./
-RUN npm install --omit=dev
+RUN npm ci --omit=dev && npm cache clean --force
+
+# === Serve-Phase: node.js backend + Runtime-Tools ===
+FROM node:20-alpine
+
+# Getrennte RUNs halten einzelne Registry-Layer klein genug fuer Proxy-Limits.
+RUN apk add --no-cache docker-cli
+RUN apk add --no-cache imagemagick libjpeg-turbo libpng libwebp
+RUN apk add --no-cache sqlite-libs
+
+WORKDIR /app
+COPY package*.json ./
+COPY --from=node-deps /app/node_modules ./node_modules
COPY backend ./backend
COPY --from=builder /site/_site ./public
diff --git a/README.md b/README.md
index 60c676b..92ab26a 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,18 @@ To deploy on a NAS or Linux server, adjust the `docker-compose.yml` to match you
```yaml
services:
photography-website:
- image: ghcr.io/kroonk/photography:latest
+ image: git.mischlabs.de/mrdiderot/photography:latest
container_name: photography-website
ports:
- "8090:8090"
+ environment:
+ - JWT_SECRET=replace-with-a-long-random-secret
+ - INITIAL_ADMIN_USERNAME=MrDiderot
+ - INITIAL_ADMIN_PASSWORD=Start123
+ - FULLS_DIR=/app/public/images/fulls
+ - THUMBS_DIR=/app/public/images/thumbs
+ - SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
+ - SYNC_CONTAINER=photography-website
volumes:
- /path/to/nextcloud/data:/app/public/images/fulls:ro
- thumbs:/app/public/images/thumbs
@@ -32,6 +40,14 @@ services:
Run `docker compose up -d` to launch the site.
+Change the initial admin password after the first login. If you prefer not to store the initial password in `docker-compose.yml`, create a bcrypt hash and use `INITIAL_ADMIN_PASSWORD_HASH` instead:
+
+```bash
+node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-admin-password', 10));"
+```
+
+The admin dashboard includes a sync button that pulls `SYNC_IMAGE` and restarts `SYNC_CONTAINER`. This requires the Docker socket mount shown above.
+
## Credits & License
- Originally based on a Jekyll template.
- UI Design by [AJ / HTML5 UP](https://html5up.net).
diff --git a/admin.html b/admin.html
index c0defed..0a2fb65 100644
--- a/admin.html
+++ b/admin.html
@@ -6,7 +6,10 @@
@@ -34,7 +41,8 @@
-
+
+
Nutzer erstellen
@@ -62,6 +70,13 @@
+
Website aktualisieren
+
Laedt das neueste Docker-Image und startet den Website-Container neu.
+
+
+
+
+
Nutzerverwaltung
| ID | Username | Rolle | Erstellt | Aktionen |
@@ -69,7 +84,7 @@
-
+
Ordner-Zuweisung
Waehle einen Nutzer und klicke auf Ordner um sie zuzuweisen oder zu entfernen.
+
diff --git a/assets/js/admin.js b/assets/js/admin.js
index e1fc24f..a5c80ab 100644
--- a/assets/js/admin.js
+++ b/assets/js/admin.js
@@ -33,32 +33,42 @@ $(document).ready(function() {
users.forEach(function(u) {
var role = u.role || 'unbekannt';
- var badge = '
' + role + '';
+ var badge = $('
').addClass('badge badge-' + role).text(role);
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
var info = created;
if (role === 'client' && u.expires_at) {
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
if (u.expired) {
- badge = '
abgelaufen';
+ badge = $('
').addClass('badge badge-expired').text('abgelaufen');
info = 'Abgelaufen: ' + expDate;
} else {
info = 'Bis: ' + expDate;
}
}
- var actions = '
';
- actions += '
';
+ var row = $('
|
');
+ var userCell = $('
| ').text(u.username + ' ').append(badge);
+ var actions = $('
| ');
+ $('
')
+ .data('user-id', u.id)
+ .data('username', u.username)
+ .appendTo(actions);
+ $('
')
+ .data('user-id', u.id)
+ .data('username', u.username)
+ .appendTo(actions);
- $('#users-tbody').append(
- '
| ' + u.id + ' | ' + u.username + ' ' + badge +
- ' | ' + role + ' | ' + info +
- ' | ' + actions + ' |
'
- );
+ row.append($('
| ').text(u.id));
+ row.append(userCell);
+ row.append($('
| ').text(role));
+ row.append($('
| ').text(info));
+ row.append(actions);
+ $('#users-tbody').append(row);
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (role !== 'admin') {
- $('#user-select').append('
');
+ $('#user-select').append($('
').val(u.id).text(u.username + ' (' + role + ')'));
}
});
}).fail(function(xhr) {
@@ -96,16 +106,20 @@ $(document).ready(function() {
});
// Nutzer loeschen
- window.deleteUser = function(id, name) {
+ function deleteUser(id, name) {
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(loadUsers)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
}
- };
+ }
+
+ $('#users-tbody').on('click', '.user-delete', function() {
+ deleteUser($(this).data('user-id'), $(this).data('username'));
+ });
// Passwort aendern (Admin setzt fuer anderen User)
- window.changePassword = function(id, name) {
+ function changePassword(id, name) {
var newPw = prompt('Neues Passwort fuer "' + name + '":');
if (!newPw) return;
$.ajax({
@@ -118,7 +132,11 @@ $(document).ready(function() {
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
- };
+ }
+
+ $('#users-tbody').on('click', '.user-password', function() {
+ changePassword($(this).data('user-id'), $(this).data('username'));
+ });
// ==================== ORDNER-ZUWEISUNG ====================
@@ -127,7 +145,8 @@ $(document).ready(function() {
function loadFolders() {
$.get('/api/admin/folders').done(function(folders) {
- allFolders = folders;
+ allFolders = folders || [];
+ renderFolderTable();
});
}
@@ -209,6 +228,30 @@ $(document).ready(function() {
});
});
+ // ==================== WEBSITE SYNC ====================
+
+ $('#sync-site').click(function() {
+ if (!confirm('Neueste Version laden und Website neu starten?')) return;
+
+ var $button = $(this);
+ var $status = $('#sync-status');
+ $button.prop('disabled', true);
+ $status.text('Sync laeuft...');
+
+ $.post('/api/admin/sync')
+ .done(function(res) {
+ $status.text(res.message || 'Sync gestartet. Website startet neu...');
+ window.setTimeout(function() {
+ window.location.reload();
+ }, 12000);
+ })
+ .fail(function(err) {
+ var msg = err.responseJSON ? err.responseJSON.error : 'Sync fehlgeschlagen';
+ $status.text(msg);
+ $button.prop('disabled', false);
+ });
+ });
+
// ==================== EIGENES PASSWORT ====================
$('#admin-pw-form').submit(function(e) {
diff --git a/backend/database.js b/backend/database.js
index 597e934..c373f9e 100644
--- a/backend/database.js
+++ b/backend/database.js
@@ -46,13 +46,44 @@ db.serialize(() => {
UNIQUE(user_id, image_name)
)`);
- // Default-Admin erstellen wenn keiner existiert
- db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
- if (!row) {
- const hash = bcrypt.hashSync('admin123', 10);
- db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
- console.log('Default admin created: admin / admin123');
+ // Initialen Admin nur mit expliziten Credentials aus der Umgebung anlegen.
+ db.serialize(() => {
+ const username = process.env.INITIAL_ADMIN_USERNAME;
+ const password = process.env.INITIAL_ADMIN_PASSWORD;
+ const passwordHash = process.env.INITIAL_ADMIN_PASSWORD_HASH || (password ? bcrypt.hashSync(password, 10) : null);
+ if (!username || !passwordHash) {
+ console.warn('Set INITIAL_ADMIN_USERNAME with INITIAL_ADMIN_PASSWORD or INITIAL_ADMIN_PASSWORD_HASH to bootstrap an admin.');
+ return;
}
+
+ db.get('SELECT id FROM users WHERE username = ?', [username], (err, row) => {
+ if (err || row) return;
+
+ db.run(
+ 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
+ [username, passwordHash, 'admin'],
+ (insertErr) => {
+ if (insertErr) {
+ console.error('Failed to create initial admin:', insertErr.message);
+ return;
+ }
+ console.log(`Initial admin created: ${username}`);
+ }
+ );
+ });
+
+ db.get('SELECT id, role FROM users WHERE username = ?', [username], (err, row) => {
+ if (err || !row || row.role === 'admin') return;
+
+ db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', row.id], (updateErr) => {
+ if (updateErr) {
+ console.error('Failed to promote initial admin:', updateErr.message);
+ return;
+ }
+ console.log(`Initial admin promoted: ${username}`);
+ }
+ );
+ });
});
});
diff --git a/backend/server.js b/backend/server.js
index 62a91cd..376ac43 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -4,13 +4,29 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
+const { execFile } = require('child_process');
const archiver = require('archiver');
const db = require('./database');
const app = express();
-const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me';
-const FULLS_DIR = path.join(__dirname, '../public/images/fulls');
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET) {
+ throw new Error('JWT_SECRET must be set before starting the server');
+}
+const PUBLIC_DIR = path.join(__dirname, '../public');
+const DEFAULT_FULLS_DIR = path.join(PUBLIC_DIR, 'images/fulls');
+const DEFAULT_THUMBS_DIR = path.join(PUBLIC_DIR, 'images/thumbs');
+const DEV_FULLS_DIR = path.join(__dirname, '../images/fulls');
+const DEV_THUMBS_DIR = path.join(__dirname, '../images/thumbs');
+const FULLS_DIR = process.env.FULLS_DIR || (fs.existsSync(DEFAULT_FULLS_DIR) ? DEFAULT_FULLS_DIR : DEV_FULLS_DIR);
+const THUMBS_DIR = process.env.THUMBS_DIR || (fs.existsSync(DEFAULT_THUMBS_DIR) ? DEFAULT_THUMBS_DIR : DEV_THUMBS_DIR);
+const SYNC_IMAGE = process.env.SYNC_IMAGE || 'git.mischlabs.de/mrdiderot/photography:latest';
+const SYNC_CONTAINER = process.env.SYNC_CONTAINER || 'photography-website';
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
+let syncInProgress = false;
+
+console.log('Using full-size images at', FULLS_DIR);
+console.log('Using thumbnails at', THUMBS_DIR);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -74,6 +90,61 @@ function listSubfolders(dir) {
}
}
+function isSafePath(baseDir, requestedPath) {
+ const resolvedBase = path.resolve(baseDir);
+ const resolvedPath = path.resolve(resolvedBase, requestedPath);
+ return resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + path.sep);
+}
+
+function hasAssignedFolder(userId, folder, callback) {
+ db.get(
+ 'SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
+ [userId, folder],
+ (err, row) => callback(!err && !!row)
+ );
+}
+
+function serveProtectedImage(req, res) {
+ const kind = req.params[0];
+ const relativePath = req.params[1] || '';
+ const baseDir = kind === 'thumbs' ? THUMBS_DIR : FULLS_DIR;
+ const segments = relativePath.split('/').filter(Boolean);
+ const fileName = segments[segments.length - 1];
+
+ if (!fileName || !IMAGE_RE.test(fileName) || !isSafePath(baseDir, relativePath)) {
+ return res.status(404).end();
+ }
+
+ const absolutePath = path.resolve(baseDir, relativePath);
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
+ return res.status(404).end();
+ }
+
+ // Bilder direkt im Root-Verzeichnis sind oeffentlich. Kundenordner sind geschuetzt.
+ if (segments.length === 1) {
+ return res.sendFile(absolutePath);
+ }
+
+ if (!req.user) {
+ return res.status(401).json({ error: 'Not logged in' });
+ }
+
+ if (req.user.role === 'admin') {
+ return res.sendFile(absolutePath);
+ }
+
+ hasAssignedFolder(req.user.id, segments[0], (allowed) => {
+ if (!allowed) return res.status(403).json({ error: 'Forbidden' });
+ res.sendFile(absolutePath);
+ });
+}
+
+function runDocker(args, callback) {
+ execFile('docker', args, { timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
+ callback(err, `${stdout || ''}${stderr || ''}`.trim());
+ });
+}
+
// ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => {
@@ -256,6 +327,41 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => {
});
});
+app.post('/api/admin/sync', requireAdmin, (req, res) => {
+ if (syncInProgress) {
+ return res.status(409).json({ error: 'Sync laeuft bereits' });
+ }
+
+ syncInProgress = true;
+ runDocker(['pull', SYNC_IMAGE], (pullErr, pullOutput) => {
+ syncInProgress = false;
+
+ if (pullErr) {
+ console.error('Sync pull failed:', pullOutput || pullErr.message);
+ return res.status(500).json({
+ error: 'Docker pull fehlgeschlagen',
+ details: pullOutput || pullErr.message
+ });
+ }
+
+ res.json({
+ success: true,
+ message: 'Neues Image geladen. Container startet neu.',
+ output: pullOutput
+ });
+
+ setTimeout(() => {
+ runDocker(['restart', SYNC_CONTAINER], (restartErr, restartOutput) => {
+ if (restartErr) {
+ console.error('Sync restart failed:', restartOutput || restartErr.message);
+ return;
+ }
+ console.log('Sync restart complete:', restartOutput);
+ });
+ }, 500);
+ });
+});
+
// ==================== DOWNLOAD API ====================
function isValidName(name) {
@@ -373,10 +479,12 @@ app.get('/api/download/folder/:folderName', (req, res) => {
// ==================== STATIC FILES ====================
-app.use(express.static(path.join(__dirname, '../public')));
+app.get(/^\/images\/(fulls|thumbs)\/(.+)$/, serveProtectedImage);
+
+app.use(express.static(PUBLIC_DIR));
app.get('*', (req, res) => {
- res.sendFile(path.join(__dirname, '../public/index.html'));
+ res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
const PORT = 8090;
diff --git a/docker-compose.yml b/docker-compose.yml
index a34ba04..0274133 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,9 +1,17 @@
services:
photography:
- image: ghcr.io/kroonk/photography:latest
+ image: git.mischlabs.de/mrdiderot/photography:latest
container_name: photography-website
ports:
- "8090:8090"
+ environment:
+ - JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
+ - INITIAL_ADMIN_USERNAME=MrDiderot
+ - INITIAL_ADMIN_PASSWORD=Start123
+ - FULLS_DIR=/app/public/images/fulls
+ - THUMBS_DIR=/app/public/images/thumbs
+ - SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
+ - SYNC_CONTAINER=photography-website
volumes:
# Nextcloud-Ordner mit Originalbildern (read-only)
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
@@ -24,7 +32,7 @@ services:
command: photography-website
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- # GitHub Login von deinem NAS uebernehmen, um ghcr.io lesen zu duerfen
+ # Registry Login von deinem NAS uebernehmen, um private Images lesen zu duerfen
- ~/.docker/config.json:/config.json:ro
environment:
- WATCHTOWER_CLEANUP=true