Compare commits

...

7 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
12 changed files with 305 additions and 34 deletions
+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,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.1.2",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.2",
"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.2",
"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({
+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.1",
"version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.1.1",
"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.1",
"version": "1.1.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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>
+23 -1
View File
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
UNATTENDED=false
UPDATE_MODE=false
UNINSTALL_MODE=false
FORCE_ADMIN_PASSWORD_RESET=false
################################################################################
# Helper Functions
@@ -487,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!"
}
@@ -737,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
@@ -989,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}"
@@ -1256,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
@@ -1301,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();
});
});