Compare commits

...

14 Commits

Author SHA1 Message Date
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
Gitea Actions Bot a4595e2ab2 chore: bump backend version to 1.1.3 2025-09-22 20:50:54 +00:00
paul 0911711a37 Deduplicate external media imports by filename
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m5s
2025-09-22 22:44:52 +02:00
paul f2c7594b23 Refetch gallery data after lightbox feedback (#29)
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-22 22:32:56 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
22 changed files with 621 additions and 55 deletions
+8 -2
View File
@@ -128,10 +128,17 @@ jobs:
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
# Increment patch version
# Increment patch version and ensure tag uniqueness
git fetch --tags --quiet || true
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
NEW_PATCH=$((NEW_PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
done
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
@@ -260,4 +267,3 @@ jobs:
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
+11 -1
View File
@@ -420,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -23,7 +23,7 @@ RUN npm ci --only=production
COPY . .
# Production stage
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.1.1",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.1",
"version": "1.1.4",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.1.1",
"version": "1.1.4",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+54 -22
View File
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
const rl = readline.createInterface({
const args = process.argv.slice(2);
const hasFlag = (flag) => args.includes(flag);
const getOption = (name) => {
const index = args.indexOf(`--${name}`);
if (index !== -1 && index + 1 < args.length) {
return args[index + 1];
}
return null;
};
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
const credentialsFileArg = getOption('credentials-file');
const resolvedCredentialsFile = credentialsFileArg
? path.resolve(process.cwd(), credentialsFileArg)
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const rl = force ? null : readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function question(prompt) {
async function ask(prompt) {
if (force) {
return 'yes';
}
return new Promise((resolve) => {
rl.question(prompt, resolve);
});
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
console.log('Found admin user:', admin.username);
console.log('Email:', admin.email);
console.log('\nThis will reset the password for this admin account.');
const confirm = await question('\nDo you want to continue? (yes/no): ');
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
if (!force) {
console.log('\nThis will reset the password for this admin account.');
}
const confirm = await ask('\nDo you want to continue? (yes/no): ');
if (!force) {
const normalized = confirm.trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
}
}
// Generate new password
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
});
// Save to file
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const credentialsDir = path.dirname(resolvedCredentialsFile);
await fs.mkdir(credentialsDir, { recursive: true });
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
const resetInfo = `
========================================
PicPeak Admin Password Reset
PicPeak Admin Credentials
========================================
Password has been reset for admin account:
Your admin account has been reset with these credentials:
Username: admin
New Password: ${newPassword}
Username: ${admin.username}
Email: ${admin.email}
Password: ${newPassword}
IMPORTANT:
1. You MUST change this password on next login
IMPORTANT SECURITY NOTES:
1. You MUST change this password after first login
2. This file contains sensitive information
3. Delete this file after noting the password
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
Login URL: ${adminUrl}
Reset performed on: ${new Date().toISOString()}
========================================
`;
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
console.log('\n✅ Password reset successful!\n');
console.log('========================================');
console.log('New Credentials:');
console.log('========================================');
console.log('Username: admin');
console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. You will be required to change this password on next login');
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
console.log('3. Delete the file after noting the password');
console.log('========================================\n');
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
console.error('❌ Error resetting password:', error.message);
process.exit(1);
} finally {
rl.close();
if (rl) {
rl.close();
}
process.exit(0);
}
}
// Run the reset
resetAdminPassword();
resetAdminPassword();
+29 -3
View File
@@ -62,11 +62,38 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
let imported = 0;
// Prepare file metadata and deduplicate by filename within type (keep largest)
let skipped = 0;
const preparedFiles = [];
for (const f of files) {
try {
const stats = await fs.stat(f.full);
const segs = f.rel.split(path.sep);
let type = 'individual';
if (segs[0] === map.collages) type = 'collage';
if (segs[0] === map.individual) type = 'individual';
preparedFiles.push({ ...f, type, size: stats.size });
} catch (err) {
skipped++;
}
}
const dedupeMap = new Map();
for (const file of preparedFiles) {
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
const existing = dedupeMap.get(dedupeKey);
if (!existing || file.size > existing.size) {
if (existing) skipped++;
dedupeMap.set(dedupeKey, file);
} else {
skipped++;
}
}
let imported = 0;
// Insert photos
for (const f of files) {
for (const f of dedupeMap.values()) {
// Infer type by subfolder names
const segs = f.rel.split(path.sep);
let type = 'individual';
@@ -79,7 +106,6 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
.where({ event_id: eventId, external_relpath: f.rel })
.first();
if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full);
const inserted = await db('photos')
.insert({
+8 -2
View File
@@ -1,5 +1,6 @@
const path = require('path');
const { resolveExternalPath } = require('./externalMediaService');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -33,10 +34,15 @@ function resolvePhotoFilePath(event, photo) {
}
const storagePath = getStoragePath();
const eventsRoot = path.join(storagePath, 'events/active');
if (photo.path && photo.path.startsWith('events/active/')) {
return path.join(storagePath, photo.path);
// Legacy paths already include prefix; normalize via safe join
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
}
return path.join(storagePath, 'events/active', photo.path || '');
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
return safePathJoin(eventsRoot, relativeSegment);
}
module.exports = {
+3 -1
View File
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
- Check the console output from when you ran migrations
- File is created in the backend directory root
- File might have been deleted for security (as recommended)
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
## Best Practices
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
- [ ] Stored new password in password manager
- [ ] Tested login with new password
- [ ] Set up additional admin accounts if needed
- [ ] Configured password policies for organization
- [ ] Configured password policies for organization
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.1.0",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.1.0",
"version": "1.1.4",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.1.0",
"version": "1.1.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { PhotoRating } from './PhotoRating';
import { PhotoLikes } from './PhotoLikes';
import { PhotoFavorites } from './PhotoFavorites';
import { PhotoComments } from './PhotoComments';
import { Skeleton } from '../common';
@@ -42,13 +43,17 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
const [currentRating, setCurrentRating] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(0);
// Update local state when data loads
useEffect(() => {
if (feedbackData) {
setCurrentRating(feedbackData.my_feedback.rating || 0);
setIsLiked(feedbackData.my_feedback.liked);
setLikeCount(feedbackData.summary.like_count);
setIsLiked(Boolean(feedbackData.my_feedback.liked));
setLikeCount(Number(feedbackData.summary.like_count) || 0);
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
}
}, [feedbackData]);
@@ -64,6 +69,12 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
if (onFeedbackUpdate) onFeedbackUpdate();
};
const handleFavoriteChange = (favorited: boolean) => {
setIsFavorited(favorited);
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
if (onFeedbackUpdate) onFeedbackUpdate();
};
if (settingsLoading) {
return (
<div className={`space-y-3 ${className}`}>
@@ -77,8 +88,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
return null;
}
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments;
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments || settings.allow_favorites;
if (!hasAnyFeedbackType) {
return null;
@@ -101,8 +112,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
)}
{/* Action Buttons */}
{settings.allow_likes && (
<div className="flex items-center gap-2">
{(settings.allow_likes || settings.allow_favorites) && (
<div className="flex flex-wrap items-center gap-2">
{settings.allow_likes && (
<PhotoLikes
photoId={photoId}
@@ -114,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
onLikeChange={handleLikeChange}
/>
)}
{settings.allow_favorites && (
<PhotoFavorites
photoId={photoId}
gallerySlug={gallerySlug}
isFavorited={isFavorited}
favoriteCount={favoriteCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onFavoriteChange={handleFavoriteChange}
/>
)}
</div>
)}
@@ -291,6 +291,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
/>
)}
</>
@@ -18,6 +18,7 @@ interface PhotoLightboxProps {
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
initialShowFeedback?: boolean;
onFeedbackChange?: () => void;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -30,6 +31,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
protectionLevel = 'standard',
useEnhancedProtection = false,
initialShowFeedback = false,
onFeedbackChange,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -533,6 +535,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
gallerySlug={slug}
showComments={true}
className="space-y-4"
onFeedbackUpdate={() => {
if (onFeedbackChange) onFeedbackChange();
}}
/>
</div>
</div>
+61 -1
View File
@@ -1,4 +1,10 @@
import axios from 'axios';
import axios, { AxiosHeaders } from 'axios';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
@@ -23,6 +29,60 @@ api.interceptors.request.use(
delete config.headers?.['Content-Type'];
}
if (typeof window !== 'undefined') {
const pathSlug = resolveSlugFromRequestUrl(config.url || '');
const params = config.params as Record<string, unknown> | undefined;
const paramSlug = typeof params?.slug === 'string' ? (params.slug as string) : null;
const rawPath = (() => {
if (!config.url) return '';
try {
if (config.url.startsWith('http://') || config.url.startsWith('https://')) {
return new URL(config.url).pathname;
}
} catch (error) {
return config.url;
}
return config.url;
})();
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|| /^\/secure-images\//.test(pathname)
|| /^\/auth\/gallery\//.test(pathname);
const isGallerySessionCheck = pathname === '/auth/session'
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
if (isGalleryEndpoint || isGallerySessionCheck) {
const fallbackSlug = getActiveGallerySlug()
|| inferGallerySlugFromLocation();
const slug = pathSlug || paramSlug || fallbackSlug;
if (slug) {
const token = getGalleryToken(slug);
if (token) {
if (!config.headers) {
config.headers = new AxiosHeaders();
}
if (config.headers instanceof AxiosHeaders) {
const existing = config.headers.get('Authorization');
if (!existing) {
config.headers.set('Authorization', `Bearer ${token}`);
}
} else {
const headersRecord = config.headers as Record<string, string | undefined>;
if (!headersRecord.Authorization) {
headersRecord.Authorization = `Bearer ${token}`;
}
}
}
}
}
}
return config;
},
(error) => {
+32 -3
View File
@@ -3,6 +3,12 @@ import type { ReactNode } from 'react';
import { api } from '../config/api';
import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import {
clearActiveGallerySlug,
clearGalleryToken,
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
interface GalleryEvent {
id: number;
@@ -55,6 +61,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
useEffect(() => {
cleanupOldGalleryAuth();
const slugAtMount = getCurrentGallerySlug();
if (slugAtMount) {
setActiveGallerySlug(slugAtMount);
} else {
clearActiveGallerySlug();
}
const initialise = async () => {
const currentSlug = getCurrentGallerySlug();
@@ -63,6 +76,8 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
return;
}
setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try {
@@ -109,6 +124,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setEvent(response.event);
setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
setActiveGallerySlug(currentSlug);
return;
}
}
@@ -118,16 +137,21 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} catch (error) {
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} finally {
setIsLoading(false);
}
};
initialise();
return () => {
clearActiveGallerySlug();
};
}, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
@@ -137,7 +161,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
setIsAuthenticated(true);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
} catch (err: any) {
@@ -152,12 +180,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
clearGalleryToken(currentSlug);
}
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
}
;
clearActiveGallerySlug();
};
return (
<GalleryAuthContext.Provider
@@ -225,7 +225,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
if (!validateForm()) {
return;
}
const feedbackSettings = formData.feedback_settings;
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
@@ -239,9 +241,16 @@ export const CreateEventPageEnhanced: React.FC = () => {
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
feedback_settings: formData.feedback_settings,
feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes,
allow_comments: feedbackSettings.allow_comments,
allow_favorites: feedbackSettings.allow_favorites,
require_name_email: feedbackSettings.require_name_email,
moderate_comments: feedbackSettings.moderate_comments,
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
};
createMutation.mutate(payload);
};
+8
View File
@@ -13,6 +13,14 @@ interface CreateEventData {
expiration_days: number;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
feedback_enabled?: boolean;
allow_ratings?: boolean;
allow_likes?: boolean;
allow_comments?: boolean;
allow_favorites?: boolean;
require_name_email?: boolean;
moderate_comments?: boolean;
show_feedback_to_guests?: boolean;
}
interface UpdateEventData {
+1
View File
@@ -23,4 +23,5 @@ export const cleanupOldGalleryAuth = () => {
// Also clear session storage
sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_active_slug');
};
+110
View File
@@ -0,0 +1,110 @@
const TOKEN_STORAGE_PREFIX = 'gallery_token_';
const ACTIVE_SLUG_KEY = 'gallery_active_slug';
const isBrowser = typeof window !== 'undefined';
const getSessionStorage = (): Storage | null => {
if (!isBrowser) return null;
try {
return window.sessionStorage;
} catch (error) {
console.warn('Session storage unavailable', error);
return null;
}
};
const extractSlugFromPath = (path: string): string | null => {
if (!path) return null;
const match = path.match(/\/gallery\/([^\/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
};
export const inferGallerySlugFromLocation = (): string | null => {
if (!isBrowser) return null;
return extractSlugFromPath(window.location.pathname);
};
export const setActiveGallerySlug = (slug: string | null) => {
const storage = getSessionStorage();
if (!storage) return;
if (slug) {
storage.setItem(ACTIVE_SLUG_KEY, slug);
} else {
storage.removeItem(ACTIVE_SLUG_KEY);
}
};
export const getActiveGallerySlug = (): string | null => {
const storage = getSessionStorage();
if (!storage) return null;
return storage.getItem(ACTIVE_SLUG_KEY);
};
export const clearActiveGallerySlug = () => {
const storage = getSessionStorage();
if (!storage) return;
storage.removeItem(ACTIVE_SLUG_KEY);
};
export const storeGalleryToken = (slug: string, token: string) => {
const storage = getSessionStorage();
if (!storage || !slug) return;
storage.setItem(`${TOKEN_STORAGE_PREFIX}${slug}`, token);
};
export const getGalleryToken = (slug?: string | null): string | null => {
const storage = getSessionStorage();
if (!storage) return null;
const resolvedSlug = slug || getActiveGallerySlug() || inferGallerySlugFromLocation();
if (!resolvedSlug) return null;
return storage.getItem(`${TOKEN_STORAGE_PREFIX}${resolvedSlug}`);
};
export const clearGalleryToken = (slug?: string | null) => {
const storage = getSessionStorage();
if (!storage) return;
if (slug) {
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${slug}`);
return;
}
const active = storage.getItem(ACTIVE_SLUG_KEY);
if (active) {
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${active}`);
}
};
export const clearAllGalleryTokens = () => {
const storage = getSessionStorage();
if (!storage) return;
const keysToRemove: string[] = [];
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key && key.startsWith(TOKEN_STORAGE_PREFIX)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => storage.removeItem(key));
};
export const resolveSlugFromRequestUrl = (url?: string | null): string | null => {
if (!url) return null;
let pathname = url;
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
pathname = new URL(url).pathname;
}
} catch (error) {
// Leave pathname as provided if URL parsing fails
}
if (!pathname.startsWith('/')) {
pathname = `/${pathname}`;
}
return extractSlugFromPath(pathname);
};
+68 -3
View File
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
UNATTENDED=false
UPDATE_MODE=false
UNINSTALL_MODE=false
FORCE_ADMIN_PASSWORD_RESET=false
################################################################################
# Helper Functions
@@ -132,6 +133,37 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
ensure_storage_layout() {
local base_dir="$1"
local storage_root="$base_dir/storage"
local storage_events_dir="$storage_root/events"
mkdir -p "$storage_events_dir/active" \
"$storage_events_dir/archived" \
"$storage_root/thumbnails" \
"$storage_root/tmp"
local legacy_dir="$base_dir/events"
if [[ -d "$legacy_dir" ]]; then
log_step "Migrating legacy events directory to storage/events..."
mkdir -p "$storage_events_dir"
local existing=""
if [[ -d "$storage_events_dir" ]]; then
existing=$(ls -A "$storage_events_dir" 2>/dev/null || true)
fi
if [[ ! -d "$storage_events_dir" || -z "$existing" ]]; then
rm -rf "$storage_events_dir"
mv "$legacy_dir" "$storage_events_dir"
else
cp -a "$legacy_dir/." "$storage_events_dir/"
rm -rf "$legacy_dir"
fi
fi
mkdir -p "$storage_events_dir/active" "$storage_events_dir/archived"
}
generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
}
@@ -456,6 +488,15 @@ EOF
# Run database migrations
log_step "Running database migrations..."
docker compose exec -T backend npm run migrate
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
else
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
fi
fi
log_success "Docker installation completed!"
}
@@ -602,7 +643,8 @@ setup_native_installation() {
# Create application directory
log_step "Creating application directory..."
mkdir -p "$NATIVE_APP_DIR"/{app,events/{active,archived},logs,config}
mkdir -p "$NATIVE_APP_DIR"/{app,logs,config}
ensure_storage_layout "$NATIVE_APP_DIR"
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
# Clone repository
@@ -668,7 +710,7 @@ DATABASE_CLIENT=sqlite3
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
# Storage root (thumbnails/uploads live under this path)
STORAGE_PATH=$NATIVE_APP_DIR
STORAGE_PATH=$NATIVE_APP_DIR/storage
# Email
SMTP_ENABLED=${SMTP_HOST:+true}
@@ -705,6 +747,13 @@ EOF
log_step "Initializing database..."
cd "$NATIVE_APP_DIR/app/backend"
run_as_user "npm run migrate"
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
fi
fi
# Create systemd services
create_systemd_services
@@ -957,7 +1006,7 @@ print_success_message() {
fi
else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
echo -e "Password: ${YELLOW}(credentials file not found)${NC}"
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
fi
echo
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
@@ -1101,6 +1150,17 @@ update_native_installation() {
if ! grep -q '^FRONTEND_DIR=' "$NATIVE_APP_DIR/app/backend/.env"; then
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
ensure_storage_layout "$NATIVE_APP_DIR"
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR/storage"
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
if grep -q '^STORAGE_PATH=' "$NATIVE_APP_DIR/app/backend/.env"; then
sed -i "s|^STORAGE_PATH=.*|STORAGE_PATH=$NATIVE_APP_DIR/storage|" "$NATIVE_APP_DIR/app/backend/.env"
else
echo "STORAGE_PATH=$NATIVE_APP_DIR/storage" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
fi
# Restart services
systemctl restart picpeak-backend
@@ -1213,6 +1273,10 @@ parse_arguments() {
SMTP_PASS="$2"
shift 2
;;
--force-admin-password-reset)
FORCE_ADMIN_PASSWORD_RESET=true
shift
;;
--enable-ssl)
ENABLE_SSL=true
shift
@@ -1258,6 +1322,7 @@ Options:
--smtp-port PORT SMTP server port
--smtp-user USER SMTP username
--smtp-pass PASS SMTP password
--force-admin-password-reset Regenerate admin credentials after setup
--enable-ssl Enable HTTPS with Let's Encrypt
--port PORT Custom port (native only)
--update Update existing installation
+173
View File
@@ -0,0 +1,173 @@
import { test, expect } from '@playwright/test';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
async function createExternalGallery(page) {
const loginResponse = await page.request.post('/api/auth/admin/login', {
data: {
username: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
},
failOnStatusCode: false,
});
expect(loginResponse.ok()).toBeTruthy();
const { token } = await loginResponse.json();
expect(token).toBeTruthy();
const eventName = `External Media Playwright ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const createResponse = await page.request.post('/api/admin/events', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
event_type: 'wedding',
event_name: eventName,
event_date: eventDate,
host_name: 'External Host',
host_email: 'host@example.com',
admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD,
expiration_days: 30,
allow_user_uploads: false,
allow_downloads: true,
disable_right_click: false,
watermark_downloads: false,
feedback_enabled: true,
allow_ratings: true,
allow_likes: true,
allow_comments: true,
allow_favorites: true,
require_name_email: false,
moderate_comments: false,
show_feedback_to_guests: true,
source_mode: 'reference',
external_path: 'picsum-demo'
},
failOnStatusCode: false,
});
if (!createResponse.ok()) {
const bodyText = await createResponse.text();
throw new Error(`Failed to create event: ${createResponse.status()} ${bodyText}`);
}
const createdEvent = await createResponse.json();
expect(createdEvent?.id).toBeTruthy();
const importResponse = await page.request.post(`/api/admin/external-media/events/${createdEvent.id}/import-external`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
external_path: 'picsum-demo',
recursive: true,
},
failOnStatusCode: false,
});
expect(importResponse.ok()).toBeTruthy();
const importBody = await importResponse.json();
expect(importBody.imported).toBeGreaterThan(0);
await page.request.put(`/api/admin/feedback/events/${createdEvent.id}/feedback-settings`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
feedback_enabled: true,
allow_ratings: true,
allow_likes: true,
allow_comments: true,
allow_favorites: true,
require_name_email: false,
moderate_comments: false,
show_feedback_to_guests: true,
},
});
return {
shareLink: createdEvent.share_link,
slug: createdEvent.slug,
};
}
test.describe('External media gallery behavior', () => {
test.describe.configure({ mode: 'serial' });
test('Maintains session and favorites after reload', async ({ page, context }) => {
if (test.info().project.name.includes('mobile')) {
test.skip('Mobile viewport handling requires manual verification.');
}
const { shareLink, slug } = await createExternalGallery(page);
await page.goto(shareLink);
await page.waitForLoadState('domcontentloaded');
const passwordField = page.getByPlaceholder(/gallery password/i).first();
await expect(passwordField).toBeVisible();
await passwordField.fill(GALLERY_PASSWORD);
await page.getByRole('button', { name: /View Gallery/i }).click();
const tiles = page.locator('.relative.group');
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
const initialTileCount = await tiles.count();
expect(initialTileCount).toBeGreaterThan(0);
const firstTile = tiles.first();
await firstTile.scrollIntoViewIfNeeded();
await firstTile.getByRole('button', { name: /View full size/i }).click();
await page.evaluate(() => {
const toggle = document.querySelector('[aria-label="Toggle feedback"]');
if (toggle instanceof HTMLElement) toggle.click();
});
const favoritesButtonInLightbox = page.getByRole('button', { name: /Add to favorites|Remove from favorites/ }).first();
await expect(favoritesButtonInLightbox).toBeVisible();
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
const refetchPromise = page.waitForResponse((res) => {
return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`);
});
if (!isAlreadyFavorited) {
const favResponsePromise = page.waitForResponse((res) => {
return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`);
});
await favoritesButtonInLightbox.click();
await Promise.all([favResponsePromise, refetchPromise]);
} else {
await refetchPromise;
}
await page.getByRole('button', { name: 'Close', exact: true }).click();
await page.getByRole('button', { name: 'Favorited' }).click();
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/gallery\//);
await expect(page.locator('.relative.group').first()).toBeVisible();
await page.getByRole('button', { name: 'Favorited' }).click();
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
await page.getByRole('button', { name: 'All', exact: true }).click();
await expect(page.locator('.relative.group')).toHaveCount(initialTileCount);
const cookies = await context.cookies();
expect(cookies.some((cookie) => cookie.name === 'gallery_token')).toBeTruthy();
});
});