Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6a960879f | |||
| 4bd153104b | |||
| e099fcf600 | |||
| 89fdf401c0 | |||
| 9cc46a1819 | |||
| f7c0e5f51a | |||
| 200581e73c | |||
| 505acf833e | |||
| 1ac5b0447a | |||
| 15a2fc2d5f | |||
| a1cf6a1156 | |||
| 481545c37b | |||
| a67df87013 | |||
| d2dbe2ea2f | |||
| 98ea5e7202 | |||
| 6b5c08e99b | |||
| 11ecad136b | |||
| 3a4dccd9f0 | |||
| 0a5e55ca96 | |||
| a72741c0d9 | |||
| e7ed7006fd | |||
| 3d3013d9d6 | |||
| bccaa649dc | |||
| 7aca927937 | |||
| e229c60b22 | |||
| 4966bc6a58 | |||
| 617f292516 | |||
| 8e95004022 | |||
| 93df328853 | |||
| 657e74a2e3 | |||
| ea77f7917e | |||
| f6e5a454ae | |||
| cc7ad4b2bc | |||
| 827a599102 | |||
| b9841b762c | |||
| e636cf5d56 | |||
| a896fa66c0 | |||
| 027c1090a4 | |||
| 4e214588a7 | |||
| 9d0607f4f0 | |||
| 8cbe97d2f4 | |||
| 0d31c9037c | |||
| ffcfd9766d | |||
| 3501a52f0e | |||
| 5ca598b80a | |||
| 4e2075c638 | |||
| 0a691d4251 | |||
| 34846ae71a | |||
| ea261dd03b | |||
| eb93223d79 |
@@ -10,10 +10,10 @@ jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout repository with full history
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for mirroring
|
||||
fetch-depth: 0 # Full history needed for finding the commit
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
@@ -28,40 +28,144 @@ jobs:
|
||||
git status
|
||||
echo "Remote info:"
|
||||
git remote -v
|
||||
echo "Checking target commit exists:"
|
||||
git show --oneline 7aca927937 || echo "Target commit not found!"
|
||||
|
||||
- name: Create filtered branch
|
||||
- name: Create completely new history from specific commit
|
||||
run: |
|
||||
TARGET_COMMIT="7aca927937"
|
||||
|
||||
# Verify the target commit exists
|
||||
if ! git cat-file -e $TARGET_COMMIT^{commit}; then
|
||||
echo "ERROR: Target commit $TARGET_COMMIT does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Target commit found: $(git log --oneline -1 $TARGET_COMMIT)"
|
||||
|
||||
# Clean up any existing github-mirror branch
|
||||
git branch -D github-mirror || true
|
||||
|
||||
# Create a new branch for GitHub
|
||||
# Create a completely new orphan branch (no history)
|
||||
git checkout --orphan github-mirror
|
||||
|
||||
# Remove sensitive files/directories
|
||||
# Example: Remove .env files, private configs, etc.
|
||||
git rm -r --cached .env* || true
|
||||
git rm -r --cached backend/.env* || true
|
||||
git rm -r --cached frontend/.env* || true
|
||||
git rm -r --cached docker-compose.prod.yml || true
|
||||
git rm -r --cached .claudedocs/ || true
|
||||
git rm -r --cached backend/data/ || true
|
||||
git rm -r --cached backend/storage/ || true
|
||||
git rm -r --cached .gitea/ || true
|
||||
git rm -r --cached scripts/install-gitea-runner.sh || true
|
||||
git rm -r --cached .drone* || true
|
||||
git rm -r --cached .github-mirror-exclude || true
|
||||
git rm -r --cached .gitattributes-github || true
|
||||
git rm -r --cached photo-sharing-prd.md || true
|
||||
git rm -r --cached CLAUDE.md || true
|
||||
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || 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
|
||||
# Clear the staging area completely
|
||||
git rm -rf . || true
|
||||
|
||||
# Get the file tree from the target commit and create initial commit
|
||||
echo "Creating new history starting from $TARGET_COMMIT..."
|
||||
git read-tree $TARGET_COMMIT
|
||||
git commit -m "Initial commit - imported from $(git log --oneline -1 $TARGET_COMMIT)"
|
||||
|
||||
echo "✅ Created new initial commit: $(git log --oneline -1)"
|
||||
|
||||
# Now get all commits after the target commit and apply their changes
|
||||
COMMITS_AFTER_TARGET=$(git rev-list --reverse --no-merges $TARGET_COMMIT..main)
|
||||
|
||||
if [ -n "$COMMITS_AFTER_TARGET" ]; then
|
||||
echo "📋 Applying changes from commits after $TARGET_COMMIT:"
|
||||
|
||||
for commit in $COMMITS_AFTER_TARGET; do
|
||||
echo "Processing: $(git log --oneline -1 $commit)"
|
||||
|
||||
# Get the commit message and author info
|
||||
COMMIT_MSG=$(git log --format="%B" -n 1 $commit)
|
||||
COMMIT_AUTHOR=$(git log --format="%an <%ae>" -n 1 $commit)
|
||||
COMMIT_DATE=$(git log --format="%ad" -n 1 $commit)
|
||||
|
||||
# Apply the changes from this commit
|
||||
if git diff-tree --no-commit-id --name-only -r $commit | xargs -I {} git show $commit:{} > /dev/null 2>&1; then
|
||||
# Apply file changes
|
||||
git checkout $commit -- . || true
|
||||
|
||||
# Stage all changes
|
||||
git add -A
|
||||
|
||||
# Only commit if there are changes
|
||||
if ! git diff --cached --quiet; then
|
||||
# Create new commit with original metadata but new SHA
|
||||
GIT_AUTHOR_NAME=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||
GIT_AUTHOR_EMAIL=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||
GIT_AUTHOR_DATE="$COMMIT_DATE"
|
||||
|
||||
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo "✅ Applied changes as new commit: $(git log --oneline -1)"
|
||||
else
|
||||
echo "⚠️ No changes to commit for $commit"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Skipping problematic commit $commit"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Finished creating new history"
|
||||
else
|
||||
echo "✅ No commits after target commit - history starts fresh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== New History Summary ==="
|
||||
echo "Total commits in new history: $(git rev-list --count github-mirror)"
|
||||
echo "History starts with: $(git log --oneline --reverse | head -1)"
|
||||
echo "Latest commit: $(git log --oneline -1)"
|
||||
|
||||
|
||||
# Commit the changes
|
||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
||||
- name: Remove sensitive files and directories
|
||||
run: |
|
||||
# Switch to the github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
echo "Current files before cleanup:"
|
||||
ls -la | head -10 || true
|
||||
echo "..."
|
||||
|
||||
# Remove sensitive files/directories if they exist
|
||||
echo "Removing sensitive files..."
|
||||
rm -rf .env* || true
|
||||
rm -rf backend/.env* || true
|
||||
rm -rf frontend/.env* || true
|
||||
rm -rf docker-compose.prod.yml || true
|
||||
rm -rf .claudedocs/ || true
|
||||
rm -rf backend/data/ || true
|
||||
rm -rf backend/storage/ || true
|
||||
rm -rf .gitea/ || true
|
||||
rm -rf scripts/install-gitea-runner.sh || true
|
||||
rm -rf .drone* || true
|
||||
rm -rf .github-mirror-exclude || true
|
||||
rm -rf .gitattributes-github || true
|
||||
rm -rf photo-sharing-prd.md || true
|
||||
rm -rf CLAUDE.md || true
|
||||
rm -rf PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||
rm -rf logs/ || true
|
||||
rm -rf frontend/.claudedocs/ || true
|
||||
rm -rf test-maintenance.sh || true
|
||||
rm -rf storage/ || true
|
||||
|
||||
echo "Sensitive files removal completed"
|
||||
|
||||
# Add and commit the cleanup if there are changes
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: remove sensitive files for GitHub mirror"
|
||||
echo "✅ Committed cleanup of sensitive files"
|
||||
else
|
||||
echo "✅ No sensitive files to remove"
|
||||
fi
|
||||
|
||||
echo "Final file structure (top level):"
|
||||
ls -la | head -10 || true
|
||||
|
||||
- name: Verify completely new history
|
||||
run: |
|
||||
git checkout github-mirror
|
||||
echo "=== Final History Verification ==="
|
||||
echo "Total commits in new github-mirror branch: $(git rev-list --count github-mirror)"
|
||||
echo ""
|
||||
echo "Complete commit history (should start from target commit content):"
|
||||
git log --oneline --reverse
|
||||
echo ""
|
||||
echo "⚠️ Note: This is a completely NEW history with new commit SHAs"
|
||||
echo "🔍 Original target commit content preserved but with new commit ID"
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
@@ -74,10 +178,13 @@ jobs:
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
- name: Force push completely new history to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Switch to github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
@@ -88,12 +195,17 @@ jobs:
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the filtered branch to GitHub main
|
||||
echo "Pushing to GitHub..."
|
||||
# Force push the completely new history to GitHub main
|
||||
echo "🔥 FORCE PUSHING completely new history to GitHub..."
|
||||
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
|
||||
git push github github-mirror:main --force
|
||||
echo "Push completed successfully!"
|
||||
echo "✅ Force push completed - GitHub now has completely new history!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
|
||||
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
|
||||
echo "📊 History starts from commit content: 7aca927937"
|
||||
echo "🔍 Check https://github.com/the-luap/picpeak to verify the new history"
|
||||
echo "📈 Total commits pushed: $(git rev-list --count github-mirror)"
|
||||
echo "🆕 All commit SHAs are NEW - no connection to previous history"
|
||||
@@ -164,11 +164,25 @@ jobs:
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
|
||||
# First, ensure we have the latest changes
|
||||
echo "Fetching latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Check if we're behind and need to update
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||
echo "Local is behind remote, pulling changes..."
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git add backend/package.json backend/package-lock.json 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
|
||||
@@ -178,7 +192,51 @@ jobs:
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
git push
|
||||
# Pull latest changes before pushing to avoid conflicts
|
||||
echo "Pulling latest changes from origin/main..."
|
||||
if ! git pull --rebase origin main; then
|
||||
echo "Rebase failed, attempting to resolve..."
|
||||
# If rebase fails, abort and try a regular merge
|
||||
git rebase --abort || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
|
||||
# Push the changes with retry logic
|
||||
echo "Pushing version bump..."
|
||||
PUSH_SUCCESS=false
|
||||
|
||||
for i in 1 2 3; do
|
||||
echo "Push attempt $i of 3..."
|
||||
|
||||
# Try to push
|
||||
if git push origin main 2>&1; then
|
||||
echo "Successfully pushed version bump on attempt $i"
|
||||
PUSH_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "Push failed on attempt $i"
|
||||
|
||||
if [ $i -lt 3 ]; then
|
||||
echo "Waiting 5 seconds before retry..."
|
||||
sleep 5
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
git fetch origin main
|
||||
|
||||
# Try rebase first, fall back to merge
|
||||
if ! git rebase origin/main; then
|
||||
echo "Rebase failed, trying merge..."
|
||||
git rebase --abort 2>/dev/null || true
|
||||
git pull origin main --no-rebase
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$PUSH_SUCCESS" = "false" ]; then
|
||||
echo "ERROR: Failed to push after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
|
||||
@@ -54,3 +54,5 @@ coverage/
|
||||
!storage/thumbnails/.gitkeep
|
||||
!data/.gitkeep
|
||||
!logs/.gitkeep
|
||||
|
||||
PRODUCTION_DEPLOYMENT_GUIDE.md
|
||||
@@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment
|
||||
pm2 start ecosystem.config.js # Alternative: PM2 deployment
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL PRODUCTION NOTICE:**
|
||||
- Production runs on a SEPARATE SERVER - never assume local changes affect production
|
||||
- ALWAYS request production server details before any troubleshooting
|
||||
- NO trial-and-error approaches in production - data loss is unacceptable
|
||||
- Every change must be thoroughly analyzed and tested locally first
|
||||
|
||||
## Key Product Requirements (from PRD)
|
||||
|
||||
### Core Features
|
||||
@@ -127,6 +133,41 @@ Background services run as separate processes:
|
||||
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
|
||||
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
|
||||
|
||||
## Troubleshooting Guidelines
|
||||
|
||||
### Before ANY Production Troubleshooting:
|
||||
1. **ALWAYS request specific details**:
|
||||
- Production server URL/IP
|
||||
- Current error messages/logs
|
||||
- Recent changes or deployments
|
||||
- Affected users/galleries
|
||||
- Time of issue occurrence
|
||||
|
||||
2. **Thorough Analysis Required**:
|
||||
- Use detailed thinking/analysis for EVERY troubleshooting task
|
||||
- Review all related code before suggesting changes
|
||||
- Consider all potential side effects
|
||||
- Never make assumptions about production environment
|
||||
|
||||
3. **Safe Troubleshooting Steps**:
|
||||
- First, reproduce issue in local/dev environment
|
||||
- Analyze logs without modifying production
|
||||
- Create detailed action plan before any changes
|
||||
- Always have rollback strategy ready
|
||||
- Document every step taken
|
||||
|
||||
### Common Issues & Safe Approaches:
|
||||
- **Email not sending**: Check email_queue table, SMTP settings, service status
|
||||
- **Photos not loading**: Verify file permissions, storage paths, nginx config
|
||||
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
|
||||
- **Performance problems**: Analyze with monitoring tools first, never experiment
|
||||
|
||||
### Data Safety Rules:
|
||||
- NEVER delete or modify production data without explicit backup confirmation
|
||||
- ALWAYS verify backups exist before any data operations
|
||||
- NO direct database modifications without transaction safety
|
||||
- Log all actions for audit trail
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
@@ -258,4 +299,30 @@ const { theme, setTheme, setThemeByName } = useTheme();
|
||||
- Guest satisfaction: >90%
|
||||
- System uptime: 99.9%
|
||||
- Email delivery rate: >98%
|
||||
- Successful archiving: 100%
|
||||
- Successful archiving: 100%
|
||||
|
||||
## Documentation & Development Practices
|
||||
|
||||
### Documentation Guidelines:
|
||||
- **NEVER create new documentation files for simple tasks**
|
||||
- **ALWAYS update existing documentation (like this CLAUDE.md)**
|
||||
- Only create new .md files when explicitly requested
|
||||
- Avoid creating temporary scripts for one-off tasks
|
||||
|
||||
### Development Best Practices:
|
||||
- Test all changes thoroughly in local environment first
|
||||
- Use version control for all changes
|
||||
- Keep commits atomic and well-described
|
||||
- Review impact on all integrated services
|
||||
- Consider backward compatibility
|
||||
- Update tests when changing functionality
|
||||
|
||||
### Production Deployment Checklist:
|
||||
- [ ] All tests passing locally
|
||||
- [ ] Linting and type checks pass
|
||||
- [ ] Database migrations tested with rollback plan
|
||||
- [ ] Environment variables documented
|
||||
- [ ] Backup strategy confirmed
|
||||
- [ ] Monitoring alerts configured
|
||||
- [ ] Rollback procedure documented
|
||||
- [ ] Stakeholders notified of maintenance window
|
||||
@@ -0,0 +1,714 @@
|
||||
# 🚀 Production Todo List - PicPeak Enhancements
|
||||
|
||||
**Priority:** HIGH - These are production fixes and enhancements
|
||||
**Estimated Time:** 2-3 days
|
||||
**Status:** Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## 📋 Action Items Overview
|
||||
|
||||
1. [Password Complexity Settings](#1-password-complexity-settings)
|
||||
2. [Gallery Login Page - Remove Event Date](#2-gallery-login-page---remove-event-date)
|
||||
3. [Analytics Umami Configuration Check](#3-analytics-umami-configuration-check)
|
||||
4. [Analytics Numbers Accuracy Fix](#4-analytics-numbers-accuracy-fix)
|
||||
5. [Missing Translation Key Fix](#5-missing-translation-key-fix)
|
||||
6. [Complete Translation Audit](#6-complete-translation-audit)
|
||||
7. [CMS Page Long German Text Formatting](#7-cms-page-long-german-text-formatting)
|
||||
8. [Event Creation Date Format Fix](#8-event-creation-date-format-fix)
|
||||
9. [Language Selector Country Flags Chrome Fix](#9-language-selector-country-flags-chrome-fix)
|
||||
|
||||
---
|
||||
|
||||
## 1. Password Complexity Settings
|
||||
|
||||
**Problem:** Admin security tab only has password minimum length setting, no complexity requirements.
|
||||
|
||||
**Current State:**
|
||||
- File: `frontend/src/pages/admin/SettingsPage.tsx` (lines 635-669)
|
||||
- Backend: `backend/src/utils/passwordValidation.js` has complexity logic but not exposed in settings
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Frontend Changes:
|
||||
```typescript
|
||||
// File: frontend/src/pages/admin/SettingsPage.tsx
|
||||
// Add to securitySettings state (around line 61):
|
||||
const [securitySettings, setSecuritySettings] = useState({
|
||||
require_password: true,
|
||||
password_min_length: 8,
|
||||
password_complexity_level: 'medium', // ADD THIS
|
||||
enable_2fa: false,
|
||||
session_timeout_minutes: 60,
|
||||
max_login_attempts: 5,
|
||||
enable_recaptcha: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: ''
|
||||
});
|
||||
|
||||
// Add complexity setting UI after password_min_length (around line 660):
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.passwordComplexity')}
|
||||
</label>
|
||||
<select
|
||||
value={securitySettings.password_complexity_level}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity_level: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="low">{t('settings.security.complexityLow')}</option>
|
||||
<option value="medium">{t('settings.security.complexityMedium')}</option>
|
||||
<option value="high">{t('settings.security.complexityHigh')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.security.complexityHelp')}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Translation Updates:
|
||||
```json
|
||||
// File: frontend/src/i18n/locales/en.json (add to settings.security):
|
||||
"passwordComplexity": "Password Complexity Level",
|
||||
"complexityLow": "Low - Length only",
|
||||
"complexityMedium": "Medium - Letters and numbers",
|
||||
"complexityHigh": "High - Letters, numbers, and symbols",
|
||||
"complexityHelp": "Controls password requirements for new gallery passwords"
|
||||
|
||||
// File: frontend/src/i18n/locales/de.json (add to settings.security):
|
||||
"passwordComplexity": "Passwort-Komplexitätsstufe",
|
||||
"complexityLow": "Niedrig - Nur Länge",
|
||||
"complexityMedium": "Mittel - Buchstaben und Zahlen",
|
||||
"complexityHigh": "Hoch - Buchstaben, Zahlen und Symbole",
|
||||
"complexityHelp": "Steuert Passwort-Anforderungen für neue Galerie-Passwörter"
|
||||
```
|
||||
|
||||
### Backend Changes:
|
||||
```javascript
|
||||
// File: backend/src/utils/passwordValidation.js
|
||||
// Update PASSWORD_CONFIG based on settings (around line 8):
|
||||
const getPasswordConfigFromSettings = async () => {
|
||||
const { db } = require('../database/db');
|
||||
const settings = await db('admin_settings').select('key', 'value');
|
||||
const settingsMap = settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const complexityLevel = settingsMap.security_password_complexity_level || 'medium';
|
||||
|
||||
return {
|
||||
...PASSWORD_CONFIG,
|
||||
minLength: parseInt(settingsMap.security_password_min_length) || 8,
|
||||
requireUppercase: complexityLevel !== 'low',
|
||||
requireLowercase: complexityLevel !== 'low',
|
||||
requireNumbers: complexityLevel === 'high' || complexityLevel === 'medium',
|
||||
requireSpecialChars: complexityLevel === 'high',
|
||||
minStrengthScore: complexityLevel === 'high' ? 3 : (complexityLevel === 'medium' ? 2 : 1)
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Gallery Login Page - Remove Event Date
|
||||
|
||||
**Problem:** Gallery login shows event date which is often used as password, creating security risk.
|
||||
|
||||
**Current State:**
|
||||
- File: `frontend/src/pages/GalleryPage.tsx` (lines 275-285)
|
||||
- Shows both event name and date with calendar icon
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Frontend Changes:
|
||||
```typescript
|
||||
// File: frontend/src/pages/GalleryPage.tsx
|
||||
// Replace the date display section (around lines 280-285):
|
||||
|
||||
// REMOVE THIS:
|
||||
/*
|
||||
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
||||
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
|
||||
</div>
|
||||
*/
|
||||
|
||||
// REPLACE WITH:
|
||||
<div className="text-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
<span className="truncate">{galleryInfo?.event_type ? t(`events.types.${galleryInfo.event_type}`) : ''}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Additional Layout Improvements:
|
||||
```typescript
|
||||
// File: frontend/src/pages/GalleryPage.tsx
|
||||
// Update the header section for better visual balance (around line 275):
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<img
|
||||
src={settingsData?.branding_logo_url ?
|
||||
buildResourceUrl(settingsData.branding_logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={settingsData?.branding_company_name || 'PicPeak'}
|
||||
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
|
||||
/>
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
{galleryInfo?.event_name}
|
||||
</h1>
|
||||
{/* Event type instead of date */}
|
||||
{galleryInfo?.event_type && (
|
||||
<div className="text-center text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
<span className="px-3 py-1 bg-white/20 rounded-full backdrop-blur-sm">
|
||||
{t(`events.types.${galleryInfo.event_type}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Analytics Umami Configuration Check
|
||||
|
||||
**Problem:** Analytics page shows "Umami Analytics Not Configured" even when configured in settings.
|
||||
|
||||
**Current State:**
|
||||
- File: `frontend/src/pages/admin/AnalyticsPage.tsx` (lines 67-87)
|
||||
- Check logic may not be working correctly
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Frontend Fix:
|
||||
```typescript
|
||||
// File: frontend/src/pages/admin/AnalyticsPage.tsx
|
||||
// Fix the Umami configuration check (around lines 67-87):
|
||||
|
||||
// REPLACE the useEffect:
|
||||
useEffect(() => {
|
||||
const fetchUmamiConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
|
||||
const settings = await response.json();
|
||||
|
||||
// Check if Umami is properly configured
|
||||
const isConfigured = settings.analytics_umami_enabled &&
|
||||
settings.analytics_umami_url &&
|
||||
settings.analytics_umami_website_id;
|
||||
|
||||
if (isConfigured) {
|
||||
setUmamiConfig({
|
||||
url: settings.analytics_umami_url,
|
||||
shareUrl: settings.analytics_umami_share_url,
|
||||
websiteId: settings.analytics_umami_website_id,
|
||||
enabled: true
|
||||
});
|
||||
} else {
|
||||
// Fall back to environment variables
|
||||
const envConfigured = import.meta.env.VITE_UMAMI_URL &&
|
||||
import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
setUmamiConfig({
|
||||
url: import.meta.env.VITE_UMAMI_URL,
|
||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
|
||||
websiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID,
|
||||
enabled: envConfigured
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Umami config:', error);
|
||||
setUmamiConfig({ enabled: false });
|
||||
}
|
||||
};
|
||||
|
||||
fetchUmamiConfig();
|
||||
}, []);
|
||||
|
||||
// Update the configuration notice condition (around line 441):
|
||||
{!umamiConfig.enabled && (
|
||||
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900">{t('analytics.notConfigured')}</p>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
{t('analytics.configureInstructions')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Analytics Numbers Accuracy Fix
|
||||
|
||||
**Problem:** Dashboard shows correct numbers but analytics page shows different numbers.
|
||||
|
||||
**Current State:**
|
||||
- Dashboard: `frontend/src/services/admin.service.ts` `getDashboardStats()`
|
||||
- Analytics: `frontend/src/services/admin.service.ts` `getAnalytics()`
|
||||
- Backend: Different endpoints with potentially different calculation logic
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Backend Investigation and Fix:
|
||||
```javascript
|
||||
// File: backend/src/routes/adminDashboard.js
|
||||
// Ensure consistent calculation logic in both /stats and /analytics endpoints
|
||||
|
||||
// Update the analytics endpoint (around line 216) to use the same calculation as stats:
|
||||
router.get('/analytics', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const days = sanitizeDays(req.query.days || 7);
|
||||
|
||||
// Use same calculation logic as /stats endpoint
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - days);
|
||||
|
||||
// Get total downloads - SAME logic as /stats
|
||||
const totalDownloads = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get total views - SAME logic as /stats
|
||||
const totalViews = await db('access_logs')
|
||||
.where('action', 'view')
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// ... rest of the analytics logic
|
||||
|
||||
// Add totals to response for verification
|
||||
res.json({
|
||||
chartData: dates,
|
||||
topGalleries,
|
||||
devices,
|
||||
totals: {
|
||||
totalViews: totalViews.count,
|
||||
totalDownloads: totalDownloads.count,
|
||||
period: `${days} days`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Analytics error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch analytics data' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend Verification:
|
||||
```typescript
|
||||
// File: frontend/src/pages/admin/AnalyticsPage.tsx
|
||||
// Add debug information in development (around line 107):
|
||||
|
||||
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
|
||||
if (!apiData) return undefined;
|
||||
|
||||
// Calculate totals from chart data
|
||||
const totalViews = apiData.chartData.reduce((sum, day) => sum + day.views, 0);
|
||||
const totalDownloads = apiData.chartData.reduce((sum, day) => sum + day.downloads, 0);
|
||||
|
||||
// Debug: Compare with API totals in development
|
||||
if (process.env.NODE_ENV === 'development' && apiData.totals) {
|
||||
console.log('Analytics Debug:', {
|
||||
calculatedViews: totalViews,
|
||||
apiTotalViews: apiData.totals.totalViews,
|
||||
calculatedDownloads: totalDownloads,
|
||||
apiTotalDownloads: apiData.totals.totalDownloads,
|
||||
period: apiData.totals.period
|
||||
});
|
||||
}
|
||||
|
||||
// ... rest of the calculation
|
||||
}, [apiData]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Missing Translation Key Fix
|
||||
|
||||
**Problem:** Missing translation key `admin.activities.analytics_settings_updated` in recent activities.
|
||||
|
||||
**Current State:**
|
||||
- Key not found in `frontend/src/i18n/locales/en.json` or `de.json`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Translation Updates:
|
||||
```json
|
||||
// File: frontend/src/i18n/locales/en.json
|
||||
// Add to admin.activities section (around line 785):
|
||||
"analytics_settings_updated": "Analytics settings updated"
|
||||
|
||||
// File: frontend/src/i18n/locales/de.json
|
||||
// Add to admin.activities section (around line 710):
|
||||
"analytics_settings_updated": "Analytik-Einstellungen aktualisiert"
|
||||
```
|
||||
|
||||
### Backend Activity Logging:
|
||||
```javascript
|
||||
// File: backend/src/routes/adminSettings.js (or wherever analytics settings are updated)
|
||||
// Ensure activity is logged with correct key:
|
||||
|
||||
await logActivity(req.admin.id, 'analytics_settings_updated', {
|
||||
settingsUpdated: Object.keys(updateData).filter(key => key.startsWith('analytics_')),
|
||||
timestamp: new Date()
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Complete Translation Audit
|
||||
|
||||
**Problem:** Need to check all recent activity types for missing translations.
|
||||
|
||||
**Current State:**
|
||||
- Activity types defined in backend, translations in frontend
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Audit Script:
|
||||
```bash
|
||||
# Create a script to find missing translation keys
|
||||
# File: scripts/audit-translations.js
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read translation files
|
||||
const enTranslations = JSON.parse(fs.readFileSync('frontend/src/i18n/locales/en.json', 'utf8'));
|
||||
const deTranslations = JSON.parse(fs.readFileSync('frontend/src/i18n/locales/de.json', 'utf8'));
|
||||
|
||||
// Common activity types that should exist
|
||||
const requiredActivityKeys = [
|
||||
'event_created', 'event_updated', 'event_deleted', 'event_archived',
|
||||
'photos_uploaded', 'photo_deleted', 'photos_bulk_deleted',
|
||||
'archive_downloaded', 'archive_deleted', 'archive_restored',
|
||||
'email_config_updated', 'email_template_updated',
|
||||
'branding_updated', 'theme_updated', 'analytics_settings_updated',
|
||||
'general_settings_updated', 'security_settings_updated',
|
||||
'category_created', 'category_updated', 'category_deleted',
|
||||
'cms_page_updated', 'favicon_uploaded',
|
||||
'bulk_download', 'gallery_password_entry', 'expiration_warning_viewed'
|
||||
];
|
||||
|
||||
console.log('Missing English translations:');
|
||||
requiredActivityKeys.forEach(key => {
|
||||
if (!enTranslations.admin?.activities?.[key]) {
|
||||
console.log(`- admin.activities.${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nMissing German translations:');
|
||||
requiredActivityKeys.forEach(key => {
|
||||
if (!deTranslations.admin?.activities?.[key]) {
|
||||
console.log(`- admin.activities.${key}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Missing Translations to Add:
|
||||
```json
|
||||
// File: frontend/src/i18n/locales/en.json
|
||||
// Add any missing keys to admin.activities:
|
||||
"analytics_settings_updated": "Analytics settings updated",
|
||||
"cms_page_updated": "CMS page updated: {{page}}",
|
||||
"security_settings_updated": "Security settings updated",
|
||||
"password_reset": "Password reset for: {{eventName}}",
|
||||
"admin_logout": "Admin {{actorName}} logged out",
|
||||
"system_activity": "System activity: {{type}}"
|
||||
|
||||
// File: frontend/src/i18n/locales/de.json
|
||||
// German equivalents:
|
||||
"analytics_settings_updated": "Analytik-Einstellungen aktualisiert",
|
||||
"cms_page_updated": "CMS-Seite aktualisiert: {{page}}",
|
||||
"security_settings_updated": "Sicherheitseinstellungen aktualisiert",
|
||||
"password_reset": "Passwort zurückgesetzt für: {{eventName}}",
|
||||
"admin_logout": "Admin {{actorName}} abgemeldet",
|
||||
"system_activity": "Systemaktivität: {{type}}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CMS Page Long German Text Formatting
|
||||
|
||||
**Problem:** Long German text like "Datenschutzerklärung" pushes image to left and looks ugly.
|
||||
|
||||
**Current State:**
|
||||
- File: `frontend/src/pages/admin/CMSPageEnhanced.tsx` (lines 130-170)
|
||||
- File: `frontend/src/pages/admin/CMSPage.tsx` (lines 90-110)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### CSS Fix:
|
||||
```typescript
|
||||
// File: frontend/src/pages/admin/CMSPageEnhanced.tsx
|
||||
// Update the page selection buttons (around line 130):
|
||||
|
||||
<button
|
||||
key={page.slug}
|
||||
onClick={() => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (confirm('You have unsaved changes. Do you want to save them?')) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
setSelectedPage(page.slug);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedPage === page.slug
|
||||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0"> {/* Add min-w-0 for text overflow */}
|
||||
<p className="font-medium text-sm truncate" title={t(`legal.${page.slug}`)}>
|
||||
{t(`legal.${page.slug}`)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 truncate">/{page.slug}</p>
|
||||
</div>
|
||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Alternative - Responsive Layout:
|
||||
```typescript
|
||||
// File: frontend/src/pages/admin/CMSPageEnhanced.tsx
|
||||
// Alternative: Use responsive text sizing
|
||||
|
||||
<p className="font-medium text-sm sm:text-base truncate" title={t(`legal.${page.slug}`)}>
|
||||
{/* For very long German words, show abbreviated version */}
|
||||
{t(`legal.${page.slug}`).length > 15
|
||||
? `${t(`legal.${page.slug}`).substring(0, 12)}...`
|
||||
: t(`legal.${page.slug}`)
|
||||
}
|
||||
</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Event Creation Date Format Fix
|
||||
|
||||
**Problem:** Event creation page uses browser English format instead of saved admin settings date format.
|
||||
|
||||
**Current State:**
|
||||
- Files: `frontend/src/pages/admin/CreateEventPageEnhanced.tsx`, `CreateEventPage.tsx`
|
||||
- Uses browser locale instead of admin date format settings
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### Hook Enhancement:
|
||||
```typescript
|
||||
// File: frontend/src/hooks/useLocalizedDate.ts
|
||||
// Add admin settings integration:
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS, enGB } from 'date-fns/locale';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { settingsService } from '../services/settings.service';
|
||||
|
||||
export const useLocalizedDate = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Fetch admin date format settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-date-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
staleTime: 10 * 60 * 1000, // Cache for 10 minutes
|
||||
});
|
||||
|
||||
const getLocale = () => {
|
||||
// Use admin settings if available, otherwise fall back to i18n language
|
||||
const savedFormat = settings?.general_date_format;
|
||||
if (savedFormat?.locale) {
|
||||
switch (savedFormat.locale) {
|
||||
case 'en-US': return enUS;
|
||||
case 'en-GB': return enGB;
|
||||
case 'de': return de;
|
||||
default: return i18n.language === 'de' ? de : enUS;
|
||||
}
|
||||
}
|
||||
return i18n.language === 'de' ? de : enUS;
|
||||
};
|
||||
|
||||
const getDateFormat = () => {
|
||||
const savedFormat = settings?.general_date_format?.format;
|
||||
if (savedFormat) {
|
||||
// Convert admin format to date-fns format
|
||||
switch (savedFormat) {
|
||||
case 'DD/MM/YYYY': return 'dd/MM/yyyy';
|
||||
case 'MM/DD/YYYY': return 'MM/dd/yyyy';
|
||||
case 'YYYY-MM-DD': return 'yyyy-MM-dd';
|
||||
case 'DD.MM.YYYY': return 'dd.MM.yyyy';
|
||||
default: return 'dd/MM/yyyy';
|
||||
}
|
||||
}
|
||||
return i18n.language === 'de' ? 'dd.MM.yyyy' : 'MM/dd/yyyy';
|
||||
};
|
||||
|
||||
const format = (date: Date | string, formatStr?: string) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const finalFormat = formatStr || getDateFormat();
|
||||
return dateFnsFormat(dateObj, finalFormat, { locale: getLocale() });
|
||||
};
|
||||
|
||||
// ... rest of the hook
|
||||
};
|
||||
```
|
||||
|
||||
### Event Creation Page Fix:
|
||||
```typescript
|
||||
// File: frontend/src/pages/admin/CreateEventPageEnhanced.tsx
|
||||
// Update the expiration date display (around line 495):
|
||||
|
||||
{formData.event_date && (
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP')}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Language Selector Country Flags Chrome Fix
|
||||
|
||||
**Problem:** Country flags not showing in Chrome browser on Windows in admin language selector.
|
||||
|
||||
**Current State:**
|
||||
- File: `frontend/src/components/common/LanguageSelector.tsx` (lines 5-8)
|
||||
- Uses emoji flags: `🇬🇧`, `🇩🇪`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
### SVG Icon Replacement:
|
||||
```typescript
|
||||
// File: frontend/src/components/common/LanguageSelector.tsx
|
||||
// Replace emoji flags with SVG icons or image flags:
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
// SVG flag components for better browser compatibility
|
||||
const FlagGB: React.FC<{ className?: string }> = ({ className = "w-4 h-4" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" fill="none">
|
||||
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0h75z"/>
|
||||
<path fill="#C8102E" d="m424 281 216 159v40L369 281h55zm-184 20 6 35L54 480H0l246-179zM640 0v3L391 191l2-44L590 0h50zM0 0l239 176h-60L0 42V0z"/>
|
||||
<path fill="#FFF" d="M241 0v480h160V0H241zM0 160v160h640V160H0z"/>
|
||||
<path fill="#C8102E" d="M0 193v96h640v-96H0zM273 0v480h96V0h-96z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FlagDE: React.FC<{ className?: string }> = ({ className = "w-4 h-4" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" fill="none">
|
||||
<path fill="#ffce00" d="M0 320h640v160H0z"/>
|
||||
<path d="M0 0h640v160H0z"/>
|
||||
<path fill="#d00" d="M0 160h640v160H0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', flag: FlagGB },
|
||||
{ code: 'de', name: 'Deutsch', flag: FlagDE },
|
||||
];
|
||||
|
||||
export const LanguageSelector: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0];
|
||||
|
||||
const handleLanguageChange = (languageCode: string) => {
|
||||
i18n.changeLanguage(languageCode);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<currentLanguage.flag className="w-4 h-4" />
|
||||
<span>{currentLanguage.name}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
|
||||
{languages.map((language) => (
|
||||
<button
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
|
||||
language.code === i18n.language
|
||||
? 'text-primary-600 bg-primary-50'
|
||||
: 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<language.flag className="w-4 h-4" />
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Alternative - Image Flags:
|
||||
```typescript
|
||||
// Alternative solution using flag images:
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', flag: '/flags/gb.svg' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '/flags/de.svg' },
|
||||
];
|
||||
|
||||
// Add images to public/flags/ directory
|
||||
// Use: <img src={language.flag} alt={language.name} className="w-4 h-4" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Testing Instructions
|
||||
|
||||
### After implementing each fix:
|
||||
|
||||
1. **Password Complexity**: Test different complexity levels in admin settings
|
||||
2. **Gallery Login**: Verify event date is hidden on gallery login pages
|
||||
3. **Analytics Check**: Verify "Not Configured" message appears/disappears correctly
|
||||
4. **Analytics Numbers**: Compare dashboard vs analytics page numbers
|
||||
5. **Translations**: Check recent activities display correct translations
|
||||
6. **CMS Formatting**: Test with long German page names
|
||||
7. **Date Format**: Test event creation with different admin date settings
|
||||
8. **Language Flags**: Test language selector in Chrome on Windows
|
||||
|
||||
### Regression Testing:
|
||||
- [ ] Gallery login still works correctly
|
||||
- [ ] Analytics page displays correctly when Umami is configured
|
||||
- [ ] Admin settings save and load correctly
|
||||
- [ ] Event creation works with all date formats
|
||||
- [ ] Language switching works in all browsers
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- All changes maintain backward compatibility
|
||||
- No database schema changes required
|
||||
- Frontend changes are non-breaking
|
||||
- Can be deployed incrementally
|
||||
- All text is properly internationalized
|
||||
|
||||
**⚠️ Important**: Test each fix in isolation before combining, especially the analytics changes as they affect production data display.
|
||||
Binary file not shown.
@@ -0,0 +1,161 @@
|
||||
# Security Logging Documentation
|
||||
|
||||
## Overview
|
||||
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
|
||||
|
||||
## Log Files
|
||||
|
||||
### 1. **security.log**
|
||||
- Location: `logs/security.log`
|
||||
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
|
||||
- Max Size: 20MB with rotation (keeps 10 files)
|
||||
- Format: JSON with timestamp
|
||||
|
||||
### 2. **error.log**
|
||||
- Location: `logs/error.log`
|
||||
- Contains: All error-level logs including auth failures
|
||||
- Max Size: 10MB with rotation (keeps 5 files)
|
||||
|
||||
### 3. **combined.log**
|
||||
- Location: `logs/combined.log`
|
||||
- Contains: All logs (info, warn, error)
|
||||
- Max Size: 50MB with rotation (keeps 10 files)
|
||||
|
||||
## Security Events Logged
|
||||
|
||||
### Rate Limiting
|
||||
When rate limits are exceeded, the following is logged:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "Rate limit exceeded",
|
||||
"security": true,
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/login",
|
||||
"method": "POST",
|
||||
"authenticated": false,
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"referer": "https://app.example.com",
|
||||
"origin": "https://app.example.com",
|
||||
"headers": {
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"x-real-ip": "192.168.1.1"
|
||||
},
|
||||
"requestUrl": "/api/admin/login",
|
||||
"rateLimitInfo": {
|
||||
"limit": 5,
|
||||
"current": 6,
|
||||
"remaining": 0,
|
||||
"resetTime": "2024-01-18T14:38:45.123Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
#### Admin Login Failures
|
||||
- Tracked in `login_attempts` table
|
||||
- Logged with: IP address, username, user agent, timestamp
|
||||
- Account lockout after 5 failures in 15 minutes
|
||||
|
||||
#### Gallery Password Failures
|
||||
- Tracked in `access_logs` table with action='login_fail'
|
||||
- Logged with: event_id, IP address, user agent
|
||||
- Gallery lockout after 5 failures in 15 minutes
|
||||
|
||||
### JWT Validation Failures
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "JWT validation failed",
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/events",
|
||||
"method": "GET",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"error": "TokenExpiredError",
|
||||
"message": "jwt expired"
|
||||
}
|
||||
```
|
||||
|
||||
### Suspicious Activity
|
||||
- Multiple IPs attempting login for same account
|
||||
- Token usage from different IP than issued
|
||||
- Token usage after password change
|
||||
- Revoked token usage attempts
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
All rate limiting settings are configurable via the admin panel:
|
||||
|
||||
| Setting | Default | Range | Description |
|
||||
|---------|---------|-------|-------------|
|
||||
| rate_limit_enabled | true | - | Enable/disable rate limiting |
|
||||
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
|
||||
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
|
||||
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
|
||||
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
|
||||
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
|
||||
|
||||
## Database Tables
|
||||
|
||||
### login_attempts
|
||||
```sql
|
||||
- id
|
||||
- username
|
||||
- ip_address
|
||||
- user_agent
|
||||
- success (boolean)
|
||||
- created_at
|
||||
```
|
||||
|
||||
### access_logs
|
||||
```sql
|
||||
- id
|
||||
- event_id
|
||||
- ip_address
|
||||
- user_agent
|
||||
- action ('view', 'download', 'login_success', 'login_fail')
|
||||
- photo_id (nullable)
|
||||
- created_at
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `LOG_LEVEL`: Set logging level (default: 'info')
|
||||
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
|
||||
|
||||
## Monitoring Recommendations
|
||||
|
||||
1. **Set up alerts for:**
|
||||
- Rate limit exceeded events (possible DDoS)
|
||||
- Multiple failed login attempts from same IP
|
||||
- Account lockout events
|
||||
- JWT validation failures spike
|
||||
|
||||
2. **Regular review:**
|
||||
- Check security.log for patterns
|
||||
- Review login_attempts table for brute force attempts
|
||||
- Monitor access_logs for suspicious gallery access patterns
|
||||
|
||||
3. **Log analysis tools:**
|
||||
- Use log aggregation tools (ELK stack, Splunk)
|
||||
- Set up dashboards for security metrics
|
||||
- Configure alerts for threshold breaches
|
||||
|
||||
## Production Deployment Notes
|
||||
|
||||
1. Ensure logs directory has proper permissions
|
||||
2. Set up log rotation outside of application if needed
|
||||
3. Consider shipping logs to centralized logging service
|
||||
4. Monitor disk space for log files
|
||||
5. Set `LOG_TO_CONSOLE=true` for container deployments
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Never log sensitive data (passwords, tokens)
|
||||
2. Use generic error messages to prevent user enumeration
|
||||
3. Clean up old login attempts regularly (7 days retention)
|
||||
4. Monitor for unusual patterns in real-time
|
||||
5. Keep rate limit settings appropriate for your usage
|
||||
+4
-4
@@ -40,10 +40,10 @@ const config = {
|
||||
keepAliveInitialDelayMillis: 0
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
acquireTimeoutMillis: 30000,
|
||||
createTimeoutMillis: 30000,
|
||||
min: 5,
|
||||
max: 25,
|
||||
acquireTimeoutMillis: 60000,
|
||||
createTimeoutMillis: 60000,
|
||||
idleTimeoutMillis: 30000,
|
||||
reapIntervalMillis: 1000,
|
||||
createRetryIntervalMillis: 200,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add rate limit settings to app_settings
|
||||
const rateLimitSettings = [
|
||||
{
|
||||
setting_key: 'rate_limit_enabled',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_window_minutes',
|
||||
setting_value: JSON.stringify(15),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_max_requests',
|
||||
setting_value: JSON.stringify(1000),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_auth_max_requests',
|
||||
setting_value: JSON.stringify(5),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_skip_authenticated',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'security'
|
||||
},
|
||||
{
|
||||
setting_key: 'rate_limit_public_endpoints_only',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'security'
|
||||
}
|
||||
];
|
||||
|
||||
// Insert settings if they don't exist
|
||||
for (const setting of rateLimitSettings) {
|
||||
const exists = await knex('app_settings')
|
||||
.where('setting_key', setting.setting_key)
|
||||
.first();
|
||||
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove rate limit settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'rate_limit_enabled',
|
||||
'rate_limit_window_minutes',
|
||||
'rate_limit_max_requests',
|
||||
'rate_limit_auth_max_requests',
|
||||
'rate_limit_skip_authenticated',
|
||||
'rate_limit_public_endpoints_only'
|
||||
])
|
||||
.del();
|
||||
};
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.56",
|
||||
"version": "1.0.71",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.56",
|
||||
"version": "1.0.71",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.56",
|
||||
"version": "1.0.71",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "nodemon server.js",
|
||||
"migrate": "node migrations/run-migrations.js",
|
||||
"migrate:safe": "node migrations/run-migrations-safe.js",
|
||||
"fix-temp-photos": "node scripts/fix-temp-photos.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
require('dotenv').config({ path: '../.env' });
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../src/database/db');
|
||||
const { generatePhotoFilename } = require('../src/utils/filenameSanitizer');
|
||||
|
||||
async function fixTempPhotos() {
|
||||
console.log('Starting to fix temporary photo files...\n');
|
||||
|
||||
try {
|
||||
// Find all photos with temp_ filenames
|
||||
const tempPhotos = await db('photos')
|
||||
.where('filename', 'like', 'temp_%')
|
||||
.orderBy('event_id', 'asc')
|
||||
.orderBy('category_id', 'asc')
|
||||
.orderBy('id', 'asc');
|
||||
|
||||
console.log(`Found ${tempPhotos.length} photos with temporary filenames\n`);
|
||||
|
||||
if (tempPhotos.length === 0) {
|
||||
console.log('No temporary photos found. Exiting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group photos by event and category
|
||||
const grouped = {};
|
||||
for (const photo of tempPhotos) {
|
||||
const key = `${photo.event_id}_${photo.category_id || 'null'}`;
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(photo);
|
||||
}
|
||||
|
||||
console.log(`Processing ${Object.keys(grouped).length} event/category groups...\n`);
|
||||
|
||||
// Process each group
|
||||
for (const [key, photos] of Object.entries(grouped)) {
|
||||
const [eventId, categoryIdStr] = key.split('_');
|
||||
const categoryId = categoryIdStr === 'null' ? null : parseInt(categoryIdStr);
|
||||
|
||||
console.log(`\nProcessing Event ID: ${eventId}, Category ID: ${categoryId || 'uncategorized'}`);
|
||||
console.log(`Photos in group: ${photos.length}`);
|
||||
|
||||
// Get event details
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error(`Event ${eventId} not found! Skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get category details if applicable
|
||||
let category = null;
|
||||
let startCounter = 1;
|
||||
|
||||
if (categoryId) {
|
||||
category = await db('photo_categories').where({ id: categoryId }).first();
|
||||
if (!category) {
|
||||
console.error(`Category ${categoryId} not found! Treating as uncategorized...`);
|
||||
} else {
|
||||
// Get the highest counter for this category
|
||||
const maxPhoto = await db('photos')
|
||||
.where({ event_id: eventId, category_id: categoryId })
|
||||
.whereNot('filename', 'like', 'temp_%')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
if (maxPhoto && maxPhoto.filename) {
|
||||
// Extract counter from filename
|
||||
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
|
||||
if (match) {
|
||||
startCounter = parseInt(match[1]) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For uncategorized, get the highest counter
|
||||
const maxPhoto = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereNull('category_id')
|
||||
.whereNot('filename', 'like', 'temp_%')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
if (maxPhoto && maxPhoto.filename) {
|
||||
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
|
||||
if (match) {
|
||||
startCounter = parseInt(match[1]) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Starting counter: ${startCounter}`);
|
||||
|
||||
// Process each photo in the group
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const counter = startCounter + i;
|
||||
|
||||
try {
|
||||
// Generate new filename
|
||||
const extension = path.extname(photo.filename);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
category ? category.name : 'uncategorized',
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
|
||||
// Build full paths
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const oldPath = path.join(storagePath, 'events/active', photo.path);
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
|
||||
// Check if old file exists
|
||||
try {
|
||||
await fs.access(oldPath);
|
||||
} catch (e) {
|
||||
console.error(`File not found: ${oldPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
await fs.rename(oldPath, newPath);
|
||||
|
||||
// Update database
|
||||
const newRelativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
filename: newFilename,
|
||||
path: newRelativePath
|
||||
});
|
||||
|
||||
console.log(`✓ Renamed: ${photo.filename} → ${newFilename}`);
|
||||
successCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to process photo ${photo.id}: ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update category counter if needed
|
||||
if (category && successCount > 0) {
|
||||
const newCounter = startCounter + photos.length - 1;
|
||||
await db('photo_categories')
|
||||
.where({ id: categoryId })
|
||||
.update({ photo_counter: newCounter });
|
||||
console.log(`Updated category counter to ${newCounter}`);
|
||||
}
|
||||
|
||||
console.log(`\nGroup summary: ${successCount} successful, ${errorCount} errors`);
|
||||
}
|
||||
|
||||
console.log('\n=== COMPLETE ===');
|
||||
console.log('All temporary photos have been processed.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
fixTempPhotos().catch(console.error);
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Test script to verify CMS and email formatting improvements
|
||||
*/
|
||||
|
||||
const { formatWelcomeMessage, nl2br } = require('../src/utils/formatters');
|
||||
|
||||
console.log('Testing CMS and Email Formatting Improvements\n');
|
||||
|
||||
// Test 1: Basic line break conversion
|
||||
console.log('Test 1: Basic line break conversion');
|
||||
const basicText = `Hello,
|
||||
This is line 1.
|
||||
This is line 2.
|
||||
|
||||
This is line 4 with an extra break.`;
|
||||
|
||||
console.log('Input:');
|
||||
console.log(basicText);
|
||||
console.log('\nOutput (nl2br):');
|
||||
console.log(nl2br(basicText));
|
||||
console.log('\n---\n');
|
||||
|
||||
// Test 2: Welcome message formatting
|
||||
console.log('Test 2: Welcome message formatting');
|
||||
const welcomeMessage = `Dear guests,
|
||||
|
||||
We're so excited to share these special moments with you!
|
||||
|
||||
Please note:
|
||||
- Download your photos before the expiration date
|
||||
- The password is case-sensitive
|
||||
- Contact us if you have any issues
|
||||
|
||||
Thank you for being part of our special day!
|
||||
|
||||
Best regards,
|
||||
Sarah & John`;
|
||||
|
||||
console.log('Input:');
|
||||
console.log(welcomeMessage);
|
||||
console.log('\nOutput (formatWelcomeMessage):');
|
||||
console.log(formatWelcomeMessage(welcomeMessage));
|
||||
console.log('\n---\n');
|
||||
|
||||
// Test 3: Empty and edge cases
|
||||
console.log('Test 3: Edge cases');
|
||||
console.log('Empty string:', formatWelcomeMessage(''));
|
||||
console.log('Null:', formatWelcomeMessage(null));
|
||||
console.log('Only spaces:', formatWelcomeMessage(' \n \n '));
|
||||
console.log('Single line:', formatWelcomeMessage('This is a single line message'));
|
||||
|
||||
console.log('\nAll tests completed!');
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script to verify security logging is working correctly
|
||||
* Run with: node scripts/test-security-logging.js
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '../.env' });
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
console.log('Testing Security Logging...\n');
|
||||
|
||||
// Test 1: Basic logging
|
||||
console.log('1. Testing basic logging levels:');
|
||||
logger.info('Test info message', { test: true });
|
||||
logger.warn('Test warning message', { test: true });
|
||||
logger.error('Test error message', { test: true });
|
||||
|
||||
// Test 2: Security event logging
|
||||
console.log('\n2. Testing security event logging:');
|
||||
|
||||
// Rate limit exceeded
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: '192.168.1.100',
|
||||
path: '/api/admin/login',
|
||||
method: 'POST',
|
||||
authenticated: false,
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
timestamp: new Date().toISOString(),
|
||||
rateLimitInfo: {
|
||||
limit: 5,
|
||||
current: 6,
|
||||
remaining: 0,
|
||||
resetTime: new Date(Date.now() + 900000).toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Auth rate limit
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
ip: '192.168.1.101',
|
||||
path: '/api/auth/admin/login',
|
||||
method: 'POST',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
authType: 'admin',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Failed login
|
||||
logger.warn('Failed login attempt', {
|
||||
username: 'testuser',
|
||||
ip: '192.168.1.102',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
reason: 'invalid_credentials',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// JWT validation failure
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: '192.168.1.103',
|
||||
path: '/api/admin/events',
|
||||
method: 'GET',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
error: 'TokenExpiredError',
|
||||
message: 'jwt expired',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Account lockout
|
||||
logger.warn('Login attempt on locked account', {
|
||||
username: 'lockeduser',
|
||||
ip: '192.168.1.104',
|
||||
remainingLockTime: 1200,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Suspicious activity
|
||||
logger.warn('Suspicious login activity detected', {
|
||||
username: 'suspicioususer',
|
||||
ips: ['192.168.1.105', '192.168.1.106', '192.168.1.107'],
|
||||
timeWindow: '15 minutes',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log('\n3. Check log files:');
|
||||
console.log('- logs/security.log - Should contain all security warnings');
|
||||
console.log('- logs/error.log - Should contain error messages');
|
||||
console.log('- logs/combined.log - Should contain all messages');
|
||||
|
||||
console.log('\n✅ Security logging test complete!');
|
||||
console.log('Review the log files to ensure all events are properly captured.');
|
||||
|
||||
// Give logger time to flush
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
+61
-35
@@ -4,11 +4,17 @@ require('dotenv').config();
|
||||
const { validateEnvironment } = require('./src/config/validateEnv');
|
||||
validateEnvironment();
|
||||
|
||||
// Initialize logger early to capture startup logs
|
||||
const logger = require('./src/utils/logger');
|
||||
logger.info('Server starting up', {
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const path = require('path');
|
||||
const { initializeDatabase, db } = require('./src/database/db');
|
||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||
@@ -16,7 +22,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||
const logger = require('./src/utils/logger');
|
||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth-enhanced');
|
||||
@@ -93,37 +99,23 @@ const corsOptions = {
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Rate limiting with admin bypass
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
|
||||
skip: (req) => {
|
||||
// Skip rate limiting for authenticated admin users
|
||||
if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
|
||||
const token = req.headers.authorization.replace('Bearer ', '');
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return decoded.type === 'admin';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Also skip rate limiting for public settings endpoint in development
|
||||
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// Initialize rate limiters (they will be created dynamically)
|
||||
let generalRateLimiter;
|
||||
let authRateLimiter;
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5 // limit auth attempts
|
||||
});
|
||||
// Function to initialize rate limiters
|
||||
async function initializeRateLimiters() {
|
||||
generalRateLimiter = await createRateLimiter();
|
||||
authRateLimiter = await createAuthRateLimiter();
|
||||
|
||||
// Apply rate limiting
|
||||
app.use('/api/', generalRateLimiter);
|
||||
app.use('/api/auth', authRateLimiter);
|
||||
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
||||
app.use('/api/admin/auth/login', authRateLimiter);
|
||||
}
|
||||
|
||||
// Apply rate limiting - admin routes check will skip for valid admin tokens
|
||||
app.use('/api/', limiter);
|
||||
app.use('/api/auth', authLimiter);
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
|
||||
// Body parsing middleware with increased limits for large uploads
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
@@ -158,6 +150,28 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
|
||||
// Static file serving for uploads (public - logos, favicons)
|
||||
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
||||
|
||||
// Debug endpoint to check IP detection (only in development)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.get('/api/debug/ip', (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
res.json({
|
||||
detectedIp: clientIp,
|
||||
reqIp: req.ip,
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip'],
|
||||
'x-forwarded-proto': req.headers['x-forwarded-proto'],
|
||||
'x-forwarded-host': req.headers['x-forwarded-host']
|
||||
},
|
||||
trustProxy: app.get('trust proxy')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
@@ -203,9 +217,21 @@ async function startServer() {
|
||||
// Initialize database
|
||||
await initializeDatabase();
|
||||
|
||||
// Initialize auth security cleanup job
|
||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||
initializeCleanupJob();
|
||||
// Initialize rate limiters after database is ready
|
||||
await initializeRateLimiters();
|
||||
logger.info('Rate limiters initialized with database configuration');
|
||||
|
||||
// Initialize auth security cleanup job
|
||||
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
||||
initializeCleanupJob();
|
||||
|
||||
// Initialize temp upload cleanup job
|
||||
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
|
||||
// Run cleanup on startup
|
||||
cleanupTempUploads();
|
||||
// Schedule periodic cleanup every hour
|
||||
setInterval(cleanupTempUploads, 60 * 60 * 1000);
|
||||
logger.info('Temp upload cleanup scheduled');
|
||||
|
||||
// Start file watcher
|
||||
startFileWatcher();
|
||||
|
||||
@@ -34,7 +34,7 @@ function validateEnvironment() {
|
||||
if (name === 'JWT_SECRET' && value) {
|
||||
// Check for the insecure default value
|
||||
if (value === 'your-secret-key') {
|
||||
errors.push(`CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.`);
|
||||
errors.push('CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.');
|
||||
}
|
||||
|
||||
// Check minimum length (should be at least 32 characters for security)
|
||||
|
||||
@@ -4,6 +4,33 @@ const knexConfig = require('../../knexfile');
|
||||
// Create database connection with built-in retry logic
|
||||
const db = knex(knexConfig);
|
||||
|
||||
// Connection retry configuration
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
// Wrapper function to handle connection retries
|
||||
async function withRetry(queryFn, retries = MAX_RETRIES) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await queryFn();
|
||||
} catch (error) {
|
||||
const isConnectionError = error.message && (
|
||||
error.message.includes('Connection terminated unexpectedly') ||
|
||||
error.message.includes('Connection ended unexpectedly') ||
|
||||
error.message.includes('ECONNREFUSED') ||
|
||||
error.message.includes('ETIMEDOUT')
|
||||
);
|
||||
|
||||
if (isConnectionError && i < retries - 1) {
|
||||
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDatabase() {
|
||||
// Events table
|
||||
const hasEventsTable = await db.schema.hasTable('events');
|
||||
@@ -59,9 +86,9 @@ async function initializeDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
|
||||
await db.raw(`DROP TABLE events`);
|
||||
await db.raw(`ALTER TABLE events_new RENAME TO events`);
|
||||
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
||||
await db.raw('DROP TABLE events');
|
||||
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||
} catch (error) {
|
||||
// If the migration fails, it might already have been applied
|
||||
console.log('Color theme migration may have already been applied');
|
||||
@@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { db, initializeDatabase, logActivity };
|
||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||
@@ -1,24 +1,83 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
logger.warn('Admin auth attempt without token', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (jwtError) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
error: jwtError.name,
|
||||
message: jwtError.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (jwtError.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
|
||||
|
||||
if (!admin) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('Admin auth failed - user not found or inactive', {
|
||||
ip: clientIp,
|
||||
userId: decoded.id,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
req.admin = admin;
|
||||
next();
|
||||
} catch (error) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.error('Admin auth middleware error', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
@@ -11,13 +11,15 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
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();
|
||||
const event = await withRetry(async () => {
|
||||
return 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' });
|
||||
|
||||
@@ -10,7 +10,7 @@ const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
|
||||
// Cache for session timeout setting
|
||||
let cachedTimeout = null;
|
||||
let cacheExpiry = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
|
||||
|
||||
// Clean up expired sessions every 5 minutes
|
||||
setInterval(() => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Validate uploaded file is complete and not corrupted
|
||||
*/
|
||||
async function validateUploadedFile(filePath) {
|
||||
try {
|
||||
// Check file exists and has size
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// For image files, verify they can be read by Sharp
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
if (imageExtensions.includes(ext)) {
|
||||
// Try to read metadata - this will fail if image is corrupted
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(filePath, {
|
||||
failOnError: false, // Don't fail on recoverable errors
|
||||
limitInputPixels: 268402689 // ~16k x 16k max
|
||||
}).metadata();
|
||||
} catch (metadataError) {
|
||||
// If metadata reading fails, the file is likely incomplete
|
||||
throw new Error(`Invalid image file: ${metadataError.message}`);
|
||||
}
|
||||
|
||||
if (!metadata || !metadata.width || !metadata.height) {
|
||||
throw new Error('Invalid image dimensions - file may be incomplete');
|
||||
}
|
||||
|
||||
// Check for reasonable dimensions
|
||||
if (metadata.width < 10 || metadata.height < 10) {
|
||||
throw new Error('Image dimensions too small');
|
||||
}
|
||||
|
||||
// Additional check: verify we can actually decode a small portion of the image
|
||||
try {
|
||||
await sharp(filePath, {
|
||||
failOnError: false,
|
||||
limitInputPixels: 268402689
|
||||
})
|
||||
.resize(10, 10) // Try to resize to very small size
|
||||
.toBuffer();
|
||||
} catch (decodeError) {
|
||||
throw new Error(`Image decode failed - file may be corrupted: ${decodeError.message}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`File validation failed for ${filePath}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate uploaded files after multer processing
|
||||
*/
|
||||
async function validateUploadedFiles(req, res, next) {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const validFiles = [];
|
||||
const invalidFiles = [];
|
||||
|
||||
// Validate each file
|
||||
for (const file of req.files) {
|
||||
try {
|
||||
await validateUploadedFile(file.path);
|
||||
validFiles.push(file);
|
||||
} catch (error) {
|
||||
logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`);
|
||||
invalidFiles.push({
|
||||
filename: file.originalname,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
// Delete the invalid file
|
||||
try {
|
||||
await fs.unlink(file.path);
|
||||
} catch (unlinkErr) {
|
||||
logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update req.files to only include valid files
|
||||
req.files = validFiles;
|
||||
|
||||
// Store invalid files info for response
|
||||
if (invalidFiles.length > 0) {
|
||||
req.invalidFiles = invalidFiles;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateUploadedFile,
|
||||
validateUploadedFiles
|
||||
};
|
||||
@@ -251,7 +251,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
||||
} catch (statError) {
|
||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||
console.error(`Entry name was: ${entry.entryName}`);
|
||||
console.error(`Error:`, statError.message);
|
||||
console.error('Error:', statError.message);
|
||||
// Skip this file if we can't stat it
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get total downloads (last 30 days)
|
||||
// Get total downloads (last 30 days) - include both single and bulk downloads
|
||||
const totalDownloads = await db('access_logs')
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
.first();
|
||||
@@ -73,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
.first();
|
||||
|
||||
const previousDownloads = await db('access_logs')
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
|
||||
.where('timestamp', '<', thirtyDaysAgo.toISOString())
|
||||
.count('id as count')
|
||||
@@ -243,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
// Get downloads per day
|
||||
// Get downloads per day - include both single and bulk downloads
|
||||
const downloadsData = await db('access_logs')
|
||||
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
|
||||
.where('action', 'download')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
@@ -272,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
||||
if (dateObj) dateObj.uniqueVisitors = row.count;
|
||||
});
|
||||
|
||||
// Get top galleries by views
|
||||
// Get top galleries by views with additional metrics
|
||||
const topGalleries = await db('access_logs')
|
||||
.select('events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(*) as views'))
|
||||
.select('events.id', 'events.event_name', 'events.slug')
|
||||
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
|
||||
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
|
||||
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
|
||||
.join('events', 'access_logs.event_id', 'events.id')
|
||||
.where('access_logs.action', 'view')
|
||||
.where('access_logs.timestamp', '>=', startDateStr)
|
||||
.groupBy('events.id')
|
||||
.groupBy('events.id', 'events.event_name', 'events.slug')
|
||||
.orderBy('views', 'desc')
|
||||
.limit(5);
|
||||
|
||||
@@ -309,10 +310,33 @@ router.get('/analytics', adminAuth, async (req, res) => {
|
||||
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
||||
});
|
||||
|
||||
// Calculate totals for the period (matching /stats logic)
|
||||
const totalViews = await db('access_logs')
|
||||
.where('action', 'view')
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalDownloadsCount = await db('access_logs')
|
||||
.whereIn('action', ['download', 'download_all'])
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const totalUniqueVisitors = await db('access_logs')
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.countDistinct('ip_address as count')
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
chartData: dates,
|
||||
topGalleries,
|
||||
devices
|
||||
devices,
|
||||
totals: {
|
||||
views: totalViews?.count || 0,
|
||||
downloads: totalDownloadsCount?.count || 0,
|
||||
uniqueVisitors: totalUniqueVisitors?.count || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Analytics error:', error);
|
||||
|
||||
@@ -210,15 +210,15 @@ router.get('/templates', adminAuth, async (req, res) => {
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const path = require('path');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
@@ -89,7 +89,14 @@ router.post('/', adminAuth, [
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
let expires_at;
|
||||
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(event_date);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
@@ -128,8 +135,7 @@ router.post('/', adminAuth, [
|
||||
);
|
||||
|
||||
// Queue creation email
|
||||
// Determine language based on email domain
|
||||
const emailLang = host_email.endsWith('.de') ? 'de' : 'en';
|
||||
// Language detection is handled by email processor
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
@@ -138,10 +144,10 @@ router.post('/', adminAuth, [
|
||||
email_data: JSON.stringify({
|
||||
host_name: host_name,
|
||||
event_name,
|
||||
event_date: await formatDate(event_date, emailLang),
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
expiry_date: await formatDate(expires_at, emailLang),
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
@@ -546,10 +552,10 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_email.split('@')[0],
|
||||
event_name: event.event_name,
|
||||
event_date: new Date(event.event_date).toLocaleDateString(),
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: newPassword,
|
||||
expiry_date: new Date(event.expires_at).toLocaleDateString()
|
||||
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||
});
|
||||
}
|
||||
|
||||
@@ -578,41 +584,37 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Get the language preference
|
||||
let language = 'en';
|
||||
try {
|
||||
// First check app_settings for general_default_language
|
||||
const langSetting = await db('app_settings')
|
||||
.where('setting_key', 'general_default_language')
|
||||
.first();
|
||||
|
||||
if (langSetting && langSetting.setting_value) {
|
||||
language = langSetting.setting_value;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch language setting:', err);
|
||||
// The email processor will determine the language based on:
|
||||
// 1. Event language setting
|
||||
// 2. App settings general_default_language
|
||||
// 3. Email config default language
|
||||
// 4. Domain-based detection
|
||||
// So we don't need to determine it here
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
let galleryPassword = req.body.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
if (!galleryPassword) {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
}
|
||||
|
||||
// Format dates based on language
|
||||
const eventDate = new Date(event.event_date);
|
||||
const expiryDate = new Date(event.expires_at);
|
||||
const dateLocale = language === 'de' ? 'de-DE' : 'en-US';
|
||||
|
||||
// Prepare password text based on language
|
||||
const passwordText = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
event_name: event.event_name,
|
||||
event_date: eventDate.toLocaleDateString(dateLocale),
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: passwordText,
|
||||
expiry_date: expiryDate.toLocaleDateString(dateLocale),
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id
|
||||
eventId: id,
|
||||
isResend: true // Flag to indicate this is a resend
|
||||
});
|
||||
|
||||
// Log the activity using the proper schema
|
||||
|
||||
+233
-107
@@ -4,55 +4,41 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for file uploads
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
destination: (req, file, cb) => {
|
||||
console.log('Multer destination called for file:', file.originalname);
|
||||
const { eventId } = req.params;
|
||||
|
||||
try {
|
||||
// Get event details
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found in multer destination:', eventId);
|
||||
return cb(new Error('Event not found'));
|
||||
}
|
||||
|
||||
// Store event in request for use in filename generation
|
||||
req.eventData = event;
|
||||
|
||||
// Create destination path - now just event folder, no type subfolder
|
||||
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
console.log('Destination path:', destPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
|
||||
cb(null, destPath);
|
||||
} catch (error) {
|
||||
console.error('Error in multer destination:', error);
|
||||
cb(error);
|
||||
}
|
||||
// We'll validate the event exists in the route handler
|
||||
// For now, just create a temp destination
|
||||
const tempPath = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`);
|
||||
|
||||
// Create directory synchronously
|
||||
require('fs').mkdirSync(tempPath, { recursive: true });
|
||||
console.log('Temp destination path:', tempPath);
|
||||
|
||||
// Store temp path for cleanup
|
||||
req.tempUploadPath = tempPath;
|
||||
|
||||
cb(null, tempPath);
|
||||
},
|
||||
filename: async (req, file, cb) => {
|
||||
filename: (req, file, cb) => {
|
||||
console.log('Multer filename called for file:', file.originalname);
|
||||
try {
|
||||
// Use temporary filename for now, will rename after getting category info
|
||||
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
|
||||
console.log('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
} catch (error) {
|
||||
console.error('Error in multer filename:', error);
|
||||
cb(error);
|
||||
}
|
||||
// Use a simple temporary filename
|
||||
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
|
||||
console.log('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,6 +51,9 @@ const upload = multer({
|
||||
files: 500, // Maximum 500 files
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
parts: 10000, // Maximum number of parts (fields + files)
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images only with proper validation
|
||||
@@ -75,7 +64,9 @@ const upload = multer({
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
|
||||
}
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
@@ -87,9 +78,29 @@ const validateUploadContent = createFileUploadValidator({
|
||||
validateContent: true
|
||||
});
|
||||
|
||||
// Request timeout middleware for uploads
|
||||
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
return (req, res, next) => {
|
||||
// Set timeout for the request
|
||||
req.setTimeout(timeout, () => {
|
||||
console.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set response timeout as well
|
||||
res.setTimeout(timeout, () => {
|
||||
console.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Upload photos for an event
|
||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
||||
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
||||
upload.array('photos', 500)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
@@ -106,7 +117,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, validateUploadContent, async (req, res) => {
|
||||
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
@@ -121,12 +132,28 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found:', eventId);
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
@@ -138,15 +165,27 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
if (parsedCategoryId) {
|
||||
category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
||||
if (!category) {
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({ error: 'Invalid category' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create final destination directory
|
||||
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
await fs.mkdir(finalDestPath, { recursive: true });
|
||||
|
||||
const uploadedPhotos = [];
|
||||
const errors = [];
|
||||
|
||||
// Process files in batches to optimize database operations
|
||||
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
|
||||
const BATCH_SIZE = 25; // Increased batch size for better performance with large uploads
|
||||
|
||||
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
|
||||
const batch = req.files.slice(i, i + BATCH_SIZE);
|
||||
@@ -169,16 +208,25 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
.whereNull('category_id')
|
||||
.count('id as count')
|
||||
.first();
|
||||
batchCounter = (uncategorizedCount.count || 0) + 1;
|
||||
batchCounter = (parseInt(uncategorizedCount.count) || 0) + 1;
|
||||
}
|
||||
|
||||
const batchPhotos = [];
|
||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
||||
|
||||
// First pass: prepare data and move files from temp to final location
|
||||
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||
const file = batch[fileIndex];
|
||||
const counter = batchCounter + fileIndex;
|
||||
const tempPath = file.path; // Original temp path
|
||||
|
||||
try {
|
||||
// Verify file is complete before processing
|
||||
const tempStats = await fs.stat(tempPath);
|
||||
if (tempStats.size === 0) {
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
@@ -188,77 +236,142 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
extension
|
||||
);
|
||||
|
||||
// Rename the file
|
||||
const oldPath = file.path;
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
await fs.rename(oldPath, newPath);
|
||||
|
||||
// Update file object
|
||||
file.filename = newFilename;
|
||||
file.path = newPath;
|
||||
|
||||
// Generate thumbnail with new filename
|
||||
const thumbnailPath = await generateThumbnail(file.path);
|
||||
|
||||
// Calculate relative paths
|
||||
// Calculate final path
|
||||
const finalPath = path.join(finalDestPath, newFilename);
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
|
||||
const relativeThumbPath = thumbnailPath;
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
batchPhotos.push({
|
||||
event_id: eventId,
|
||||
filename: file.filename,
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
category_id: parsedCategoryId || null,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null,
|
||||
type: 'individual',
|
||||
size_bytes: file.size
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
|
||||
// Store move operation for later
|
||||
fileRenameOperations.push({
|
||||
tempPath: tempPath,
|
||||
finalPath: finalPath,
|
||||
filename: newFilename,
|
||||
photoData: photoData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${file.originalname}:`, error);
|
||||
console.error(`Error preparing file ${file.originalname}:`, error);
|
||||
errors.push({ filename: file.originalname, error: error.message });
|
||||
// Delete the file if it was partially processed
|
||||
if (file.path) {
|
||||
try { await fs.unlink(file.path); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert all photos from this batch
|
||||
// Insert all photos in this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with category_id: ${parsedCategoryId}`);
|
||||
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
// Update category counter if needed
|
||||
if (category) {
|
||||
if (category && parsedCategoryId) {
|
||||
const newCounter = batchCounter + batchPhotos.length - 1;
|
||||
await trx('photo_categories')
|
||||
.where({ id: parsedCategoryId })
|
||||
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
|
||||
.update({ photo_counter: newCounter });
|
||||
console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`);
|
||||
}
|
||||
|
||||
// Add to uploaded photos array
|
||||
batchPhotos.forEach((photo, index) => {
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[index]?.id || insertedIds[index],
|
||||
filename: photo.filename,
|
||||
size: photo.size_bytes,
|
||||
category_id: photo.category_id
|
||||
});
|
||||
});
|
||||
// Commit the transaction first
|
||||
await trx.commit();
|
||||
console.log(`Successfully committed batch of ${batchPhotos.length} photos`);
|
||||
|
||||
// Now move files from temp to final location after successful commit
|
||||
for (let idx = 0; idx < fileRenameOperations.length; idx++) {
|
||||
const operation = fileRenameOperations[idx];
|
||||
try {
|
||||
// Move the file from temp to final location
|
||||
await fs.rename(operation.tempPath, operation.finalPath);
|
||||
console.log(`Moved file from ${operation.tempPath} to ${operation.finalPath}`);
|
||||
|
||||
// Verify the file was moved successfully
|
||||
const finalStats = await fs.stat(operation.finalPath);
|
||||
if (finalStats.size !== operation.photoData.size_bytes) {
|
||||
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[idx]?.id || insertedIds[idx],
|
||||
filename: operation.filename,
|
||||
size: operation.photoData.size_bytes,
|
||||
category_id: operation.photoData.category_id
|
||||
});
|
||||
} catch (moveError) {
|
||||
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
|
||||
errors.push({
|
||||
filename: operation.filename,
|
||||
error: `File move failed: ${moveError.message}`
|
||||
});
|
||||
|
||||
// Try to clean up the database entry if file move failed
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
try {
|
||||
await db('photos').where({ id: photoId }).delete();
|
||||
console.log(`Cleaned up database entry for failed photo ${photoId}`);
|
||||
} catch (cleanupError) {
|
||||
console.error(`Failed to clean up database entry:`, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No photos to insert, just rollback
|
||||
await trx.rollback();
|
||||
}
|
||||
|
||||
// Commit the batch transaction
|
||||
await trx.commit();
|
||||
} catch (error) {
|
||||
console.error(`Error processing batch starting at index ${i}:`, error);
|
||||
await trx.rollback();
|
||||
console.error('Stack trace:', error.stack);
|
||||
|
||||
// Try to clean up files from failed batch
|
||||
for (const file of batch) {
|
||||
if (file.path) {
|
||||
try { await fs.unlink(file.path); } catch (e) {}
|
||||
}
|
||||
// Rollback if not already committed
|
||||
if (!trx.isCompleted()) {
|
||||
await trx.rollback();
|
||||
}
|
||||
|
||||
// Add all files in this batch to errors
|
||||
for (const file of batch) {
|
||||
errors.push({
|
||||
filename: file.originalname,
|
||||
error: `Batch processing failed: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp upload directory
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp upload directory: ${req.tempUploadPath}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,24 +382,39 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Include any files that were invalid from the validation middleware
|
||||
const totalInvalidFiles = (req.invalidFiles || []).concat(errors);
|
||||
|
||||
// Prepare response
|
||||
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||
const response = {
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||
photos: uploadedPhotos,
|
||||
totalFiles: req.files.length,
|
||||
totalFiles: totalAttempted,
|
||||
successCount: uploadedPhotos.length,
|
||||
failureCount: errors.length
|
||||
failureCount: totalInvalidFiles.length
|
||||
};
|
||||
|
||||
// Include error details if any files failed
|
||||
if (errors.length > 0) {
|
||||
response.errors = errors;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
|
||||
if (totalInvalidFiles.length > 0) {
|
||||
response.errors = totalInvalidFiles;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photos:', error);
|
||||
|
||||
// Clean up temp upload directory on error
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp upload directory after error: ${req.tempUploadPath}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
}
|
||||
});
|
||||
@@ -613,26 +741,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
if (!photo) {
|
||||
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const filePath = path.join(storagePath, photo.thumbnail_path);
|
||||
|
||||
console.log(`Attempting to serve thumbnail: ${filePath}`);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (error) {
|
||||
console.error(`Thumbnail file not found: ${filePath}`, error);
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
const filePath = path.join(storagePath, thumbnailPath);
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
|
||||
res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for logo uploads
|
||||
@@ -495,6 +496,43 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update analytics settings
|
||||
router.put('/analytics', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'analytics',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'analytics_settings_updated',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||
});
|
||||
|
||||
res.json({ message: 'Analytics settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Analytics settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update analytics settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get storage info
|
||||
router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -582,4 +620,68 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Update rate limit settings
|
||||
router.put('/security/rate-limit', adminAuth, [
|
||||
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||
body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'),
|
||||
body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'),
|
||||
body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
rate_limit_enabled,
|
||||
rate_limit_window_minutes,
|
||||
rate_limit_max_requests,
|
||||
rate_limit_auth_max_requests,
|
||||
rate_limit_skip_authenticated,
|
||||
rate_limit_public_endpoints_only
|
||||
} = req.body;
|
||||
|
||||
// Update each setting
|
||||
const settings = [
|
||||
{ key: 'rate_limit_enabled', value: rate_limit_enabled },
|
||||
{ key: 'rate_limit_window_minutes', value: rate_limit_window_minutes },
|
||||
{ key: 'rate_limit_max_requests', value: rate_limit_max_requests },
|
||||
{ key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests },
|
||||
{ key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated },
|
||||
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
|
||||
];
|
||||
|
||||
for (const { key, value } of settings) {
|
||||
await db('app_settings')
|
||||
.where('setting_key', key)
|
||||
.update({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the rate limit settings cache to apply changes immediately
|
||||
clearSettingsCache();
|
||||
|
||||
// Log activity
|
||||
await logActivity('settings_updated',
|
||||
{
|
||||
category: 'security',
|
||||
subcategory: 'rate_limit',
|
||||
changes: settings.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Rate limit settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Rate limit settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update rate limit settings' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
@@ -87,10 +87,10 @@ router.post('/', adminAuth, [
|
||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
event_name,
|
||||
event_date: new Date(event_date).toLocaleDateString(),
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
expiry_date: expires_at.toLocaleDateString(),
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const router = express.Router();
|
||||
|
||||
// Get public settings (branding and theme)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// Fetch branding, theme, general, and select security settings
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
|
||||
.select('setting_key', 'setting_value');
|
||||
// Fetch branding, theme, general, and security settings
|
||||
// Note: We include analytics in the query but it might not exist yet
|
||||
const settings = await withRetry(async () => {
|
||||
return await db('app_settings')
|
||||
.where(function() {
|
||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
|
||||
.orWhere('setting_key', 'like', 'analytics_%');
|
||||
})
|
||||
.select('setting_key', 'setting_value');
|
||||
});
|
||||
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
@@ -41,7 +47,12 @@ router.get('/', async (req, res) => {
|
||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||
// Umami analytics configuration (only if enabled)
|
||||
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
|
||||
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
|
||||
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
|
||||
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -109,6 +109,10 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
@@ -118,6 +122,32 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Get branding settings for logo
|
||||
let logoUrl = '';
|
||||
@@ -156,10 +186,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
|
||||
// Process templates with variables
|
||||
subject = subjectTemplate(variables);
|
||||
htmlBody = htmlTemplate(variables);
|
||||
textBody = textTemplate(variables);
|
||||
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||
subject = subjectTemplate(processedVariables);
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
|
||||
@@ -60,6 +60,7 @@ async function processEmailQueue() {
|
||||
}
|
||||
|
||||
// Start email queue processor
|
||||
setInterval(processEmailQueue, 60000); // Process every minute
|
||||
// DISABLED: Using emailProcessor.js instead to prevent duplicate connections
|
||||
// setInterval(processEmailQueue, 60000); // Process every minute
|
||||
|
||||
module.exports = { sendEmail, processEmailQueue };
|
||||
|
||||
@@ -51,6 +51,13 @@ async function processNewPhoto(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
|
||||
// Skip temporary upload files
|
||||
const filename = path.basename(filePath);
|
||||
if (filename.startsWith('temp_')) {
|
||||
logger.debug(`Skipping temporary upload file: ${filename}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the event
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Configure sharp for better memory management with large batches
|
||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||
@@ -10,7 +11,7 @@ const THUMBNAIL_WIDTH = 300;
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||
|
||||
async function generateThumbnail(imagePath) {
|
||||
async function generateThumbnail(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
const thumbnailFilename = `thumb_${filename}`;
|
||||
const thumbnailDir = getThumbnailPath();
|
||||
@@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) {
|
||||
// Ensure thumbnail directory exists
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
|
||||
// Check if we need to regenerate (for broken thumbnails)
|
||||
if (options.regenerate) {
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
logger.info(`Deleted broken thumbnail: ${thumbnailPath}`);
|
||||
} catch (err) {
|
||||
// File might not exist, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate thumbnail with memory-efficient settings
|
||||
// First, verify the source image is complete and valid
|
||||
const metadata = await sharp(imagePath).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Invalid image metadata - file may be incomplete');
|
||||
}
|
||||
|
||||
// Generate thumbnail with memory-efficient settings and error handling
|
||||
await sharp(imagePath, {
|
||||
limitInputPixels: 268402689, // ~16k x 16k max
|
||||
sequentialRead: true // More memory efficient for large images
|
||||
sequentialRead: true, // More memory efficient for large images
|
||||
failOnError: false // Don't fail on minor issues
|
||||
})
|
||||
.resize(THUMBNAIL_WIDTH, null, {
|
||||
withoutEnlargement: true,
|
||||
@@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) {
|
||||
})
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
// Verify the thumbnail was created successfully
|
||||
const stats = await fs.stat(thumbnailPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Generated thumbnail is empty');
|
||||
}
|
||||
|
||||
return path.relative(getStoragePath(), thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for ${filename}:`, error);
|
||||
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
|
||||
|
||||
// Clean up any partially created file
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch (unlinkErr) {
|
||||
// Ignore unlink errors
|
||||
}
|
||||
|
||||
// Return null if thumbnail generation fails, don't fail the whole upload
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail };
|
||||
/**
|
||||
* Check if a thumbnail exists and is valid
|
||||
*/
|
||||
async function isThumbnailValid(thumbnailPath) {
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), thumbnailPath);
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
||||
// Check if file exists and has content
|
||||
if (stats.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to read metadata to ensure it's a valid image
|
||||
await sharp(fullPath).metadata();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate thumbnail if it's broken or missing
|
||||
*/
|
||||
async function ensureThumbnail(photo) {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
// Check if thumbnail exists and is valid
|
||||
if (photo.thumbnail_path) {
|
||||
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
||||
if (isValid) {
|
||||
return photo.thumbnail_path;
|
||||
}
|
||||
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
||||
}
|
||||
|
||||
// Generate new thumbnail
|
||||
const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||
|
||||
if (newThumbnailPath) {
|
||||
// Update database with new thumbnail path
|
||||
const { db } = require('../database/db');
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({ thumbnail_path: newThumbnailPath });
|
||||
|
||||
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
|
||||
return newThumbnailPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache for rate limit settings
|
||||
let settingsCache = null;
|
||||
let cacheExpiry = 0;
|
||||
const CACHE_DURATION = 60000; // 1 minute cache
|
||||
|
||||
/**
|
||||
* Get rate limit settings from database with caching
|
||||
*/
|
||||
async function getRateLimitSettings() {
|
||||
try {
|
||||
// Check cache
|
||||
if (settingsCache && Date.now() < cacheExpiry) {
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'rate_limit_enabled',
|
||||
'rate_limit_window_minutes',
|
||||
'rate_limit_max_requests',
|
||||
'rate_limit_auth_max_requests',
|
||||
'rate_limit_skip_authenticated',
|
||||
'rate_limit_public_endpoints_only'
|
||||
]);
|
||||
|
||||
// Parse settings into object
|
||||
const config = {
|
||||
enabled: true,
|
||||
windowMinutes: 15,
|
||||
maxRequests: 100,
|
||||
authMaxRequests: 5,
|
||||
skipAuthenticated: true,
|
||||
publicEndpointsOnly: false
|
||||
};
|
||||
|
||||
settings.forEach(setting => {
|
||||
const value = JSON.parse(setting.setting_value);
|
||||
switch (setting.setting_key) {
|
||||
case 'rate_limit_enabled':
|
||||
config.enabled = value;
|
||||
break;
|
||||
case 'rate_limit_window_minutes':
|
||||
config.windowMinutes = value;
|
||||
break;
|
||||
case 'rate_limit_max_requests':
|
||||
config.maxRequests = value;
|
||||
break;
|
||||
case 'rate_limit_auth_max_requests':
|
||||
config.authMaxRequests = value;
|
||||
break;
|
||||
case 'rate_limit_skip_authenticated':
|
||||
config.skipAuthenticated = value;
|
||||
break;
|
||||
case 'rate_limit_public_endpoints_only':
|
||||
config.publicEndpointsOnly = value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Update cache
|
||||
settingsCache = config;
|
||||
cacheExpiry = Date.now() + CACHE_DURATION;
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch rate limit settings:', error);
|
||||
// Return defaults on error
|
||||
return {
|
||||
enabled: true,
|
||||
windowMinutes: 15,
|
||||
maxRequests: 100,
|
||||
authMaxRequests: 5,
|
||||
skipAuthenticated: true,
|
||||
publicEndpointsOnly: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear settings cache (call when settings are updated)
|
||||
*/
|
||||
function clearSettingsCache() {
|
||||
settingsCache = null;
|
||||
cacheExpiry = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request has valid authentication
|
||||
*/
|
||||
function isAuthenticated(req) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check if token is valid
|
||||
if (!decoded || typeof decoded !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid token found - check type
|
||||
req.tokenType = decoded.type; // 'admin' or 'gallery'
|
||||
req.tokenPayload = decoded;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if rate limiting should be applied to this request
|
||||
*/
|
||||
function shouldSkipRateLimit(req, config) {
|
||||
// If rate limiting is disabled globally
|
||||
if (!config.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Never skip rate limiting for auth endpoints
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
if (isAuthEndpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we should skip authenticated requests
|
||||
if (config.skipAuthenticated && isAuthenticated(req)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we only rate limit public endpoints
|
||||
if (config.publicEndpointsOnly) {
|
||||
const isPublicEndpoint = req.path.startsWith('/api/public/') ||
|
||||
req.path.startsWith('/api/gallery/') ||
|
||||
isAuthEndpoint;
|
||||
return !isPublicEndpoint;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create dynamic rate limiter
|
||||
*/
|
||||
async function createRateLimiter() {
|
||||
const config = await getRateLimitSettings();
|
||||
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: async (req) => {
|
||||
// Refresh config for each request
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
|
||||
// Different limits for auth endpoints
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||
},
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
skip: async (req) => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return shouldSkipRateLimit(req, currentConfig);
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
// Enhanced logging for production analysis
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
authenticated: isAuthenticated(req),
|
||||
tokenType: req.tokenType,
|
||||
userAgent: req.headers['user-agent'],
|
||||
referer: req.headers['referer'],
|
||||
origin: req.headers['origin'],
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip']
|
||||
},
|
||||
requestUrl: req.originalUrl,
|
||||
rateLimitInfo: {
|
||||
limit: req.rateLimit?.limit,
|
||||
current: req.rateLimit?.current,
|
||||
remaining: req.rateLimit?.remaining,
|
||||
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
error: 'Too many requests, please try again later.',
|
||||
retryAfter: res.getHeader('Retry-After')
|
||||
});
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in headers
|
||||
legacyHeaders: false, // Disable X-RateLimit headers
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth-specific rate limiter
|
||||
*/
|
||||
async function createAuthRateLimiter() {
|
||||
const config = await getRateLimitSettings();
|
||||
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: config.authMaxRequests,
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
skip: async () => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return !currentConfig.enabled;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
// Enhanced logging for auth failures
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip']
|
||||
},
|
||||
requestUrl: req.originalUrl,
|
||||
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
||||
rateLimitInfo: {
|
||||
limit: req.rateLimit?.limit,
|
||||
current: req.rateLimit?.current,
|
||||
remaining: req.rateLimit?.remaining,
|
||||
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
error: 'Too many authentication attempts, please try again later.',
|
||||
retryAfter: res.getHeader('Retry-After')
|
||||
});
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRateLimitSettings,
|
||||
clearSettingsCache,
|
||||
createRateLimiter,
|
||||
createAuthRateLimiter,
|
||||
isAuthenticated,
|
||||
shouldSkipRateLimit
|
||||
};
|
||||
@@ -56,30 +56,30 @@ class WatermarkService {
|
||||
let left, top;
|
||||
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
left = padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'top-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
left = padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'center':
|
||||
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
||||
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
||||
break;
|
||||
default:
|
||||
// Default to bottom-right
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
case 'top-left':
|
||||
left = padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'top-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = padding;
|
||||
break;
|
||||
case 'bottom-left':
|
||||
left = padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'bottom-right':
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
break;
|
||||
case 'center':
|
||||
left = Math.floor((imageWidth - watermarkWidth) / 2);
|
||||
top = Math.floor((imageHeight - watermarkHeight) / 2);
|
||||
break;
|
||||
default:
|
||||
// Default to bottom-right
|
||||
left = imageWidth - watermarkWidth - padding;
|
||||
top = imageHeight - watermarkHeight - padding;
|
||||
}
|
||||
|
||||
return { left: Math.max(0, left), top: Math.max(0, top) };
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('./logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Clean up old temporary upload directories
|
||||
* Removes temp directories older than 1 hour
|
||||
*/
|
||||
async function cleanupTempUploads() {
|
||||
const tempPath = path.join(getStoragePath(), 'temp');
|
||||
|
||||
try {
|
||||
// Ensure temp directory exists
|
||||
await fs.mkdir(tempPath, { recursive: true });
|
||||
|
||||
// Read all items in temp directory
|
||||
const items = await fs.readdir(tempPath);
|
||||
|
||||
let cleanedCount = 0;
|
||||
const oneHourAgo = Date.now() - (60 * 60 * 1000); // 1 hour
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(tempPath, item);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(itemPath);
|
||||
|
||||
// Only process directories that match our upload pattern
|
||||
if (stats.isDirectory() && item.startsWith('upload_')) {
|
||||
// Extract timestamp from directory name
|
||||
const parts = item.split('_');
|
||||
if (parts.length >= 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
|
||||
// Remove if older than 1 hour
|
||||
if (!isNaN(timestamp) && timestamp < oneHourAgo) {
|
||||
logger.info(`Cleaning up old temp upload directory: ${item}`);
|
||||
await fs.rm(itemPath, { recursive: true, force: true });
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing temp item ${item}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.info(`Cleaned up ${cleanedCount} old temp upload directories`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error during temp upload cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of temp uploads
|
||||
* Runs every hour
|
||||
*/
|
||||
function startTempUploadCleanup() {
|
||||
// Run immediately on startup
|
||||
cleanupTempUploads();
|
||||
|
||||
// Then run every hour
|
||||
setInterval(() => {
|
||||
cleanupTempUploads();
|
||||
}, 60 * 60 * 1000); // 1 hour
|
||||
|
||||
logger.info('Temp upload cleanup service started');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanupTempUploads,
|
||||
startTempUploadCleanup
|
||||
};
|
||||
@@ -27,7 +27,28 @@ async function formatDate(date, language = 'en') {
|
||||
}
|
||||
}
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
// Ensure proper date parsing
|
||||
let dateObj;
|
||||
if (date instanceof Date) {
|
||||
dateObj = date;
|
||||
} else if (typeof date === 'string') {
|
||||
// For date strings like "2025-07-16", parse as local date to avoid timezone issues
|
||||
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
// Parse YYYY-MM-DD format as local date
|
||||
const [year, month, day] = date.split('-').map(num => parseInt(num, 10));
|
||||
dateObj = new Date(year, month - 1, day);
|
||||
} else {
|
||||
dateObj = new Date(date);
|
||||
}
|
||||
} else {
|
||||
dateObj = new Date(date);
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
console.error('Invalid date provided to formatDate:', date);
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
|
||||
// Use appropriate locale based on language
|
||||
let locale = dateConfig.locale || 'en-GB';
|
||||
@@ -39,33 +60,33 @@ async function formatDate(date, language = 'en') {
|
||||
|
||||
// Format based on the configured format
|
||||
switch (dateConfig.format) {
|
||||
case 'MM/DD/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'DD/MM/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'YYYY-MM-DD':
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
case 'DD.MM.YYYY':
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
default:
|
||||
// Use long format as fallback
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
case 'MM/DD/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'DD/MM/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'YYYY-MM-DD':
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
case 'DD.MM.YYYY':
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
default:
|
||||
// Use long format as fallback
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Formatters for email content and other text transformations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert plain text line breaks to HTML line breaks
|
||||
* @param {string} text - The text to format
|
||||
* @returns {string} - Text with HTML line breaks
|
||||
*/
|
||||
function nl2br(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Normalize line endings
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// Convert newlines to <br> tags
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('<br />');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format welcome message for email templates
|
||||
* @param {string} message - The welcome message
|
||||
* @returns {string} - Formatted message for HTML emails
|
||||
*/
|
||||
function formatWelcomeMessage(message) {
|
||||
if (!message || message.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return nl2br(message);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nl2br,
|
||||
formatWelcomeMessage
|
||||
};
|
||||
+77
-10
@@ -1,31 +1,98 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
const logDir = path.join(__dirname, '../../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Custom format for production logs
|
||||
const productionFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
// Ensure all security events are properly formatted
|
||||
if (info.level === 'warn' && (info.message.includes('rate limit') ||
|
||||
info.message.includes('auth') ||
|
||||
info.message.includes('login') ||
|
||||
info.message.includes('JWT'))) {
|
||||
return JSON.stringify({
|
||||
timestamp: info.timestamp,
|
||||
level: info.level,
|
||||
message: info.message,
|
||||
security: true,
|
||||
...info
|
||||
});
|
||||
}
|
||||
return JSON.stringify(info);
|
||||
})
|
||||
);
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
format: productionFormat,
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/error.log'),
|
||||
level: 'error'
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 5,
|
||||
tailable: true
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/combined.log')
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 50 * 1024 * 1024, // 50MB
|
||||
maxFiles: 10,
|
||||
tailable: true
|
||||
}),
|
||||
// Separate security log for authentication and rate limiting
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'security.log'),
|
||||
level: 'warn',
|
||||
maxsize: 20 * 1024 * 1024, // 20MB
|
||||
maxFiles: 10,
|
||||
tailable: true,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
// Only log security-related warnings
|
||||
if (info.message.includes('rate limit') ||
|
||||
info.message.includes('auth') ||
|
||||
info.message.includes('login') ||
|
||||
info.message.includes('JWT') ||
|
||||
info.message.includes('lockout') ||
|
||||
info.message.includes('suspicious')) {
|
||||
return JSON.stringify(info);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
)
|
||||
})
|
||||
]
|
||||
].filter(Boolean)
|
||||
});
|
||||
|
||||
// Add console logging for non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(info => {
|
||||
return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`;
|
||||
})
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// In production, also log to console for container environments
|
||||
if (process.env.LOG_TO_CONSOLE === 'true') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: productionFormat
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
|
||||
@@ -113,6 +113,84 @@ function validatePassword(password, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complexity settings from database
|
||||
* @returns {Object} - Password complexity configuration
|
||||
*/
|
||||
async function getPasswordComplexitySettings() {
|
||||
try {
|
||||
const { db, withRetry } = require('../database/db');
|
||||
|
||||
// Use retry wrapper to handle connection failures
|
||||
const settings = await withRetry(async () => {
|
||||
return await db('app_settings')
|
||||
.where('setting_key', 'security_password_complexity_level')
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!settings || !settings.setting_value) {
|
||||
return 'moderate'; // Default
|
||||
}
|
||||
|
||||
const value = typeof settings.setting_value === 'string'
|
||||
? JSON.parse(settings.setting_value)
|
||||
: settings.setting_value;
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get password complexity settings:', error);
|
||||
return 'moderate'; // Default on error - ensures app continues working
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get password configuration based on complexity level
|
||||
* @param {string} complexityLevel - Complexity level (simple, moderate, strong, very_strong)
|
||||
* @returns {Object} - Password configuration
|
||||
*/
|
||||
function getPasswordConfigForComplexity(complexityLevel) {
|
||||
const configs = {
|
||||
simple: {
|
||||
minLength: 6,
|
||||
requireUppercase: false,
|
||||
requireLowercase: false,
|
||||
requireNumbers: false,
|
||||
requireSpecialChars: false,
|
||||
preventCommonPasswords: true,
|
||||
minStrengthScore: 0
|
||||
},
|
||||
moderate: {
|
||||
minLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: false,
|
||||
preventCommonPasswords: true,
|
||||
minStrengthScore: 2
|
||||
},
|
||||
strong: {
|
||||
minLength: 12,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: false,
|
||||
preventCommonPasswords: true,
|
||||
minStrengthScore: 3
|
||||
},
|
||||
very_strong: {
|
||||
minLength: 12,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: true,
|
||||
preventCommonPasswords: true,
|
||||
minStrengthScore: 3
|
||||
}
|
||||
};
|
||||
|
||||
return configs[complexityLevel] || configs.moderate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password for specific contexts (admin, gallery)
|
||||
* @param {string} password - Password to validate
|
||||
@@ -120,19 +198,16 @@ function validatePassword(password, options = {}) {
|
||||
* @param {Object} userData - Additional user data for context-aware validation
|
||||
* @returns {Object} - Validation result
|
||||
*/
|
||||
function validatePasswordInContext(password, context, userData = {}) {
|
||||
// For gallery context, use more lenient validation
|
||||
async function validatePasswordInContext(password, context, userData = {}) {
|
||||
// For gallery context, use dynamic complexity settings
|
||||
if (context === 'gallery') {
|
||||
// Gallery-specific validation options
|
||||
// Get complexity settings from database
|
||||
const complexityLevel = await getPasswordComplexitySettings();
|
||||
|
||||
// Get configuration for the complexity level
|
||||
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
|
||||
...getPasswordConfigForComplexity(complexityLevel),
|
||||
skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
|
||||
};
|
||||
|
||||
// Base validation with gallery-specific options
|
||||
@@ -281,5 +356,7 @@ module.exports = {
|
||||
generateSecurePassword,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure,
|
||||
getPasswordComplexitySettings,
|
||||
getPasswordConfigForComplexity,
|
||||
PASSWORD_CONFIG
|
||||
};
|
||||
@@ -47,7 +47,7 @@ function escapeLikePattern(input) {
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/%/g, '\\%') // Escape percent signs
|
||||
.replace(/_/g, '\\_') // Escape underscores
|
||||
.replace(/'/g, "''"); // Escape single quotes for safety
|
||||
.replace(/'/g, '\'\''); // Escape single quotes for safety
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Generated
+153
-5
@@ -1,18 +1,24 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.73",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.73",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.26.1",
|
||||
"@tiptap/extension-hard-break": "^2.26.1",
|
||||
"@tiptap/extension-link": "^2.25.0",
|
||||
"@tiptap/extension-placeholder": "^2.26.1",
|
||||
"@tiptap/extension-text-align": "^2.26.1",
|
||||
"@tiptap/react": "^2.25.0",
|
||||
"@tiptap/starter-kit": "^2.25.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.3.2",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -22,6 +28,8 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "^2.3.5",
|
||||
@@ -1541,6 +1549,20 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-character-count": {
|
||||
"version": "2.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.26.1.tgz",
|
||||
"integrity": "sha512-F7LP1a9GF28thbApowWT2I41baqX74HMUTrV9LGrNXaOkW2gxZz+CDOzfHsbHyfuwfIxIjv07Qf/HKA6Cc1qbA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz",
|
||||
@@ -1568,6 +1590,23 @@
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block-lowlight": {
|
||||
"version": "2.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.26.1.tgz",
|
||||
"integrity": "sha512-yptuTPYAzVMKHUTwNKYveuu0rYHYyFknPz3O2++PWeeBGxkNB+T6LhwZ/JhXceHcZxzlGyka9r2mXR7pslhugw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/extension-code-block": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0",
|
||||
"highlight.js": "^11",
|
||||
"lowlight": "^2 || ^3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz",
|
||||
@@ -1627,9 +1666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.25.0.tgz",
|
||||
"integrity": "sha512-h8be5Zdtsl5GQHxRXvYlGfIJsLvdbexflSTr12gr4kvcQqTdtrsqyu2eksfAK+p2szbiwP2G4VZlH0LNS47UXQ==",
|
||||
"version": "2.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz",
|
||||
"integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -1749,6 +1788,20 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "2.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz",
|
||||
"integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz",
|
||||
@@ -1775,6 +1828,19 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "2.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.26.1.tgz",
|
||||
"integrity": "sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.25.0.tgz",
|
||||
@@ -1935,6 +2001,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "2.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
|
||||
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-cookie": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
|
||||
@@ -1955,6 +2030,12 @@
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
@@ -2012,6 +2093,12 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -3233,6 +3320,19 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
|
||||
"integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -3350,6 +3450,14 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -3576,6 +3684,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
@@ -3935,6 +4053,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -3954,6 +4078,30 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
|
||||
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^2.0.0",
|
||||
"fault": "^2.0.0",
|
||||
"highlight.js": "~11.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight/node_modules/highlight.js": {
|
||||
"version": "11.8.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
|
||||
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.73",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,10 +12,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.26.1",
|
||||
"@tiptap/extension-hard-break": "^2.26.1",
|
||||
"@tiptap/extension-link": "^2.25.0",
|
||||
"@tiptap/extension-placeholder": "^2.26.1",
|
||||
"@tiptap/extension-text-align": "^2.26.1",
|
||||
"@tiptap/react": "^2.25.0",
|
||||
"@tiptap/starter-kit": "^2.25.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.3.2",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -25,6 +31,8 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "^2.3.5",
|
||||
|
||||
+29
-14
@@ -23,6 +23,7 @@ import {
|
||||
SettingsPage,
|
||||
CMSPage
|
||||
} from './pages/admin';
|
||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
@@ -43,17 +44,26 @@ function App() {
|
||||
// Initialize Umami Analytics based on settings
|
||||
useEffect(() => {
|
||||
const initializeAnalytics = async () => {
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
try {
|
||||
// Fetch public settings to check if analytics is enabled
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
const settings = await response.json();
|
||||
try {
|
||||
// Fetch public settings to get Umami configuration
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
const settings = await response.json();
|
||||
|
||||
// Check if Umami is enabled and configured in backend settings
|
||||
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
||||
// Use backend configuration
|
||||
analyticsService.initialize({
|
||||
websiteId: settings.umami_website_id,
|
||||
hostUrl: settings.umami_url,
|
||||
autoTrack: true,
|
||||
doNotTrack: true
|
||||
});
|
||||
} else {
|
||||
// Fall back to environment variables if backend not configured
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
// Only initialize if analytics is enabled in settings
|
||||
if (settings.enable_analytics !== false) {
|
||||
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
@@ -61,9 +71,14 @@ function App() {
|
||||
doNotTrack: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings for analytics:', error);
|
||||
// Initialize analytics anyway if settings fetch fails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings for analytics:', error);
|
||||
// Fall back to environment variables on error
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (umamiUrl && umamiWebsiteId) {
|
||||
analyticsService.initialize({
|
||||
websiteId: umamiWebsiteId,
|
||||
hostUrl: umamiUrl,
|
||||
@@ -109,7 +124,7 @@ function App() {
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route path="cms" element={<CMSPageEnhanced />} />
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -36,23 +36,7 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load image:', src, err);
|
||||
// Log more details about the error
|
||||
if (err.response) {
|
||||
console.error('Response status:', err.response.status);
|
||||
console.error('Response headers:', err.response.headers);
|
||||
if (err.response.data instanceof Blob) {
|
||||
// Try to read error message from blob
|
||||
try {
|
||||
const text = await err.response.data.text();
|
||||
console.error('Response data:', text);
|
||||
} catch (e) {
|
||||
console.error('Could not read blob data');
|
||||
}
|
||||
} else {
|
||||
console.error('Response data:', err.response.data);
|
||||
}
|
||||
}
|
||||
// Image loading failed - handled by error state
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import CharacterCount from '@tiptap/extension-character-count';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { lowlight } from 'lowlight';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -10,33 +16,103 @@ import {
|
||||
Link as LinkIcon,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Quote,
|
||||
Code,
|
||||
Code2,
|
||||
Minus,
|
||||
Undo,
|
||||
Redo
|
||||
Redo,
|
||||
RemoveFormatting,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Eye,
|
||||
Edit3,
|
||||
Columns,
|
||||
Maximize2,
|
||||
HelpCircle,
|
||||
Save
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import DOMPurify from 'dompurify';
|
||||
import '../../styles/prose-overrides.css';
|
||||
|
||||
interface CMSEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
onSave?: () => void;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
|
||||
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('edit');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({
|
||||
hardBreak: false, // We'll use the separate HardBreak extension
|
||||
codeBlock: false, // We'll use CodeBlockLowlight instead
|
||||
}),
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
HTMLAttributes: {
|
||||
class: 'hard-break',
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'center', 'right', 'justify'],
|
||||
defaultAlignment: 'left',
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
class: 'hljs',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Start typing your content here...',
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: null,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
updateCounts(editor);
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
updateCounts(editor);
|
||||
},
|
||||
});
|
||||
|
||||
const updateCounts = useCallback((editor: any) => {
|
||||
const text = editor.state.doc.textContent;
|
||||
setCharCount(editor.storage.characterCount.characters());
|
||||
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
|
||||
}, []);
|
||||
|
||||
// Update editor content when prop changes
|
||||
React.useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
@@ -61,12 +137,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}> = ({ onClick, active, children, title }) => (
|
||||
disabled?: boolean;
|
||||
}> = ({ onClick, active, children, title, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded hover:bg-neutral-100 ${
|
||||
disabled={disabled}
|
||||
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
|
||||
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
|
||||
}`}
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
@@ -74,116 +152,418 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
|
||||
</button>
|
||||
);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
const getPreviewContent = () => {
|
||||
return DOMPurify.sanitize(editor?.getHTML() || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
|
||||
'code', 'pre', 'hr', 'div', 'span'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
KEEP_CONTENT: true,
|
||||
ADD_TAGS: ['br'], // Explicitly allow br tags
|
||||
ADD_ATTR: ['style'], // Allow style for text alignment
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
{/* Top Toolbar */}
|
||||
<div className="border-b border-neutral-200 bg-neutral-50">
|
||||
{/* View Mode Controls */}
|
||||
<div className="flex items-center justify-between p-2 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('edit')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'edit'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 inline-block mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'preview'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-4 h-4 inline-block mr-1" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('split')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'split'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
<Columns className="w-4 h-4 inline-block mr-1" />
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onSave && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
isLoading={isSaving}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowHelp(true)}
|
||||
title="Help & Keyboard Shortcuts"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
active={isFullscreen}
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
{/* Formatting Toolbar */}
|
||||
{viewMode !== 'preview' && (
|
||||
<div className="flex items-center gap-1 p-2 flex-wrap">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
active={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1 (Ctrl+Alt+1)"
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2 (Ctrl+Alt+2)"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3 (Ctrl+Alt+3)"
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
|
||||
active={editor.isActive('heading', { level: 4 })}
|
||||
title="Heading 4 (Ctrl+Alt+4)"
|
||||
>
|
||||
<Heading4 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
|
||||
active={editor.isActive('heading', { level: 5 })}
|
||||
title="Heading 5 (Ctrl+Alt+5)"
|
||||
>
|
||||
<Heading5 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
|
||||
active={editor.isActive('heading', { level: 6 })}
|
||||
title="Heading 6 (Ctrl+Alt+6)"
|
||||
>
|
||||
<Heading6 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline Code (Ctrl+E)"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
active={editor.isActive('codeBlock')}
|
||||
title="Code Block (Ctrl+Alt+C)"
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title="Add Link"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet List (Ctrl+Shift+8)"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Numbered List (Ctrl+Shift+9)"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Blockquote (Ctrl+Shift+B)"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
active={editor.isActive('link')}
|
||||
title="Add Link (Ctrl+K)"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
active={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
active={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
active={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
active={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Justify"
|
||||
>
|
||||
<AlignJustify className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-300 mx-1" />
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addLink()}
|
||||
placeholder="Enter URL..."
|
||||
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setShowLinkDialog(false);
|
||||
setLinkUrl('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Editor */}
|
||||
{viewMode !== 'preview' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{viewMode !== 'edit' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{wordCount} words</span>
|
||||
<span>{charCount} characters</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
Press Shift+Enter for line break, Enter for new paragraph
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link Dialog */}
|
||||
{showLinkDialog && (
|
||||
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addLink()}
|
||||
placeholder="Enter URL..."
|
||||
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setShowLinkDialog(false);
|
||||
setLinkUrl('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/* Help Modal */}
|
||||
{showHelp && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Formatting</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+B</kbd> - Bold</div>
|
||||
<div><kbd>Ctrl+I</kbd> - Italic</div>
|
||||
<div><kbd>Ctrl+E</kbd> - Inline code</div>
|
||||
<div><kbd>Ctrl+K</kbd> - Add link</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Headings</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
|
||||
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
|
||||
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
|
||||
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
|
||||
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
|
||||
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
|
||||
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
|
||||
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
|
||||
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Text Alignment</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>Click alignment buttons in toolbar</div>
|
||||
<div>Works on paragraphs and headings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Line Breaks</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div><kbd>Enter</kbd> - New paragraph</div>
|
||||
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Navigation</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><kbd>Ctrl+Z</kbd> - Undo</div>
|
||||
<div><kbd>Ctrl+Y</kbd> - Redo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={() => setShowHelp(false)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface WelcomeMessageEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 6
|
||||
}) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
// Convert newlines to <br> tags for preview
|
||||
const getPreviewHtml = () => {
|
||||
return value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('<br />');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 text-neutral-400">
|
||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-500">
|
||||
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
|
||||
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<div
|
||||
className="text-sm text-neutral-700 whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
||||
@@ -8,6 +8,7 @@ export { PhotoUpload } from './PhotoUpload';
|
||||
export { CategoryManager } from './CategoryManager';
|
||||
export { EventCategoryManager } from './EventCategoryManager';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
|
||||
export { BulkArchiveModal } from './BulkArchiveModal';
|
||||
export { MaintenanceBanner } from './MaintenanceBanner';
|
||||
export { EmailPreviewModal } from './EmailPreviewModal';
|
||||
|
||||
@@ -46,7 +46,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.warn('No auth token found for image:', src);
|
||||
// No auth token - use fallback
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -69,7 +69,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
// console.log('Fetching authenticated image:', fullImageUrl);
|
||||
// Fetch authenticated image
|
||||
const response = await fetch(fullImageUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
@@ -85,7 +85,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load image:', src, err);
|
||||
// Image loading failed - use fallback
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -2,9 +2,28 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
// SVG Flag Components
|
||||
const GBFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#012169" d="M0 0h640v480H0z"/>
|
||||
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0h75z"/>
|
||||
<path fill="#C8102E" d="m424 281 216 159v40L369 281h55zm-184 20 6 35L54 480H0l240-179zM640 0v3L391 191l2-44L590 0h50zM0 0l239 176h-60L0 42V0z"/>
|
||||
<path fill="#FFF" d="M241 0v480h160V0H241zM0 160v160h640V160H0z"/>
|
||||
<path fill="#C8102E" d="M0 193v96h640v-96H0zM273 0v480h96V0h-96z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#000" d="M0 0h640v160H0z"/>
|
||||
<path fill="#D00" d="M0 160h640v160H0z"/>
|
||||
<path fill="#FFCE00" d="M0 320h640v160H0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||
];
|
||||
|
||||
export const LanguageSelector: React.FC = () => {
|
||||
@@ -25,7 +44,7 @@ export const LanguageSelector: React.FC = () => {
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>{currentLanguage.flag}</span>
|
||||
<currentLanguage.Flag className="w-5 h-5" />
|
||||
<span>{currentLanguage.name}</span>
|
||||
</button>
|
||||
|
||||
@@ -41,7 +60,7 @@ export const LanguageSelector: React.FC = () => {
|
||||
: 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{language.flag}</span>
|
||||
<language.Flag className="w-5 h-5" />
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -54,16 +54,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// Fetch photos
|
||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('Event prop:', event);
|
||||
console.log('Event prop hero_photo_id:', event?.hero_photo_id);
|
||||
if (data) {
|
||||
console.log('Gallery data:', data);
|
||||
console.log('Event data from API:', data.event);
|
||||
console.log('Hero photo ID from API:', data.event?.hero_photo_id);
|
||||
}
|
||||
}, [data, event]);
|
||||
// Data updates are handled by React Query
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Handle window resize
|
||||
@@ -124,7 +115,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse event theme:', e);
|
||||
// Invalid theme format - use default
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
@@ -142,10 +133,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||
console.log('Setting hero photo ID in existing gallery settings:', fullEvent.hero_photo_id);
|
||||
// Apply hero photo ID to existing gallery settings
|
||||
} else if (fullEvent.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||
console.log('Creating gallery settings with hero photo ID:', fullEvent.hero_photo_id);
|
||||
// Create gallery settings with hero photo ID
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
@@ -368,11 +359,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
console.log('Header extra - data loaded:', !!data);
|
||||
console.log('Header extra - allow uploads:', data?.event?.allow_user_uploads);
|
||||
console.log('Header extra - showSidebar:', showSidebar);
|
||||
console.log('Header extra - isMobile:', isMobile);
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||
|
||||
@@ -92,7 +92,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }
|
||||
const downloadPromises = selectedPhotosList.map(photo =>
|
||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
||||
.catch(err => {
|
||||
console.error(`Failed to download ${photo.filename}:`, err);
|
||||
// Download failed - error handled by UI
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -115,7 +115,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
const downloadPromises = selectedPhotosList.map(photo =>
|
||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
||||
.catch(err => {
|
||||
console.error(`Failed to download ${photo.filename}:`, err);
|
||||
// Download failed - error handled by UI
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to upload ${file.name}:`, error);
|
||||
// Upload error handled - user notified via UI
|
||||
failedCount++;
|
||||
|
||||
// Show specific error message
|
||||
|
||||
@@ -47,12 +47,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
useEffect(() => {
|
||||
if (photos.length > 0) {
|
||||
const heroId = gallerySettings.heroImageId;
|
||||
console.log('HeroGalleryLayout - heroImageId:', heroId, 'photos:', photos.length);
|
||||
// Process hero layout with provided photos
|
||||
|
||||
// If admin has selected a specific hero image, always use it
|
||||
if (heroId) {
|
||||
const adminSelectedHero = photos.find(p => p.id === heroId);
|
||||
console.log('Looking for hero photo with ID:', heroId, 'Found:', adminSelectedHero?.filename);
|
||||
// Hero photo selected by admin
|
||||
if (adminSelectedHero) {
|
||||
setHeroPhoto(adminSelectedHero);
|
||||
setHasInitialized(true);
|
||||
|
||||
@@ -107,7 +107,7 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
||||
}
|
||||
// Don't redirect - let the component handle the auth state
|
||||
} else {
|
||||
} else if (galleryMatch) {
|
||||
// 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 = '/';
|
||||
|
||||
@@ -44,7 +44,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
// Auth check failed - user needs to login
|
||||
setError('Failed to check authentication');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -75,7 +75,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
localStorage.removeItem(`gallery_token_${currentSlug}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored event data');
|
||||
// Invalid stored data - clear it
|
||||
localStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
localStorage.removeItem(`gallery_token_${currentSlug}`);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { settingsService } from '../services/settings.service';
|
||||
|
||||
export const useLocalizedDate = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Fetch admin settings to get the date format
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings-general'],
|
||||
queryFn: () => settingsService.getSettingsByType('general'),
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
const getLocale = () => {
|
||||
return i18n.language === 'de' ? de : enUS;
|
||||
};
|
||||
|
||||
const format = (date: Date | string, formatStr: string) => {
|
||||
const format = (date: Date | string, formatStr?: string) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return dateFnsFormat(dateObj, formatStr, { locale: getLocale() });
|
||||
// Use admin-configured date format if available and no format string provided
|
||||
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
|
||||
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
|
||||
};
|
||||
|
||||
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
|
||||
@@ -22,6 +33,7 @@ export const useLocalizedDate = () => {
|
||||
return {
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
locale: getLocale()
|
||||
locale: getLocale(),
|
||||
dateFormat: settings?.general_date_format || 'PPP'
|
||||
};
|
||||
};
|
||||
@@ -428,6 +428,12 @@
|
||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||
"minPasswordLength": "Minimale Passwortlänge",
|
||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||
"passwordComplexity": "Passwort-Komplexität",
|
||||
"passwordComplexityHelp": "Sicherheitsstufe für Galerie-Passwörter",
|
||||
"complexitySimple": "Einfach (6+ Zeichen, beliebiger Text)",
|
||||
"complexityModerate": "Moderat (8+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
|
||||
"complexityStrong": "Stark (12+ Zeichen, Groß-/Kleinschreibung/Zahlen)",
|
||||
"complexityVeryStrong": "Sehr stark (12+ Zeichen, alle Zeichentypen)",
|
||||
"sessionAuth": "Sitzung & Authentifizierung",
|
||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||
@@ -715,11 +721,18 @@
|
||||
"settings_updated": "Einstellungen aktualisiert",
|
||||
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
||||
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
||||
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
|
||||
"category_created": "Kategorie erstellt: {{categoryName}}",
|
||||
"category_updated": "Kategorie aktualisiert: {{categoryName}}",
|
||||
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
|
||||
"favicon_uploaded": "Favicon hochgeladen",
|
||||
"analytics_settings_updated": "Analytik-Einstellungen aktualisiert",
|
||||
"cms_page_updated": "CMS-Seite aktualisiert: {{page}}",
|
||||
"security_settings_updated": "Sicherheitseinstellungen aktualisiert",
|
||||
"password_reset": "Passwort zurückgesetzt für: {{eventName}}",
|
||||
"admin_logout": "Admin {{actorName}} abgemeldet",
|
||||
"system_activity": "Systemaktivität: {{type}}",
|
||||
"unknown": "Unbekannte Aktivität"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -447,6 +447,12 @@
|
||||
"requirePassword": "Require password for all galleries",
|
||||
"minPasswordLength": "Minimum Password Length",
|
||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||
"passwordComplexity": "Password Complexity",
|
||||
"passwordComplexityHelp": "Security level required for gallery passwords",
|
||||
"complexitySimple": "Simple (6+ chars, any text)",
|
||||
"complexityModerate": "Moderate (8+ chars, mixed case/numbers)",
|
||||
"complexityStrong": "Strong (12+ chars, uppercase/lowercase/numbers)",
|
||||
"complexityVeryStrong": "Very Strong (12+ chars, all character types)",
|
||||
"sessionAuth": "Session & Authentication",
|
||||
"sessionTimeout": "Session Timeout (minutes)",
|
||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||
@@ -494,6 +500,30 @@
|
||||
"sent": "Sent",
|
||||
"failed": "Failed",
|
||||
"lastUpdate": "Last update"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"umamiIntegration": "Umami Analytics Integration",
|
||||
"enableUmami": "Enable Umami Analytics",
|
||||
"umamiUrl": "Umami URL",
|
||||
"umamiUrlHelp": "The URL of your Umami instance (e.g., https://analytics.yourdomain.com)",
|
||||
"websiteId": "Website ID",
|
||||
"websiteIdHelp": "Your Umami website ID (found in Umami dashboard)",
|
||||
"shareUrl": "Share URL (Optional)",
|
||||
"shareUrlHelp": "Public share URL for embedding the full dashboard (create in Umami)",
|
||||
"umamiInfo": "About Umami Analytics",
|
||||
"umamiInfoText": "Umami is a privacy-focused, open-source analytics platform. It tracks page views, unique visitors, and custom events without using cookies.",
|
||||
"learnMore": "Learn more about Umami",
|
||||
"saveAnalyticsSettings": "Save Analytics Settings",
|
||||
"backendAnalytics": "Backend Analytics",
|
||||
"backendAnalyticsText": "The system also tracks basic analytics server-side for security and performance monitoring.",
|
||||
"tracked": "Tracked Metrics",
|
||||
"galleryViews": "Gallery page views",
|
||||
"photoDownloads": "Individual and bulk downloads",
|
||||
"uniqueVisitors": "Unique visitors by IP",
|
||||
"deviceTypes": "Device types from user agents",
|
||||
"privacy": "Privacy",
|
||||
"privacyText": "IP addresses are hashed for privacy. No personal data is stored. Analytics data is retained for 90 days."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -766,11 +796,18 @@
|
||||
"settings_updated": "Settings updated",
|
||||
"event_updated": "Event updated: {{eventName}}",
|
||||
"event_deleted": "Event deleted: {{eventName}}",
|
||||
"email_resent": "Creation email resent for: {{eventName}}",
|
||||
"category_created": "Category created: {{categoryName}}",
|
||||
"category_updated": "Category updated: {{categoryName}}",
|
||||
"category_deleted": "Category deleted: {{categoryName}}",
|
||||
"general_settings_updated": "General settings updated",
|
||||
"favicon_uploaded": "Favicon uploaded",
|
||||
"analytics_settings_updated": "Analytics settings updated",
|
||||
"cms_page_updated": "CMS page updated: {{page}}",
|
||||
"security_settings_updated": "Security settings updated",
|
||||
"password_reset": "Password reset for: {{eventName}}",
|
||||
"admin_logout": "Admin {{actorName}} logged out",
|
||||
"system_activity": "System activity: {{type}}",
|
||||
"unknown": "Unknown activity"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './styles/prose-overrides.css'
|
||||
import './i18n/config'
|
||||
import App from './App.tsx'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Calendar, AlertCircle, Clock } from 'lucide-react';
|
||||
import { AlertCircle, Clock } from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../hooks/useLocalizedDate';
|
||||
@@ -289,13 +289,9 @@ export const GalleryPage: React.FC = () => {
|
||||
alt={settingsData?.branding_company_name || 'PicPeak'}
|
||||
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
|
||||
/>
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-2 px-2" style={{ color: 'var(--color-primary, #5C8762)' }}>
|
||||
{galleryInfo?.event_name}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
||||
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiration Warning */}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
toast.success('Login successful!');
|
||||
setLoginSuccess(true);
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
// Login error handled by UI notification
|
||||
|
||||
// Handle network errors gracefully
|
||||
if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
@@ -17,6 +17,7 @@ import { Button, Card, Loading } from '../../components/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
// Map API response to component format
|
||||
interface ComponentAnalyticsData {
|
||||
@@ -52,10 +53,8 @@ export const AnalyticsPage: React.FC = () => {
|
||||
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
|
||||
const [isEmbedMode, setIsEmbedMode] = useState(false);
|
||||
|
||||
// Check if Umami is configured
|
||||
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
// const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL;
|
||||
// Check if Umami is configured from settings or environment
|
||||
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string; enabled?: boolean }>({});
|
||||
|
||||
// Fetch analytics data from backend
|
||||
const { data: apiData, isLoading, refetch } = useQuery({
|
||||
@@ -73,6 +72,63 @@ export const AnalyticsPage: React.FC = () => {
|
||||
queryFn: () => adminService.getDashboardStats(),
|
||||
});
|
||||
|
||||
// Fetch Umami config from admin settings since we're in admin panel
|
||||
useEffect(() => {
|
||||
const fetchUmamiConfig = async () => {
|
||||
try {
|
||||
// Use admin API endpoint with auth token since we're in admin area
|
||||
const response = await api.get('/admin/settings');
|
||||
const settings = response.data;
|
||||
|
||||
// Transform the settings array to object
|
||||
const settingsMap = settings.reduce((acc: any, setting: any) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Check if Umami is enabled in admin settings
|
||||
if (settingsMap.analytics_umami_enabled && settingsMap.analytics_umami_url && settingsMap.analytics_umami_website_id) {
|
||||
setUmamiConfig({
|
||||
url: settingsMap.analytics_umami_url,
|
||||
shareUrl: settingsMap.analytics_umami_share_url,
|
||||
enabled: true
|
||||
});
|
||||
} else {
|
||||
// Fall back to environment variables if they exist
|
||||
const envUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (envUrl && envWebsiteId) {
|
||||
setUmamiConfig({
|
||||
url: envUrl,
|
||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
|
||||
enabled: true
|
||||
});
|
||||
} else {
|
||||
setUmamiConfig({ enabled: false });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Umami config:', error);
|
||||
// Fall back to environment variables if they exist
|
||||
const envUrl = import.meta.env.VITE_UMAMI_URL;
|
||||
const envWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
||||
|
||||
if (envUrl && envWebsiteId) {
|
||||
setUmamiConfig({
|
||||
url: envUrl,
|
||||
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
|
||||
enabled: true
|
||||
});
|
||||
} else {
|
||||
setUmamiConfig({ enabled: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchUmamiConfig();
|
||||
}, []);
|
||||
|
||||
// Calculate trends and format data
|
||||
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
|
||||
if (!apiData) return undefined;
|
||||
@@ -96,11 +152,15 @@ export const AnalyticsPage: React.FC = () => {
|
||||
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
|
||||
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
|
||||
|
||||
// Format top galleries for downloads
|
||||
const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({
|
||||
name: gallery.event_name,
|
||||
downloads: gallery.views // Using views as download count for now
|
||||
}));
|
||||
// Get actual download data for top galleries - sort by downloads
|
||||
const topGalleriesWithDownloads = apiData.topGalleries
|
||||
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
|
||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
|
||||
.slice(0, 5) // Take top 5
|
||||
.map(gallery => ({
|
||||
name: gallery.event_name,
|
||||
downloads: gallery.downloads || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
pageViews: {
|
||||
@@ -122,7 +182,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
topPages: apiData.topGalleries.map(gallery => ({
|
||||
path: `/gallery/${gallery.slug}`,
|
||||
views: gallery.views,
|
||||
uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors
|
||||
uniqueVisitors: gallery.uniqueVisitors || gallery.views // Use actual unique visitors if available
|
||||
}))
|
||||
};
|
||||
}, [apiData]);
|
||||
@@ -166,7 +226,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// If Umami is configured and embed mode is enabled, show the Umami dashboard
|
||||
if (isEmbedMode && umamiShareUrl) {
|
||||
if (isEmbedMode && umamiConfig.shareUrl) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -185,7 +245,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
|
||||
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
|
||||
<iframe
|
||||
src={umamiShareUrl}
|
||||
src={umamiConfig.shareUrl}
|
||||
className="w-full h-full border-0"
|
||||
title="Umami Analytics Dashboard"
|
||||
/>
|
||||
@@ -203,7 +263,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{umamiShareUrl && (
|
||||
{umamiConfig.shareUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEmbedMode(true)}
|
||||
@@ -405,7 +465,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Configuration Notice */}
|
||||
{!umamiUrl && (
|
||||
{umamiConfig.enabled === false && (
|
||||
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
||||
|
||||
@@ -184,21 +184,12 @@ export const CMSPage: React.FC = () => {
|
||||
<CMSEditor
|
||||
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
|
||||
onChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={updateMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('cms.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPage?.updated_at && (
|
||||
<p className="text-xs text-neutral-500 mt-4">
|
||||
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
|
||||
export const CMSPageEnhanced: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
queryKey: ['cms-pages'],
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
cmsService.updatePage(slug, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
|
||||
setHasUnsavedChanges(false);
|
||||
setLastSaved(new Date());
|
||||
setIsAutoSaving(false);
|
||||
if (!isAutoSaving) {
|
||||
toast.success(t('cms.pageUpdated'));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setIsAutoSaving(false);
|
||||
toast.error(t('toast.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-save functionality
|
||||
const autoSave = useCallback(
|
||||
debounce(() => {
|
||||
if (hasUnsavedChanges && !updateMutation.isPending) {
|
||||
setIsAutoSaving(true);
|
||||
updateMutation.mutate({
|
||||
slug: selectedPage,
|
||||
data: editForm,
|
||||
});
|
||||
}
|
||||
}, 3000),
|
||||
[hasUnsavedChanges, editForm, selectedPage]
|
||||
);
|
||||
|
||||
// Trigger auto-save when content changes
|
||||
useEffect(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
autoSave();
|
||||
}
|
||||
return () => {
|
||||
autoSave.cancel();
|
||||
};
|
||||
}, [hasUnsavedChanges, autoSave]);
|
||||
|
||||
// Load page data when selection changes
|
||||
React.useEffect(() => {
|
||||
if (pages) {
|
||||
const page = pages.find(p => p.slug === selectedPage);
|
||||
if (page) {
|
||||
setEditForm(page);
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}
|
||||
}, [pages, selectedPage]);
|
||||
|
||||
const handleSave = () => {
|
||||
autoSave.cancel(); // Cancel any pending auto-save
|
||||
updateMutation.mutate({
|
||||
slug: selectedPage,
|
||||
data: editForm,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
const field = editingLang === 'de' ? 'content_de' : 'content_en';
|
||||
setEditForm(prev => ({ ...prev, [field]: content }));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
const field = editingLang === 'de' ? 'title_de' : 'title_en';
|
||||
setEditForm(prev => ({ ...prev, [field]: title }));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loading size="lg" text={t('cms.loadingPages')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
|
||||
<div className="space-y-2">
|
||||
{pages?.map((page) => (
|
||||
<button
|
||||
key={page.slug}
|
||||
onClick={() => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (confirm('You have unsaved changes. Do you want to save them?')) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
setSelectedPage(page.slug);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||
selectedPage === page.slug
|
||||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
||||
</div>
|
||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md" className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<a
|
||||
href={`${window.location.origin}/${selectedPage}?lang=en`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('cms.englishVersion')}
|
||||
</a>
|
||||
<a
|
||||
href={`${window.location.origin}/${selectedPage}?lang=de`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('cms.germanVersion')}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Auto-save status */}
|
||||
{(hasUnsavedChanges || lastSaved) && (
|
||||
<Card padding="md" className="mt-4">
|
||||
<div className="text-sm">
|
||||
{isAutoSaving && (
|
||||
<div className="flex items-center gap-2 text-neutral-600">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Auto-saving...
|
||||
</div>
|
||||
)}
|
||||
{!isAutoSaving && hasUnsavedChanges && (
|
||||
<div className="flex items-center gap-2 text-yellow-600">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
|
||||
Unsaved changes
|
||||
</div>
|
||||
)}
|
||||
{!hasUnsavedChanges && lastSaved && (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
Saved {new Date(lastSaved).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
|
||||
</h2>
|
||||
|
||||
{/* Language Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingLang('en')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
editingLang === 'en'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingLang('de')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
editingLang === 'de'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
Deutsch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<Input
|
||||
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={t('cms.pageTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||
</label>
|
||||
<CMSEditor
|
||||
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
|
||||
onChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPage?.updated_at && (
|
||||
<p className="text-xs text-neutral-500 mt-4">
|
||||
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { format, addDays } from 'date-fns';
|
||||
import { enUS, de } from 'date-fns/locale';
|
||||
import { addDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -57,6 +57,7 @@ const EVENT_TYPES = [
|
||||
export const CreateEventPageEnhanced: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const isMountedRef = useRef(true);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
// const [showPreview, setShowPreview] = useState(false);
|
||||
@@ -320,12 +321,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.welcomeMessage')}
|
||||
</label>
|
||||
<textarea
|
||||
<WelcomeMessageEditor
|
||||
value={formData.welcome_message}
|
||||
onChange={handleInputChange('welcome_message')}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, welcome_message: value }))}
|
||||
placeholder={t('events.welcomeMessagePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
rows={3}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +500,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
</div>
|
||||
{formData.event_date && (
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
|
||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
@@ -64,6 +64,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [securitySettings, setSecuritySettings] = useState({
|
||||
require_password: true,
|
||||
password_min_length: 8,
|
||||
password_complexity: 'moderate',
|
||||
enable_2fa: false,
|
||||
session_timeout_minutes: 60,
|
||||
max_login_attempts: 5,
|
||||
@@ -72,6 +73,14 @@ export const SettingsPage: React.FC = () => {
|
||||
recaptcha_secret_key: ''
|
||||
});
|
||||
|
||||
// Analytics settings state
|
||||
const [analyticsSettings, setAnalyticsSettings] = useState({
|
||||
umami_enabled: false,
|
||||
umami_url: '',
|
||||
umami_website_id: '',
|
||||
umami_share_url: ''
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
// Set the language if it's different from current
|
||||
@@ -104,8 +113,16 @@ export const SettingsPage: React.FC = () => {
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
||||
});
|
||||
|
||||
// Extract analytics settings
|
||||
setAnalyticsSettings({
|
||||
umami_enabled: settings.analytics_umami_enabled || false,
|
||||
umami_url: settings.analytics_umami_url || '',
|
||||
umami_website_id: settings.analytics_umami_website_id || '',
|
||||
umami_share_url: settings.analytics_umami_share_url || ''
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
}, [settings, i18n]);
|
||||
|
||||
// Save mutations
|
||||
const saveGeneralMutation = useMutation({
|
||||
@@ -144,6 +161,24 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const saveAnalyticsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Convert to the format expected by the API
|
||||
const settingsData: Record<string, any> = {};
|
||||
Object.entries(analyticsSettings).forEach(([key, value]) => {
|
||||
settingsData[`analytics_${key}`] = value;
|
||||
});
|
||||
return settingsService.updateSettings(settingsData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -202,6 +237,16 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
{t('settings.categories.title')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'analytics'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{t('settings.analytics.title')}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -621,6 +666,25 @@ export const SettingsPage: React.FC = () => {
|
||||
max="32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.passwordComplexity')}
|
||||
</label>
|
||||
<select
|
||||
value={securitySettings.password_complexity}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="simple">{t('settings.security.complexitySimple')}</option>
|
||||
<option value="moderate">{t('settings.security.complexityModerate')}</option>
|
||||
<option value="strong">{t('settings.security.complexityStrong')}</option>
|
||||
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.passwordComplexityHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -754,6 +818,127 @@ export const SettingsPage: React.FC = () => {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={analyticsSettings.umami_enabled}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
|
||||
</label>
|
||||
|
||||
{analyticsSettings.umami_enabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.analytics.umamiUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_url}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com"
|
||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.analytics.umamiUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.analytics.websiteId')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={analyticsSettings.umami_website_id}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.analytics.websiteIdHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.analytics.shareUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={analyticsSettings.umami_share_url}
|
||||
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
|
||||
placeholder="https://analytics.yourdomain.com/share/..."
|
||||
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.analytics.shareUrlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
||||
<p>{t('settings.analytics.umamiInfoText')}</p>
|
||||
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
|
||||
{t('settings.analytics.learnMore')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => saveAnalyticsMutation.mutate()}
|
||||
isLoading={saveAnalyticsMutation.isPending}
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
>
|
||||
{t('settings.analytics.saveAnalyticsSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Backend Analytics Info */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
|
||||
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
|
||||
<ul className="text-xs text-neutral-600 space-y-1">
|
||||
<li>• {t('settings.analytics.galleryViews')}</li>
|
||||
<li>• {t('settings.analytics.photoDownloads')}</li>
|
||||
<li>• {t('settings.analytics.uniqueVisitors')}</li>
|
||||
<li>• {t('settings.analytics.deviceTypes')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
|
||||
<p className="text-xs text-neutral-600">
|
||||
{t('settings.analytics.privacyText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import DOMPurify from 'dompurify';
|
||||
import { Loading, Card } from '../../components/common';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import { api } from '../../config/api';
|
||||
import '../../styles/prose-overrides.css';
|
||||
|
||||
export const LegalPage: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
@@ -104,7 +105,20 @@ export const LegalPage: React.FC = () => {
|
||||
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(page.content) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.content, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
|
||||
'code', 'pre', 'hr', 'div', 'span'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
KEEP_CONTENT: true,
|
||||
ADD_TAGS: ['br'], // Explicitly allow br tags
|
||||
ADD_ATTR: ['style'], // Allow style for text alignment
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
</Card>
|
||||
|
||||
@@ -162,7 +162,19 @@ export const settingsService = {
|
||||
|
||||
// Update multiple settings at once
|
||||
async updateSettings(settings: Record<string, any>): Promise<void> {
|
||||
await api.put('/admin/settings/general', settings);
|
||||
// Determine the endpoint based on setting keys
|
||||
const firstKey = Object.keys(settings)[0];
|
||||
let endpoint = '/admin/settings/general';
|
||||
|
||||
if (firstKey?.startsWith('security_')) {
|
||||
endpoint = '/admin/settings/security';
|
||||
} else if (firstKey?.startsWith('analytics_')) {
|
||||
endpoint = '/admin/settings/analytics';
|
||||
} else if (firstKey?.startsWith('branding_')) {
|
||||
endpoint = '/admin/settings/branding';
|
||||
}
|
||||
|
||||
await api.put(endpoint, settings);
|
||||
},
|
||||
|
||||
// Get storage information
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/* Prose overrides for CMS content */
|
||||
|
||||
/* Preserve line breaks in prose content */
|
||||
.prose br {
|
||||
display: block !important;
|
||||
content: "" !important;
|
||||
margin: 0.5em 0 !important;
|
||||
}
|
||||
|
||||
/* Hard breaks should create visible line breaks */
|
||||
.prose .hard-break {
|
||||
display: block !important;
|
||||
height: 0.5em !important;
|
||||
}
|
||||
|
||||
/* Ensure paragraphs have proper spacing */
|
||||
.prose p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.prose p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Code block styling */
|
||||
.prose pre {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Inline code styling */
|
||||
.prose code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Text alignment classes */
|
||||
.prose .text-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.prose .text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.prose .text-right {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.prose .text-justify {
|
||||
text-align: justify !important;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for code blocks */
|
||||
.prose .hljs {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Basic syntax highlighting colors */
|
||||
.hljs-keyword { color: #a626a4; }
|
||||
.hljs-string { color: #50a14f; }
|
||||
.hljs-comment { color: #a0a1a7; font-style: italic; }
|
||||
.hljs-number { color: #e45649; }
|
||||
.hljs-function { color: #4078f2; }
|
||||
.hljs-tag { color: #e45649; }
|
||||
.hljs-attribute { color: #986801; }
|
||||
.hljs-selector-class { color: #986801; }
|
||||
.hljs-selector-id { color: #986801; }
|
||||
|
||||
/* Preserve whitespace in content */
|
||||
.prose-preserve-whitespace {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Ensure empty paragraphs with only line breaks are visible */
|
||||
.prose p:empty::before {
|
||||
content: "\200B"; /* Zero-width space */
|
||||
display: inline;
|
||||
}
|
||||
Reference in New Issue
Block a user