Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ae35217c | |||
| fe651fa38e | |||
| f7b8c0c0fe | |||
| 4af3cc2486 | |||
| 3632b936e9 | |||
| 66a6d4003a | |||
| bdf73c1f06 | |||
| f032743690 | |||
| a9902b95b4 | |||
| 9d1c0b672a | |||
| 727fd8bae8 | |||
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e | |||
| c844f634c8 | |||
| 1d94398e2d | |||
| a2551dc0ad | |||
| 32821934e6 | |||
| b9c28e52cd | |||
| c94b6268cf | |||
| 439c743fd1 | |||
| 74144f1fc6 | |||
| 99a0376657 | |||
| 21b1e79672 | |||
| cfaee103b6 | |||
| c0e346992d | |||
| 04f45a16c9 | |||
| efad1da74d |
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mirror:
|
mirror:
|
||||||
@@ -19,11 +20,22 @@ jobs:
|
|||||||
git config --global user.name "the-luap"
|
git config --global user.name "the-luap"
|
||||||
git config --global user.email "paul-nothaft@hotmail.de"
|
git config --global user.email "paul-nothaft@hotmail.de"
|
||||||
|
|
||||||
|
- name: Debug - Show current branch and status
|
||||||
|
run: |
|
||||||
|
echo "Current branch:"
|
||||||
|
git branch -a
|
||||||
|
echo "Git status:"
|
||||||
|
git status
|
||||||
|
echo "Remote info:"
|
||||||
|
git remote -v
|
||||||
|
|
||||||
- name: Create filtered branch
|
- name: Create filtered branch
|
||||||
run: |
|
run: |
|
||||||
|
# Clean up any existing github-mirror branch
|
||||||
|
git branch -D github-mirror || true
|
||||||
|
|
||||||
# Create a new branch for GitHub
|
# Create a new branch for GitHub
|
||||||
git checkout --orphan -b github-mirror
|
git checkout --orphan github-mirror
|
||||||
|
|
||||||
# Remove sensitive files/directories
|
# Remove sensitive files/directories
|
||||||
# Example: Remove .env files, private configs, etc.
|
# Example: Remove .env files, private configs, etc.
|
||||||
@@ -43,17 +55,45 @@ jobs:
|
|||||||
git rm -r --cached CLAUDE.md || true
|
git rm -r --cached CLAUDE.md || true
|
||||||
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||||
git rm -r --cached logs/ || true
|
git rm -r --cached logs/ || true
|
||||||
|
git rm -r --cached frontend/.claudedocs/ || true
|
||||||
|
git rm -r --cached test-maintenance.sh || true
|
||||||
|
git rm -r --cached storage/ || true
|
||||||
|
|
||||||
|
|
||||||
# Commit the changes
|
# Commit the changes
|
||||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
git commit -m "Remove sensitive files for GitHub mirror" || true
|
||||||
|
|
||||||
|
- name: Check GitHub token
|
||||||
|
env:
|
||||||
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$GITHUBTOKEN" ]; then
|
||||||
|
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Push to GitHub
|
- name: Push to GitHub
|
||||||
env:
|
env:
|
||||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
# Remove existing github remote if it exists
|
||||||
|
git remote remove github || true
|
||||||
|
|
||||||
# Add GitHub remote
|
# Add GitHub remote
|
||||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||||
|
|
||||||
|
# Verify remote was added
|
||||||
|
echo "GitHub remote added:"
|
||||||
|
git remote -v
|
||||||
|
|
||||||
# Force push the filtered branch to GitHub main
|
# Force push the filtered branch to GitHub main
|
||||||
|
echo "Pushing to GitHub..."
|
||||||
git push github github-mirror:main --force
|
git push github github-mirror:main --force
|
||||||
|
echo "Push completed successfully!"
|
||||||
|
|
||||||
|
- name: Workflow completed
|
||||||
|
run: |
|
||||||
|
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||||
|
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
|
||||||
@@ -14,6 +14,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
new_version: ${{ steps.version.outputs.new_version }}
|
new_version: ${{ steps.version.outputs.new_version }}
|
||||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||||
|
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
@@ -30,15 +31,104 @@ jobs:
|
|||||||
git config --global user.name 'Gitea Actions Bot'
|
git config --global user.name 'Gitea Actions Bot'
|
||||||
git config --global user.email 'actions@gitea.local'
|
git config --global user.email 'actions@gitea.local'
|
||||||
|
|
||||||
- name: Bump version
|
- name: Detect changes and bump version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
# Get current version from backend package.json
|
set -e # Exit on error
|
||||||
CURRENT_VERSION=$(node -p "require('./backend/package.json').version")
|
|
||||||
echo "Current version: $CURRENT_VERSION"
|
|
||||||
|
|
||||||
# Split version into parts
|
echo "=== Debug Info ==="
|
||||||
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
|
echo "GitHub event before: ${{ github.event.before }}"
|
||||||
|
echo "GitHub SHA: ${{ github.sha }}"
|
||||||
|
echo "Current directory: $(pwd)"
|
||||||
|
echo "Git log (last 5): $(git log --oneline -5)"
|
||||||
|
|
||||||
|
# Get the commit range for changed files
|
||||||
|
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||||
|
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||||
|
echo "Using commit range: $COMMIT_RANGE"
|
||||||
|
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||||
|
else
|
||||||
|
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||||
|
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||||
|
COMMIT_RANGE="HEAD~1..HEAD"
|
||||||
|
echo "Using commit range: $COMMIT_RANGE"
|
||||||
|
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||||
|
else
|
||||||
|
echo "First commit detected, checking all files"
|
||||||
|
CHANGED_FILES=$(git ls-files)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED_FILES"
|
||||||
|
|
||||||
|
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||||
|
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||||
|
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||||
|
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||||
|
|
||||||
|
echo "Backend files changed: $BACKEND_CHANGED"
|
||||||
|
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||||
|
echo "Root files changed: $ROOT_CHANGED"
|
||||||
|
|
||||||
|
# Get current versions
|
||||||
|
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||||
|
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||||
|
|
||||||
|
echo "Current backend version: $BACKEND_VERSION"
|
||||||
|
echo "Current frontend version: $FRONTEND_VERSION"
|
||||||
|
|
||||||
|
# Determine what to update based on changes
|
||||||
|
BACKEND_UPDATE=false
|
||||||
|
FRONTEND_UPDATE=false
|
||||||
|
COMPONENT_CHANGED="none"
|
||||||
|
|
||||||
|
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||||
|
# Root changes affect both components
|
||||||
|
BACKEND_UPDATE=true
|
||||||
|
FRONTEND_UPDATE=true
|
||||||
|
COMPONENT_CHANGED="both"
|
||||||
|
SOURCE_VERSION=$BACKEND_VERSION
|
||||||
|
echo "Root changes detected - updating both components"
|
||||||
|
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||||
|
# Both components changed
|
||||||
|
BACKEND_UPDATE=true
|
||||||
|
FRONTEND_UPDATE=true
|
||||||
|
COMPONENT_CHANGED="both"
|
||||||
|
# Use the higher version as source
|
||||||
|
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||||
|
SOURCE_VERSION=$BACKEND_VERSION
|
||||||
|
else
|
||||||
|
SOURCE_VERSION=$FRONTEND_VERSION
|
||||||
|
fi
|
||||||
|
echo "Both backend and frontend changed - updating both"
|
||||||
|
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||||
|
# Only backend changed
|
||||||
|
BACKEND_UPDATE=true
|
||||||
|
COMPONENT_CHANGED="backend"
|
||||||
|
SOURCE_VERSION=$BACKEND_VERSION
|
||||||
|
echo "Only backend changed - updating backend"
|
||||||
|
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||||
|
# Only frontend changed
|
||||||
|
FRONTEND_UPDATE=true
|
||||||
|
COMPONENT_CHANGED="frontend"
|
||||||
|
SOURCE_VERSION=$FRONTEND_VERSION
|
||||||
|
echo "Only frontend changed - updating frontend"
|
||||||
|
else
|
||||||
|
echo "No relevant changes detected"
|
||||||
|
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||||
|
echo "new_version=" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Component changed: $COMPONENT_CHANGED"
|
||||||
|
echo "Source version: $SOURCE_VERSION"
|
||||||
|
echo "Backend update: $BACKEND_UPDATE"
|
||||||
|
echo "Frontend update: $FRONTEND_UPDATE"
|
||||||
|
|
||||||
|
# Calculate new version
|
||||||
|
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||||
MAJOR="${version_parts[0]}"
|
MAJOR="${version_parts[0]}"
|
||||||
MINOR="${version_parts[1]}"
|
MINOR="${version_parts[1]}"
|
||||||
PATCH="${version_parts[2]}"
|
PATCH="${version_parts[2]}"
|
||||||
@@ -49,14 +139,23 @@ jobs:
|
|||||||
|
|
||||||
echo "New version: $NEW_VERSION"
|
echo "New version: $NEW_VERSION"
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Update version in package.json files
|
# Update versions in package.json files
|
||||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
if [ "$BACKEND_UPDATE" = true ]; then
|
||||||
cd ../frontend && npm version $NEW_VERSION --no-git-tag-version
|
echo "Updating backend version to $NEW_VERSION"
|
||||||
cd ..
|
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if there are changes
|
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||||
if [[ -n $(git status -s) ]]; then
|
echo "Updating frontend version to $NEW_VERSION"
|
||||||
|
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||||
|
cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if there are changes to commit
|
||||||
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||||
@@ -65,15 +164,36 @@ jobs:
|
|||||||
- name: Commit version bump
|
- name: Commit version bump
|
||||||
if: steps.version.outputs.version_changed == 'true'
|
if: steps.version.outputs.version_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
git add backend/package.json backend/package-lock.json
|
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||||
git add frontend/package.json frontend/package-lock.json
|
|
||||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
if [ "$COMPONENT" = "both" ]; then
|
||||||
|
git add backend/package.json backend/package-lock.json
|
||||||
|
git add frontend/package.json frontend/package-lock.json
|
||||||
|
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||||
|
elif [ "$COMPONENT" = "backend" ]; then
|
||||||
|
git add backend/package.json backend/package-lock.json
|
||||||
|
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||||
|
elif [ "$COMPONENT" = "frontend" ]; then
|
||||||
|
git add frontend/package.json frontend/package-lock.json
|
||||||
|
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||||
|
fi
|
||||||
|
|
||||||
git push
|
git push
|
||||||
|
|
||||||
- name: Create Git tag
|
- name: Create Git tag
|
||||||
if: steps.version.outputs.version_changed == 'true'
|
if: steps.version.outputs.version_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
|
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||||
|
|
||||||
|
if [ "$COMPONENT" = "both" ]; then
|
||||||
|
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||||
|
elif [ "$COMPONENT" = "backend" ]; then
|
||||||
|
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||||
|
elif [ "$COMPONENT" = "frontend" ]; then
|
||||||
|
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||||
|
|
||||||
trigger-drone:
|
trigger-drone:
|
||||||
@@ -84,5 +204,6 @@ jobs:
|
|||||||
- name: Trigger Drone Build
|
- name: Trigger Drone Build
|
||||||
run: |
|
run: |
|
||||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||||
|
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||||
echo "Drone will automatically trigger on the new tag"
|
echo "Drone will automatically trigger on the new tag"
|
||||||
# Drone CI will automatically trigger on the tag push event
|
# Drone CI will automatically trigger on the tag push event
|
||||||
@@ -11,8 +11,7 @@
|
|||||||
|
|
||||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||||
|
|
||||||

