Compare commits

...

7 Commits

Author SHA1 Message Date
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
15 changed files with 322 additions and 27 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
+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.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.1",
"version": "1.1.2",
"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.2",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+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 = {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.1.0",
"version": "1.1.1",
"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.1",
"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>
)}
+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);
};
+45 -2
View File
@@ -132,6 +132,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
}
@@ -602,7 +633,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 +700,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}
@@ -1101,6 +1133,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