|

|
||||||
> 📸 *Gallery preview will be updated soon with latest interface*
|
|
||||||
|
|
||||||
## 🌟 Why Choose PicPeak?
|
## 🌟 Why Choose PicPeak?
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.30",
|
"version": "1.0.46",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.30",
|
"version": "1.0.46",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.30",
|
"version": "1.0.46",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to check storage directory structure and verify files
|
||||||
|
* Usage: node scripts/check-storage.js [eventSlug]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||||
|
|
||||||
|
async function checkDirectory(dirPath, description) {
|
||||||
|
try {
|
||||||
|
await fs.access(dirPath);
|
||||||
|
const stats = await fs.stat(dirPath);
|
||||||
|
const files = await fs.readdir(dirPath);
|
||||||
|
console.log(`✓ ${description}: ${dirPath}`);
|
||||||
|
console.log(` - Files/Folders: ${files.length}`);
|
||||||
|
console.log(` - Permissions: ${(stats.mode & parseInt('777', 8)).toString(8)}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`✗ ${description}: ${dirPath} - ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStorageStructure(eventSlug = null) {
|
||||||
|
console.log('Checking storage structure...');
|
||||||
|
console.log(`Storage base path: ${STORAGE_PATH}\n`);
|
||||||
|
|
||||||
|
// Check main directories
|
||||||
|
await checkDirectory(STORAGE_PATH, 'Storage root');
|
||||||
|
await checkDirectory(path.join(STORAGE_PATH, 'events'), 'Events directory');
|
||||||
|
await checkDirectory(path.join(STORAGE_PATH, 'events/active'), 'Active events');
|
||||||
|
await checkDirectory(path.join(STORAGE_PATH, 'events/archived'), 'Archived events');
|
||||||
|
await checkDirectory(path.join(STORAGE_PATH, 'thumbnails'), 'Thumbnails');
|
||||||
|
await checkDirectory(path.join(STORAGE_PATH, 'uploads'), 'Uploads');
|
||||||
|
|
||||||
|
console.log('\n---\n');
|
||||||
|
|
||||||
|
// If event slug provided, check specific event
|
||||||
|
if (eventSlug) {
|
||||||
|
console.log(`Checking specific event: ${eventSlug}`);
|
||||||
|
|
||||||
|
const event = await db('events').where('slug', eventSlug).first();
|
||||||
|
if (!event) {
|
||||||
|
console.log(`✗ Event not found in database: ${eventSlug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Event found in database:`);
|
||||||
|
console.log(` - ID: ${event.id}`);
|
||||||
|
console.log(` - Name: ${event.event_name}`);
|
||||||
|
console.log(` - Active: ${event.is_active}`);
|
||||||
|
console.log(` - Archived: ${event.is_archived}`);
|
||||||
|
|
||||||
|
// Check event directory
|
||||||
|
const eventDir = path.join(STORAGE_PATH, 'events/active', eventSlug);
|
||||||
|
const eventExists = await checkDirectory(eventDir, 'Event directory');
|
||||||
|
|
||||||
|
if (eventExists) {
|
||||||
|
const files = await fs.readdir(eventDir);
|
||||||
|
console.log(` - Photo files: ${files.filter(f => /\.(jpg|jpeg|png|gif)$/i.test(f)).length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check photos in database
|
||||||
|
const photos = await db('photos').where('event_id', event.id).select('id', 'filename', 'path', 'thumbnail_path');
|
||||||
|
console.log(`\nDatabase photos: ${photos.length}`);
|
||||||
|
|
||||||
|
// Check if photo files exist
|
||||||
|
let existingPhotos = 0;
|
||||||
|
let missingPhotos = 0;
|
||||||
|
let existingThumbnails = 0;
|
||||||
|
let missingThumbnails = 0;
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
|
||||||
|
try {
|
||||||
|
await fs.access(photoPath);
|
||||||
|
existingPhotos++;
|
||||||
|
} catch {
|
||||||
|
missingPhotos++;
|
||||||
|
console.log(` ✗ Missing photo: ${photo.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.thumbnail_path) {
|
||||||
|
const thumbPath = path.join(STORAGE_PATH, photo.thumbnail_path.replace(/^\//, ''));
|
||||||
|
try {
|
||||||
|
await fs.access(thumbPath);
|
||||||
|
existingThumbnails++;
|
||||||
|
} catch {
|
||||||
|
missingThumbnails++;
|
||||||
|
console.log(` ✗ Missing thumbnail: ${photo.thumbnail_path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nFile check summary:`);
|
||||||
|
console.log(` - Photos: ${existingPhotos} exist, ${missingPhotos} missing`);
|
||||||
|
console.log(` - Thumbnails: ${existingThumbnails} exist, ${missingThumbnails} missing`);
|
||||||
|
} else {
|
||||||
|
// List all event directories
|
||||||
|
try {
|
||||||
|
const activeDir = path.join(STORAGE_PATH, 'events/active');
|
||||||
|
const eventDirs = await fs.readdir(activeDir);
|
||||||
|
console.log(`Active event directories: ${eventDirs.length}`);
|
||||||
|
for (const dir of eventDirs.slice(0, 10)) {
|
||||||
|
console.log(` - ${dir}`);
|
||||||
|
}
|
||||||
|
if (eventDirs.length > 10) {
|
||||||
|
console.log(` ... and ${eventDirs.length - 10} more`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Could not list event directories:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
const eventSlug = process.argv[2] || null;
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
checkStorageStructure(eventSlug).then(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
console.log('\nStorage check complete');
|
||||||
|
}).catch(async error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
await db.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to clean up orphaned and temporary thumbnails
|
||||||
|
* Usage: node scripts/cleanup-thumbnails.js [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||||
|
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||||
|
|
||||||
|
async function cleanupThumbnails(dryRun = false) {
|
||||||
|
console.log('Starting thumbnail cleanup...');
|
||||||
|
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
|
||||||
|
console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all thumbnail files
|
||||||
|
const files = await fs.readdir(THUMBNAILS_DIR);
|
||||||
|
console.log(`Found ${files.length} files in thumbnails directory`);
|
||||||
|
|
||||||
|
// Get all valid thumbnail paths from database
|
||||||
|
const validThumbnails = await db('photos')
|
||||||
|
.whereNotNull('thumbnail_path')
|
||||||
|
.select('thumbnail_path');
|
||||||
|
|
||||||
|
const validPaths = new Set(
|
||||||
|
validThumbnails.map(t => path.basename(t.thumbnail_path))
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${validPaths.size} valid thumbnails in database\n`);
|
||||||
|
|
||||||
|
let tempCount = 0;
|
||||||
|
let orphanedCount = 0;
|
||||||
|
let validCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Skip directories
|
||||||
|
const filePath = path.join(THUMBNAILS_DIR, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (stats.isDirectory()) continue;
|
||||||
|
|
||||||
|
// Check if it's a temporary file
|
||||||
|
if (file.startsWith('thumb_temp_')) {
|
||||||
|
tempCount++;
|
||||||
|
console.log(`Temporary file: ${file}`);
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
deletedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` Failed to delete: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if it's an orphaned thumbnail
|
||||||
|
else if (!validPaths.has(file)) {
|
||||||
|
orphanedCount++;
|
||||||
|
console.log(`Orphaned file: ${file}`);
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
deletedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` Failed to delete: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
validCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- Summary ---');
|
||||||
|
console.log(`Total files: ${files.length}`);
|
||||||
|
console.log(`Valid thumbnails: ${validCount}`);
|
||||||
|
console.log(`Temporary files: ${tempCount}`);
|
||||||
|
console.log(`Orphaned files: ${orphanedCount}`);
|
||||||
|
if (!dryRun) {
|
||||||
|
console.log(`Deleted files: ${deletedCount}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Files to be deleted: ${tempCount + orphanedCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during cleanup:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
// Run the cleanup
|
||||||
|
cleanupThumbnails(dryRun).then(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
console.log('\nCleanup complete');
|
||||||
|
}).catch(async error => {
|
||||||
|
console.error('Cleanup failed:', error);
|
||||||
|
await db.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to diagnose thumbnail serving issues
|
||||||
|
* Usage: node scripts/diagnose-thumbnails.js <eventId>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||||
|
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||||
|
|
||||||
|
async function diagnoseThumbnails(eventId) {
|
||||||
|
if (!eventId) {
|
||||||
|
console.error('Usage: node scripts/diagnose-thumbnails.js <eventId>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Diagnosing thumbnails for event ID: ${eventId}`);
|
||||||
|
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||||
|
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get event info
|
||||||
|
const event = await db('events').where('id', eventId).first();
|
||||||
|
if (!event) {
|
||||||
|
console.error(`Event not found with ID: ${eventId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Event: ${event.event_name} (${event.slug})`);
|
||||||
|
console.log(`Active: ${event.is_active}, Archived: ${event.is_archived}\n`);
|
||||||
|
|
||||||
|
// Get photos for this event
|
||||||
|
const photos = await db('photos')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.select('id', 'filename', 'path', 'thumbnail_path');
|
||||||
|
|
||||||
|
console.log(`Found ${photos.length} photos in database\n`);
|
||||||
|
|
||||||
|
let missingThumbnails = 0;
|
||||||
|
let existingThumbnails = 0;
|
||||||
|
let pathIssues = [];
|
||||||
|
|
||||||
|
for (const photo of photos.slice(0, 10)) { // Check first 10 photos
|
||||||
|
console.log(`Photo ID ${photo.id}: ${photo.filename}`);
|
||||||
|
console.log(` Photo path: ${photo.path}`);
|
||||||
|
console.log(` Thumbnail path in DB: ${photo.thumbnail_path}`);
|
||||||
|
|
||||||
|
if (photo.thumbnail_path) {
|
||||||
|
// Expected thumbnail filename
|
||||||
|
const expectedThumbName = `thumb_${photo.filename}`;
|
||||||
|
const expectedThumbPath = path.join(THUMBNAILS_DIR, expectedThumbName);
|
||||||
|
|
||||||
|
// Check if thumbnail exists
|
||||||
|
try {
|
||||||
|
await fs.access(expectedThumbPath);
|
||||||
|
console.log(` ✓ Thumbnail exists at: ${expectedThumbName}`);
|
||||||
|
existingThumbnails++;
|
||||||
|
|
||||||
|
// Check if DB path matches expected path
|
||||||
|
const dbThumbName = path.basename(photo.thumbnail_path);
|
||||||
|
if (dbThumbName !== expectedThumbName) {
|
||||||
|
console.log(` ⚠ Path mismatch! DB has: ${dbThumbName}, Expected: ${expectedThumbName}`);
|
||||||
|
pathIssues.push({
|
||||||
|
photoId: photo.id,
|
||||||
|
dbPath: photo.thumbnail_path,
|
||||||
|
expectedPath: `thumbnails/${expectedThumbName}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(` ✗ Thumbnail missing: ${expectedThumbName}`);
|
||||||
|
missingThumbnails++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ No thumbnail path in database`);
|
||||||
|
missingThumbnails++;
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('--- Summary ---');
|
||||||
|
console.log(`Existing thumbnails: ${existingThumbnails}`);
|
||||||
|
console.log(`Missing thumbnails: ${missingThumbnails}`);
|
||||||
|
console.log(`Path issues: ${pathIssues.length}`);
|
||||||
|
|
||||||
|
if (pathIssues.length > 0) {
|
||||||
|
console.log('\n--- Path Issues ---');
|
||||||
|
console.log('The following photos have incorrect thumbnail paths in the database:');
|
||||||
|
for (const issue of pathIssues) {
|
||||||
|
console.log(`Photo ID ${issue.photoId}:`);
|
||||||
|
console.log(` Current: ${issue.dbPath}`);
|
||||||
|
console.log(` Should be: ${issue.expectedPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nTo fix path issues, run:');
|
||||||
|
console.log(`UPDATE photos SET thumbnail_path = 'thumbnails/thumb_' || filename WHERE event_id = ${eventId};`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any thumbnails in the directory that match this event
|
||||||
|
const files = await fs.readdir(THUMBNAILS_DIR);
|
||||||
|
const eventThumbnails = files.filter(f => {
|
||||||
|
// Try to match thumbnails for this event
|
||||||
|
for (const photo of photos) {
|
||||||
|
if (f === `thumb_${photo.filename}`) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n--- Filesystem Check ---`);
|
||||||
|
console.log(`Found ${eventThumbnails.length} thumbnails in directory for this event`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during diagnosis:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
|
||||||
|
|
||||||
|
// Run the diagnosis
|
||||||
|
diagnoseThumbnails(eventId).then(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
console.log('\nDiagnosis complete');
|
||||||
|
}).catch(async error => {
|
||||||
|
console.error('Diagnosis failed:', error);
|
||||||
|
await db.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to regenerate missing thumbnails for photos in the database
|
||||||
|
* Usage: node scripts/regenerate-thumbnails.js [eventId]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const THUMBNAIL_SIZE = 300;
|
||||||
|
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||||
|
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
|
||||||
|
|
||||||
|
async function ensureDirectoryExists(dirPath) {
|
||||||
|
try {
|
||||||
|
await fs.access(dirPath);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(dirPath, { recursive: true });
|
||||||
|
console.log(`Created directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateThumbnail(photoPath, thumbnailPath) {
|
||||||
|
try {
|
||||||
|
await sharp(photoPath)
|
||||||
|
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center'
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toFile(thumbnailPath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateThumbnails(eventId = null) {
|
||||||
|
try {
|
||||||
|
console.log('Starting thumbnail regeneration...');
|
||||||
|
console.log(`Storage path: ${STORAGE_PATH}`);
|
||||||
|
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
|
||||||
|
|
||||||
|
// Ensure thumbnails directory exists
|
||||||
|
await ensureDirectoryExists(THUMBNAILS_DIR);
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
let query = db('photos')
|
||||||
|
.join('events', 'photos.event_id', 'events.id')
|
||||||
|
.select(
|
||||||
|
'photos.id',
|
||||||
|
'photos.filename',
|
||||||
|
'photos.path',
|
||||||
|
'photos.thumbnail_path',
|
||||||
|
'events.slug as event_slug'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
query = query.where('photos.event_id', eventId);
|
||||||
|
console.log(`Filtering for event ID: ${eventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos = await query;
|
||||||
|
console.log(`Found ${photos.length} photos to process`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let skipCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
|
||||||
|
const thumbnailFilename = `thumb_${photo.filename}`;
|
||||||
|
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if photo file exists
|
||||||
|
await fs.access(photoPath);
|
||||||
|
|
||||||
|
// Check if thumbnail already exists
|
||||||
|
try {
|
||||||
|
await fs.access(thumbnailPath);
|
||||||
|
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
|
||||||
|
skipCount++;
|
||||||
|
continue;
|
||||||
|
} catch {
|
||||||
|
// Thumbnail doesn't exist, generate it
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generating thumbnail for ${photo.filename}...`);
|
||||||
|
const success = await generateThumbnail(photoPath, thumbnailPath);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Update database with thumbnail path
|
||||||
|
await db('photos')
|
||||||
|
.where('id', photo.id)
|
||||||
|
.update({
|
||||||
|
thumbnail_path: `thumbnails/${thumbnailFilename}`
|
||||||
|
});
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
console.log(`✓ Generated thumbnail for ${photo.filename}`);
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Photo file not found: ${photoPath}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nThumbnail regeneration complete!');
|
||||||
|
console.log(`- Successfully generated: ${successCount}`);
|
||||||
|
console.log(`- Skipped (already exist): ${skipCount}`);
|
||||||
|
console.log(`- Errors: ${errorCount}`);
|
||||||
|
console.log(`- Total processed: ${photos.length}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during thumbnail regeneration:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
regenerateThumbnails(eventId).then(() => {
|
||||||
|
console.log('Script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Script failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to test photo authentication
|
||||||
|
* Usage: node scripts/test-photo-auth.js <jwt-token>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
async function testPhotoAuth(token) {
|
||||||
|
if (!token) {
|
||||||
|
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
|
||||||
|
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
|
||||||
|
console.log(`Base URL: ${baseUrl}\n`);
|
||||||
|
|
||||||
|
// Test URLs
|
||||||
|
const tests = [
|
||||||
|
{
|
||||||
|
name: 'Thumbnail via static route',
|
||||||
|
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Photo via static route',
|
||||||
|
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gallery photos API',
|
||||||
|
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
console.log(`Testing: ${test.name}`);
|
||||||
|
console.log(`URL: ${test.url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(test.url, {
|
||||||
|
headers: test.headers,
|
||||||
|
validateStatus: () => true // Don't throw on any status
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Status: ${response.status}`);
|
||||||
|
console.log(`Headers:`, response.headers['content-type']);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
if (test.name.includes('API')) {
|
||||||
|
console.log(`Photos count: ${response.data.photos?.length || 0}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Content length: ${response.headers['content-length']} bytes`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Error:`, response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Network error:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('---\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode token to show info
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||||
|
console.log('Token payload:', payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to decode token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get token from command line
|
||||||
|
const token = process.argv[2];
|
||||||
|
|
||||||
|
testPhotoAuth(token).catch(error => {
|
||||||
|
console.error('Test failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+6
-3
@@ -146,14 +146,17 @@ const setCorsHeaders = (req, res, next) => {
|
|||||||
// Import secure static middleware
|
// Import secure static middleware
|
||||||
const secureStatic = require('./src/middleware/secureStatic');
|
const secureStatic = require('./src/middleware/secureStatic');
|
||||||
|
|
||||||
|
// Get storage path from environment or use default
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||||
|
|
||||||
// Static file serving for photos (protected)
|
// Static file serving for photos (protected)
|
||||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/events/active')));
|
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
||||||
|
|
||||||
// Static file serving for thumbnails (protected)
|
// Static file serving for thumbnails (protected)
|
||||||
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/thumbnails')));
|
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
|
||||||
|
|
||||||
// Static file serving for uploads (public - logos, favicons)
|
// Static file serving for uploads (public - logos, favicons)
|
||||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
|
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
const event = await db('events').where({ id: decoded.eventId, is_active: formatBoolean(true) }).first();
|
const event = await db('events')
|
||||||
|
.where({
|
||||||
|
id: decoded.eventId,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
is_archived: formatBoolean(false)
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ async function photoAuth(req, res, next) {
|
|||||||
// Extract event slug from the path
|
// Extract event slug from the path
|
||||||
let eventSlug;
|
let eventSlug;
|
||||||
|
|
||||||
|
console.log('PhotoAuth middleware - path:', req.path);
|
||||||
|
|
||||||
// For thumbnails, we need to parse the filename to get the event info
|
// For thumbnails, we need to parse the filename to get the event info
|
||||||
if (req.path.startsWith('/thumb_')) {
|
if (req.path.startsWith('/thumb_')) {
|
||||||
// For now, we'll rely on JWT token for thumbnail access
|
// For now, we'll rely on JWT token for thumbnail access
|
||||||
@@ -26,9 +28,22 @@ async function photoAuth(req, res, next) {
|
|||||||
|
|
||||||
// Check if it's a gallery token
|
// Check if it's a gallery token
|
||||||
if (decoded.type === 'gallery') {
|
if (decoded.type === 'gallery') {
|
||||||
// For thumbnails, we accept any valid gallery token
|
// For thumbnails, we need to verify the token is for a valid event
|
||||||
if (!eventSlug) {
|
if (!eventSlug) {
|
||||||
const event = await db('events').where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }).first();
|
// Extract event ID from the decoded token
|
||||||
|
if (decoded.eventId) {
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
||||||
|
.first();
|
||||||
|
if (event) {
|
||||||
|
req.event = event;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to slug
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||||
|
.first();
|
||||||
if (event) {
|
if (event) {
|
||||||
req.event = event;
|
req.event = event;
|
||||||
return next();
|
return next();
|
||||||
@@ -36,7 +51,9 @@ async function photoAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
// For regular photos, check if token matches the event
|
// For regular photos, check if token matches the event
|
||||||
else if (decoded.eventSlug === eventSlug) {
|
else if (decoded.eventSlug === eventSlug) {
|
||||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
const event = await db('events')
|
||||||
|
.where({ slug: eventSlug, is_active: formatBoolean(true) })
|
||||||
|
.first();
|
||||||
if (event) {
|
if (event) {
|
||||||
req.event = event;
|
req.event = event;
|
||||||
return next();
|
return next();
|
||||||
@@ -46,18 +63,12 @@ async function photoAuth(req, res, next) {
|
|||||||
|
|
||||||
// Check if it's an admin token (admins can view all photos)
|
// Check if it's an admin token (admins can view all photos)
|
||||||
if (decoded.type === 'admin') {
|
if (decoded.type === 'admin') {
|
||||||
if (!eventSlug) {
|
// For both thumbnails and photos with admin token, allow access
|
||||||
// For thumbnails with admin token, allow access
|
return next();
|
||||||
return next();
|
|
||||||
}
|
|
||||||
const event = await db('events').where({ slug: eventSlug }).first();
|
|
||||||
if (event) {
|
|
||||||
req.event = event;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, fall through to password check
|
// Token invalid, fall through to password check
|
||||||
|
console.error('JWT verification failed:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +79,8 @@ async function photoAuth(req, res, next) {
|
|||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no eventSlug (thumbnails), we require JWT token
|
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||||
if (!eventSlug) {
|
if (!eventSlug && !password) {
|
||||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -363,12 +363,29 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
// Delete archive file if exists
|
// Delete archive file if exists
|
||||||
if (archive.archive_path) {
|
if (archive.archive_path) {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(archive.archive_path);
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||||
|
await fs.unlink(fullArchivePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete archive file:', error);
|
console.error('Failed to delete archive file:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete thumbnails for this event
|
||||||
|
const photos = await db('photos').where('event_id', req.params.id).select('thumbnail_path');
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
for (const photo of photos) {
|
||||||
|
if (photo.thumbnail_path) {
|
||||||
|
try {
|
||||||
|
const thumbPath = path.join(storagePath, photo.thumbnail_path.replace(/^\//, ''));
|
||||||
|
await fs.unlink(thumbPath);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - thumbnail might already be deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete from database (cascade will delete photos and logs)
|
// Delete from database (cascade will delete photos and logs)
|
||||||
await db('events').where('id', req.params.id).delete();
|
await db('events').where('id', req.params.id).delete();
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ router.post('/', adminAuth, [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
const processedEventName = event_name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||||
|
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||||
|
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||||
|
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
|
|
||||||
@@ -388,13 +393,56 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete associated photos
|
// Start a transaction to ensure all deletions succeed or fail together
|
||||||
await db('photos').where('event_id', id).del();
|
await db.transaction(async (trx) => {
|
||||||
|
// 1. Delete activity logs (audit trail)
|
||||||
|
await trx('activity_logs').where('event_id', id).del();
|
||||||
|
|
||||||
// Delete event
|
// 2. Delete access logs
|
||||||
await db('events').where('id', id).del();
|
await trx('access_logs').where('event_id', id).del();
|
||||||
|
|
||||||
// Log activity
|
// 3. Delete email queue entries
|
||||||
|
await trx('email_queue').where('event_id', id).del();
|
||||||
|
|
||||||
|
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
||||||
|
await trx('photos').where('event_id', id).del();
|
||||||
|
|
||||||
|
// 5. Delete categories (photo_categories has CASCADE delete for event_id)
|
||||||
|
await trx('photo_categories').where('event_id', id).del();
|
||||||
|
|
||||||
|
// 6. Finally delete the event
|
||||||
|
await trx('events').where('id', id).del();
|
||||||
|
|
||||||
|
// Delete event folder from storage if it exists
|
||||||
|
if (event.folder_path) {
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fsPromises = require('fs').promises;
|
||||||
|
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete event folder:', err);
|
||||||
|
// Don't fail the transaction if folder deletion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete archive if exists
|
||||||
|
if (event.archive_path) {
|
||||||
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
const archivePath = path.join(storagePath, event.archive_path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fsPromises = require('fs').promises;
|
||||||
|
await fsPromises.unlink(archivePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete archive file:', err);
|
||||||
|
// Don't fail the transaction if file deletion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity (outside transaction)
|
||||||
await logActivity('event_deleted',
|
await logActivity('event_deleted',
|
||||||
{ event_name: event.event_name },
|
{ event_name: event.event_name },
|
||||||
null,
|
null,
|
||||||
@@ -404,7 +452,19 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
res.json({ message: 'Event deleted successfully' });
|
res.json({ message: 'Event deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting event:', error);
|
console.error('Error deleting event:', error);
|
||||||
res.status(500).json({ error: 'Failed to delete event' });
|
|
||||||
|
// Provide more specific error messages
|
||||||
|
if (error.message && error.message.includes('foreign key constraint')) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Cannot delete event due to existing references. Please contact support.',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to delete event',
|
||||||
|
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -552,8 +552,8 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
photos: photos.map(photo => ({
|
photos: photos.map(photo => ({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
|
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||||
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
category_id: photo.category_id,
|
category_id: photo.category_id,
|
||||||
category_name: photo.category_name,
|
category_name: photo.category_name,
|
||||||
@@ -646,4 +646,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debug endpoint to check photo existence
|
||||||
|
router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
const event = await db('events').where({ id: eventId }).first();
|
||||||
|
const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first();
|
||||||
|
const photos = await db('photos').where({ event_id: eventId }).limit(5);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
event: event || 'Not found',
|
||||||
|
photoCount: photoCount.count,
|
||||||
|
samplePhotos: photos,
|
||||||
|
storagePath: getStoragePath()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -119,7 +119,8 @@ router.post('/gallery/verify', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
hero_photo_id: event.hero_photo_id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,35 +6,11 @@ const archiver = require('archiver');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
|
||||||
try {
|
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
const event = await db('events')
|
|
||||||
.where({ id: decoded.eventId, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.event = event;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying gallery access:', error);
|
|
||||||
res.status(401).json({ error: 'Invalid token', details: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify share token
|
// Verify share token
|
||||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +60,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
|
|
||||||
// If token provided, verify it matches the share link
|
// If token provided, verify it matches the share link
|
||||||
if (token) {
|
if (token) {
|
||||||
const expectedToken = event.share_link.split('/').pop();
|
let expectedToken = event.share_link;
|
||||||
|
// Handle both formats: full URL or just token
|
||||||
|
if (event.share_link && event.share_link.includes('/')) {
|
||||||
|
expectedToken = event.share_link.split('/').pop();
|
||||||
|
}
|
||||||
if (token !== expectedToken) {
|
if (token !== expectedToken) {
|
||||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||||
}
|
}
|
||||||
@@ -156,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
photos: photos.map(photo => ({
|
photos: photos.map(photo => ({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
url: `/photos/${photo.path}`,
|
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
|
||||||
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
|
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
category_id: photo.category_id,
|
category_id: photo.category_id,
|
||||||
category_name: photo.category_name,
|
category_name: photo.category_name,
|
||||||
@@ -340,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve thumbnail
|
||||||
|
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo || !photo.thumbnail_path) {
|
||||||
|
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
try {
|
||||||
|
await fs.access(thumbPath);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate headers
|
||||||
|
res.setHeader('Content-Type', 'image/jpeg');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
|
||||||
|
// Send file
|
||||||
|
res.sendFile(path.resolve(thumbPath));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving thumbnail:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get photo stats
|
// Get photo stats
|
||||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ function validatePassword(password, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip zxcvbn check if explicitly disabled (for gallery passwords)
|
||||||
|
if (options.skipStrengthCheck) {
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
score: 2, // Default moderate score for gallery passwords
|
||||||
|
feedback: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Use zxcvbn for strength analysis
|
// Use zxcvbn for strength analysis
|
||||||
const strength = zxcvbn(password);
|
const strength = zxcvbn(password);
|
||||||
|
|
||||||
@@ -111,7 +121,52 @@ function validatePassword(password, options = {}) {
|
|||||||
* @returns {Object} - Validation result
|
* @returns {Object} - Validation result
|
||||||
*/
|
*/
|
||||||
function validatePasswordInContext(password, context, userData = {}) {
|
function validatePasswordInContext(password, context, userData = {}) {
|
||||||
// Base validation
|
// For gallery context, use more lenient validation
|
||||||
|
if (context === 'gallery') {
|
||||||
|
// Gallery-specific validation options
|
||||||
|
const galleryOptions = {
|
||||||
|
minLength: 6, // Reduced minimum length
|
||||||
|
requireUppercase: false, // Don't require uppercase for galleries
|
||||||
|
requireLowercase: false, // Don't require lowercase for galleries
|
||||||
|
requireNumbers: false, // Numbers are optional
|
||||||
|
requireSpecialChars: false, // Special chars are optional
|
||||||
|
preventCommonPasswords: true, // Still prevent common passwords
|
||||||
|
minStrengthScore: 0, // Accept any score for galleries
|
||||||
|
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base validation with gallery-specific options
|
||||||
|
const result = validatePassword(password, galleryOptions);
|
||||||
|
|
||||||
|
// Override validation for common date formats
|
||||||
|
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
|
||||||
|
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
|
||||||
|
if (datePattern.test(password)) {
|
||||||
|
// Date format is valid for gallery passwords
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
score: 2,
|
||||||
|
feedback: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional gallery-specific checks
|
||||||
|
if (password.length < 6) {
|
||||||
|
result.valid = false;
|
||||||
|
result.errors = ['Password must be at least 6 characters long'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's too simple (e.g., just "123456")
|
||||||
|
if (/^\d{1,6}$/.test(password)) {
|
||||||
|
result.valid = false;
|
||||||
|
result.errors.push('Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base validation for other contexts
|
||||||
const result = validatePassword(password);
|
const result = validatePassword(password);
|
||||||
|
|
||||||
// Context-specific validation
|
// Context-specific validation
|
||||||
@@ -136,16 +191,6 @@ function validatePasswordInContext(password, context, userData = {}) {
|
|||||||
result.errors.push('Password must not contain parts of your email');
|
result.errors.push('Password must not contain parts of your email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (context === 'gallery') {
|
|
||||||
// Gallery passwords can be more lenient for user convenience
|
|
||||||
// Allow passwords with score >= 1 (weak but acceptable)
|
|
||||||
if (result.score < 1) {
|
|
||||||
result.valid = false;
|
|
||||||
result.errors.push('Password is too simple. Please add more complexity');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't check for event name in password - allow date-based passwords
|
|
||||||
// This allows passwords like "Sommer2025!" which users prefer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Nginx Configuration Fix for Photo Authentication
|
||||||
|
|
||||||
|
If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy.
|
||||||
|
|
||||||
|
## Common Issue
|
||||||
|
|
||||||
|
The `Authorization` header is often not passed through by default in nginx proxy configurations.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Add these lines to your nginx configuration for the PicPeak location block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
|
||||||
|
# Important: Pass the Authorization header
|
||||||
|
proxy_pass_header Authorization;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
|
||||||
|
# Other standard proxy headers
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative Fix Using Traefik
|
||||||
|
|
||||||
|
If using Traefik, ensure headers are passed:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
picpeak:
|
||||||
|
labels:
|
||||||
|
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. Check if Authorization header is reaching the backend:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check nginx logs to see if the header is present:
|
||||||
|
```bash
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose Fix
|
||||||
|
|
||||||
|
If using docker-compose with nginx proxy, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- NGINX_PROXY_PASS_HEADER=Authorization
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.30",
|
"version": "1.0.45",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.30",
|
"version": "1.0.45",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-link": "^2.25.0",
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.30",
|
"version": "1.0.45",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -62,9 +62,14 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
let imageUrl = src;
|
let imageUrl = src;
|
||||||
|
|
||||||
// Build full URL for the image
|
// Build full URL for the image
|
||||||
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
|
// For API paths that start with /admin, we need to prepend /api
|
||||||
|
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||||
|
? buildResourceUrl(`/api${imageUrl}`)
|
||||||
|
: imageUrl.startsWith('/')
|
||||||
|
? buildResourceUrl(imageUrl)
|
||||||
|
: imageUrl;
|
||||||
|
|
||||||
console.log('Fetching authenticated image:', fullImageUrl);
|
// console.log('Fetching authenticated image:', fullImageUrl);
|
||||||
const response = await fetch(fullImageUrl, {
|
const response = await fetch(fullImageUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
|
|
||||||
// Fetch photos
|
// Fetch photos
|
||||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -294,11 +294,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
|
// Check if it's an authentication error (401)
|
||||||
|
const is401Error = (error as any)?.response?.status === 401;
|
||||||
|
|
||||||
|
if (is401Error) {
|
||||||
|
// Authentication failed - logout and let the parent component handle re-authentication
|
||||||
|
logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
|
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
|
||||||
<Button onClick={() => window.location.reload()} className="mt-4">
|
<Button onClick={() => refetch()} className="mt-4">
|
||||||
{t('gallery.tryAgain')}
|
{t('gallery.tryAgain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6 text-white" />
|
<X className="w-6 h-6 text-white" />
|
||||||
@@ -171,7 +171,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
{/* Navigation buttons */}
|
{/* Navigation buttons */}
|
||||||
<button
|
<button
|
||||||
onClick={goToPrevious}
|
onClick={goToPrevious}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||||
aria-label="Previous photo"
|
aria-label="Previous photo"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-6 h-6 text-white" />
|
<ChevronLeft className="w-6 h-6 text-white" />
|
||||||
@@ -179,20 +179,19 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||||
aria-label="Next photo"
|
aria-label="Next photo"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-6 h-6 text-white" />
|
<ChevronRight className="w-6 h-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Bottom toolbar */}
|
{/* Bottom toolbar */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
|
||||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<p className="text-sm opacity-75">
|
<p className="text-sm opacity-75">
|
||||||
{currentIndex + 1} / {photos.length}
|
{currentIndex + 1} / {photos.length}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-medium">{currentPhoto.filename}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -231,7 +230,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
|
|
||||||
{/* Image container */}
|
{/* Image container */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center z-0"
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
@@ -257,7 +256,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Touch/swipe indicators for mobile */}
|
{/* Touch/swipe indicators for mobile */}
|
||||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden">
|
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||||
Swipe to navigate
|
Swipe to navigate
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+37
-14
@@ -32,14 +32,24 @@ api.interceptors.request.use(
|
|||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For gallery routes, get the slug from the URL path
|
// For gallery routes, try to extract slug from the request URL first
|
||||||
const pathParts = window.location.pathname.split('/');
|
const galleryMatch = config.url?.match(/\/gallery\/([^\/]+)/);
|
||||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
if (galleryMatch && galleryMatch[1]) {
|
||||||
const gallerySlug = pathParts[2];
|
const gallerySlug = galleryMatch[1];
|
||||||
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
|
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to getting slug from the current page URL
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
||||||
|
const gallerySlug = pathParts[2];
|
||||||
|
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,21 +83,34 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Redirect to appropriate login
|
// Check if it's an admin route
|
||||||
const isAdminRoute = error.config?.url?.includes('/admin');
|
const isAdminRoute = error.config?.url?.includes('/admin');
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
if (isAdminRoute) {
|
if (isAdminRoute) {
|
||||||
// Clear admin token on unauthorized
|
// Clear admin token on unauthorized
|
||||||
Cookies.remove(ADMIN_TOKEN_KEY);
|
Cookies.remove(ADMIN_TOKEN_KEY);
|
||||||
window.location.href = '/admin/login';
|
// Only redirect if we're not already on the admin login page
|
||||||
|
if (!currentPath.includes('/admin/login')) {
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For gallery routes, clear gallery-specific token and redirect
|
// For gallery routes, check if the error is from a gallery API call
|
||||||
const currentPath = window.location.pathname;
|
const galleryMatch = error.config?.url?.match(/\/gallery\/([^\/]+)/);
|
||||||
const pathParts = currentPath.split('/');
|
|
||||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
// Don't redirect if we're on any gallery page (to avoid redirect loops during login)
|
||||||
const gallerySlug = pathParts[2];
|
if (currentPath.startsWith('/gallery/')) {
|
||||||
localStorage.removeItem(`gallery_token_${gallerySlug}`);
|
// If we have a gallery match from the API URL, clear that specific gallery's token
|
||||||
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
if (galleryMatch && galleryMatch[1]) {
|
||||||
window.location.href = `/gallery/${gallerySlug}`;
|
const gallerySlug = galleryMatch[1];
|
||||||
|
localStorage.removeItem(`gallery_token_${gallerySlug}`);
|
||||||
|
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
||||||
|
}
|
||||||
|
// Don't redirect - let the component handle the auth state
|
||||||
|
} else {
|
||||||
|
// We're not on a gallery page but got a 401 from a gallery API
|
||||||
|
// This shouldn't happen in normal flow, but if it does, redirect to homepage
|
||||||
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
|
|||||||
enabled,
|
enabled,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
// Add a small delay to ensure auth token is properly set
|
||||||
|
retryDelay: 100,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { settingsService } from '../services/settings.service';
|
import { api } from '../config/api';
|
||||||
|
|
||||||
export function useWatermarkSettings() {
|
export function useWatermarkSettings() {
|
||||||
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
|
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
|
||||||
@@ -8,11 +8,13 @@ export function useWatermarkSettings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await settingsService.getSettingsByType('branding');
|
// Use public settings endpoint that doesn't require authentication
|
||||||
const brandingSettings = settingsService.formatBrandingSettings(settings);
|
const response = await api.get('/public/settings');
|
||||||
setWatermarkEnabled(brandingSettings.watermark_enabled);
|
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch watermark settings:', error);
|
console.error('Failed to fetch watermark settings:', error);
|
||||||
|
// Default to false if we can't fetch settings
|
||||||
|
setWatermarkEnabled(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
newErrors.password = t('validation.passwordRequired');
|
newErrors.password = t('validation.passwordRequired');
|
||||||
} else if (formData.password.length < 6) {
|
} else if (formData.password.length < 6) {
|
||||||
newErrors.password = t('validation.passwordMinLength');
|
newErrors.password = t('validation.passwordMinLength');
|
||||||
|
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||||
|
// Prevent simple numeric passwords like "123456"
|
||||||
|
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password !== formData.confirm_password) {
|
if (formData.password !== formData.confirm_password) {
|
||||||
@@ -412,6 +415,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
onChange={handleInputChange('password')}
|
onChange={handleInputChange('password')}
|
||||||
error={errors.password}
|
error={errors.password}
|
||||||
placeholder={t('events.enterPassword')}
|
placeholder={t('events.enterPassword')}
|
||||||
|
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
className="pr-10"
|
className="pr-10"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
newErrors.password = t('validation.passwordRequired');
|
newErrors.password = t('validation.passwordRequired');
|
||||||
} else if (formData.password.length < 6) {
|
} else if (formData.password.length < 6) {
|
||||||
newErrors.password = t('validation.passwordMinLength');
|
newErrors.password = t('validation.passwordMinLength');
|
||||||
|
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||||
|
// Prevent simple numeric passwords like "123456"
|
||||||
|
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password !== formData.confirm_password) {
|
if (formData.password !== formData.confirm_password) {
|
||||||
@@ -309,7 +312,6 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
value={formData.event_date}
|
value={formData.event_date}
|
||||||
onChange={handleInputChange('event_date')}
|
onChange={handleInputChange('event_date')}
|
||||||
error={errors.event_date}
|
error={errors.event_date}
|
||||||
min={format(new Date(), 'yyyy-MM-dd')}
|
|
||||||
leftIcon={<Calendar className="w-5 h-5" />}
|
leftIcon={<Calendar className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,6 +456,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleInputChange('password')}
|
onChange={handleInputChange('password')}
|
||||||
error={errors.password}
|
error={errors.password}
|
||||||
|
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||||
leftIcon={<Lock className="w-5 h-5" />}
|
leftIcon={<Lock className="w-5 h-5" />}
|
||||||
rightIcon={
|
rightIcon={
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -33,12 +33,47 @@ export const EventsListPage: React.FC = () => {
|
|||||||
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
|
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
|
||||||
// const [showFilters, setShowFilters] = useState(false);
|
// const [showFilters, setShowFilters] = useState(false);
|
||||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
||||||
|
|
||||||
// Get filter from URL
|
// Get filter from URL
|
||||||
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
|
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
|
||||||
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.dropdown-container')) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeDropdown !== null) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [activeDropdown]);
|
||||||
|
|
||||||
|
// Update dropdown position on scroll/resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScrollOrResize = () => {
|
||||||
|
if (activeDropdown !== null) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
window.addEventListener('resize', handleScrollOrResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
window.removeEventListener('resize', handleScrollOrResize);
|
||||||
|
};
|
||||||
|
}, [activeDropdown]);
|
||||||
|
|
||||||
// Fetch events
|
// Fetch events
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['admin-events', statusFilter],
|
queryKey: ['admin-events', statusFilter],
|
||||||
@@ -272,8 +307,8 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Events Table */}
|
{/* Events Table */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-visible">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto overflow-y-visible">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -347,21 +382,38 @@ export const EventsListPage: React.FC = () => {
|
|||||||
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
|
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="relative inline-block text-left">
|
<div className="relative inline-block text-left dropdown-container">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (activeDropdown === event.id) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
} else {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setActiveDropdown(event.id);
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-neutral-400 hover:text-neutral-600 p-1"
|
className="text-neutral-400 hover:text-neutral-600 p-1"
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-5 h-5" />
|
<MoreVertical className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{activeDropdown === event.id && (
|
{activeDropdown === event.id && dropdownPosition && (
|
||||||
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
|
<div
|
||||||
|
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
|
||||||
|
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
|
||||||
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/admin/events/${event.id}`);
|
navigate(`/admin/events/${event.id}`);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -374,7 +426,10 @@ export const EventsListPage: React.FC = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
onClick={() => setActiveDropdown(null)}
|
onClick={() => {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
{t('events.viewGallery')}
|
{t('events.viewGallery')}
|
||||||
@@ -385,6 +440,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
archiveMutation.mutate(event.id);
|
archiveMutation.mutate(event.id);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -397,6 +453,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast.info(t('events.downloadArchiveSoon'));
|
toast.info(t('events.downloadArchiveSoon'));
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -409,6 +466,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
if (confirm(t('events.deleteEventConfirm'))) {
|
if (confirm(t('events.deleteEventConfirm'))) {
|
||||||
deleteMutation.mutate(event.id);
|
deleteMutation.mutate(event.id);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const settingsService = {
|
|||||||
formData.append('logo', file);
|
formData.append('logo', file);
|
||||||
|
|
||||||
const response = await api.post<{ logoUrl: string }>(
|
const response = await api.post<{ logoUrl: string }>(
|
||||||
'/api/admin/settings/logo',
|
'/admin/settings/logo',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -118,7 +118,7 @@ export const settingsService = {
|
|||||||
formData.append('favicon', file);
|
formData.append('favicon', file);
|
||||||
|
|
||||||
const response = await api.post<{ faviconUrl: string }>(
|
const response = await api.post<{ faviconUrl: string }>(
|
||||||
'/api/admin/settings/favicon',
|
'/admin/settings/favicon',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -136,7 +136,7 @@ export const settingsService = {
|
|||||||
formData.append('watermarkLogo', file);
|
formData.append('watermarkLogo', file);
|
||||||
|
|
||||||
const response = await api.post<{ watermarkLogoUrl: string }>(
|
const response = await api.post<{ watermarkLogoUrl: string }>(
|
||||||
'/api/admin/settings/branding/watermark-logo',
|
'/admin/settings/branding/watermark-logo',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user