Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 086a4ca342 | |||
| 6de64a1df1 | |||
| 3074748bbc | |||
| 934d6ddc58 | |||
| a699a0477b | |||
| ed0243ec39 | |||
| ac31798bf5 | |||
| 65d796b9f0 | |||
| 6389b9df3f | |||
| 87d1761091 | |||
| fda132eed4 | |||
| 1cadce196b | |||
| 840b8870ec | |||
| ad495a92c4 |
@@ -10,10 +10,13 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
|
|||||||
# Database Configuration (PostgreSQL)
|
# Database Configuration (PostgreSQL)
|
||||||
DATABASE_CLIENT=pg
|
DATABASE_CLIENT=pg
|
||||||
DB_USER=picpeak
|
DB_USER=picpeak
|
||||||
|
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||||
|
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||||
DB_PASSWORD=your_secure_postgres_password_here
|
DB_PASSWORD=your_secure_postgres_password_here
|
||||||
DB_NAME=picpeak_prod
|
DB_NAME=picpeak_prod
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
|
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||||
REDIS_PASSWORD=your_secure_redis_password_here
|
REDIS_PASSWORD=your_secure_redis_password_here
|
||||||
|
|
||||||
# Admin Account (initial setup)
|
# Admin Account (initial setup)
|
||||||
|
|||||||
+192
-14
@@ -8,6 +8,7 @@ This guide covers deploying PicPeak using Docker Compose with direct port exposu
|
|||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
|
- [First Login](#first-login)
|
||||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||||
- [Maintenance](#maintenance)
|
- [Maintenance](#maintenance)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
@@ -23,8 +24,8 @@ This guide covers deploying PicPeak using Docker Compose with direct port exposu
|
|||||||
|
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/wedding-photo-sharing.git
|
git clone https://github.com/the-luap/picpeak.git
|
||||||
cd wedding-photo-sharing
|
cd picpeak
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Set up environment**
|
2. **Set up environment**
|
||||||
@@ -58,21 +59,45 @@ Generate secure values:
|
|||||||
# JWT Secret
|
# JWT Secret
|
||||||
openssl rand -base64 64
|
openssl rand -base64 64
|
||||||
|
|
||||||
# Database Password
|
# Database Password (avoid $ character - see warning below)
|
||||||
openssl rand -base64 32
|
openssl rand -base64 32 | tr -d '$'
|
||||||
|
|
||||||
# Redis Password
|
# Redis Password (avoid $ character - see warning below)
|
||||||
openssl rand -base64 32
|
openssl rand -base64 32 | tr -d '$'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
|
||||||
|
- Avoid `$` in passwords (recommended - use the commands above)
|
||||||
|
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||||
|
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||||
|
|
||||||
|
### Backend Configuration (.env)
|
||||||
Update `.env` with:
|
Update `.env` with:
|
||||||
- `JWT_SECRET` - Authentication secret
|
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||||
- `DB_PASSWORD` - PostgreSQL password
|
- `DB_PASSWORD` - PostgreSQL password
|
||||||
- `REDIS_PASSWORD` - Redis password
|
- `REDIS_PASSWORD` - Redis password
|
||||||
- `SMTP_*` - Email configuration
|
- `SMTP_*` - Email configuration
|
||||||
- `FRONTEND_URL` - Your domain URL
|
- **URL Configuration** (for backend CORS):
|
||||||
- `ADMIN_URL` - Backend admin URL
|
- `FRONTEND_URL` - Frontend URL (e.g., `http://localhost:3000` for Docker)
|
||||||
- `VITE_API_URL` - API URL for frontend
|
- `ADMIN_URL` - Admin URL (e.g., `http://localhost:3000` for Docker)
|
||||||
|
|
||||||
|
### Frontend Configuration (frontend/.env)
|
||||||
|
Create `frontend/.env` from `frontend/.env.example`:
|
||||||
|
```bash
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `frontend/.env` with:
|
||||||
|
- `VITE_API_URL` - Backend API URL
|
||||||
|
- For Docker deployment: `http://localhost:3001/api`
|
||||||
|
- For non-Docker local dev: `http://localhost:3001`
|
||||||
|
- For production with reverse proxy: `/api`
|
||||||
|
|
||||||
|
⚠️ **IMPORTANT PORT CONFIGURATION**:
|
||||||
|
- The frontend runs on port **3000** in Docker (exposed via nginx)
|
||||||
|
- The backend API runs on port **3001**
|
||||||
|
- The frontend `.env` file MUST point to the correct backend port (3001)
|
||||||
|
- Default `.env.example` is configured for Docker deployment
|
||||||
|
|
||||||
### Email Configuration Examples
|
### Email Configuration Examples
|
||||||
|
|
||||||
@@ -119,20 +144,114 @@ By default, services are exposed on:
|
|||||||
|
|
||||||
### Initial Admin Setup
|
### Initial Admin Setup
|
||||||
|
|
||||||
The admin credentials are generated during first startup. Check the logs:
|
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
|
||||||
|
|
||||||
|
#### Finding the Auto-Generated Admin Password
|
||||||
|
|
||||||
|
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
|
||||||
|
|
||||||
|
**Option 1: Search Docker logs for admin password** (recommended)
|
||||||
```bash
|
```bash
|
||||||
docker compose logs backend | grep -A 5 "Admin user created"
|
# Find the auto-generated admin password in logs
|
||||||
|
docker compose logs backend | grep "Admin password"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the helper script:
|
You should see output like:
|
||||||
|
```
|
||||||
|
✅ Admin password generated: BraveTiger6231!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: View the complete initialization logs**
|
||||||
```bash
|
```bash
|
||||||
|
# View the complete admin setup logs
|
||||||
|
docker compose logs backend | grep -A 10 "Admin user created"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Check the saved credentials file**
|
||||||
|
```bash
|
||||||
|
# The password is also saved in the backend container
|
||||||
|
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 4: Use the helper script**
|
||||||
|
```bash
|
||||||
|
# Show current admin username and email (password is hidden)
|
||||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||||
|
|
||||||
# To reset password
|
# Reset the admin password to a new random password
|
||||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Important Security Notes
|
||||||
|
|
||||||
|
- **Login requires the email address**, not username
|
||||||
|
- The admin password is only displayed once during initial setup
|
||||||
|
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||||
|
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||||
|
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||||
|
|
||||||
|
## 🔐 First Login
|
||||||
|
|
||||||
|
After deployment, you must complete the first login process which includes mandatory password change for security.
|
||||||
|
|
||||||
|
### Step 1: Locate Your Admin Password
|
||||||
|
|
||||||
|
1. **Find the auto-generated password** from the credentials file:
|
||||||
|
```bash
|
||||||
|
# Docker deployment
|
||||||
|
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
|
||||||
|
|
||||||
|
# Or directly from the host (if you have access)
|
||||||
|
cat data/ADMIN_CREDENTIALS.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Note the admin email** (default: `admin@example.com` unless customized)
|
||||||
|
|
||||||
|
### Step 2: Access Admin Panel
|
||||||
|
|
||||||
|
1. Navigate to your admin panel URL (e.g., `http://your-domain.com:3001/admin` or `https://your-domain.com/admin`)
|
||||||
|
2. Login using:
|
||||||
|
- **Email**: `admin@example.com` (or your custom admin email)
|
||||||
|
- **Password**: The auto-generated password from the logs
|
||||||
|
|
||||||
|
### Step 3: Mandatory Password Change
|
||||||
|
|
||||||
|
Upon first login, the system will **automatically redirect** you to change your password:
|
||||||
|
|
||||||
|
1. **You cannot skip this step** - it's enforced for security
|
||||||
|
2. Enter the current auto-generated password
|
||||||
|
3. Create a new secure password meeting these requirements:
|
||||||
|
- Minimum 12 characters
|
||||||
|
- At least one uppercase letter
|
||||||
|
- At least one lowercase letter
|
||||||
|
- At least one number
|
||||||
|
- At least one special character (!@#$%^&*)
|
||||||
|
|
||||||
|
### Security Best Practices for New Password
|
||||||
|
|
||||||
|
- **Use a unique password** not used elsewhere
|
||||||
|
- **Consider a password manager** for generation and storage
|
||||||
|
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
|
||||||
|
- **Avoid personal information** (names, dates, etc.)
|
||||||
|
- **Save securely** - you cannot recover this password easily
|
||||||
|
|
||||||
|
### If You Lose Access
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### Configuring Admin Email
|
||||||
|
|
||||||
|
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env
|
||||||
|
ADMIN_EMAIL=your-email@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||||
|
|
||||||
## 🔒 Reverse Proxy Setup
|
## 🔒 Reverse Proxy Setup
|
||||||
|
|
||||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||||
@@ -325,6 +444,54 @@ docker exec picpeak-backend npm run migrate
|
|||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
|
#### 502 Bad Gateway / Login Failures
|
||||||
|
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
|
||||||
|
|
||||||
|
1. **CORS Configuration Errors**:
|
||||||
|
```bash
|
||||||
|
# WRONG - Missing port will cause CORS errors
|
||||||
|
FRONTEND_URL=http://10.0.252.12
|
||||||
|
|
||||||
|
# CORRECT - Include the port you're accessing from
|
||||||
|
FRONTEND_URL=http://10.0.252.12:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
|
||||||
|
|
||||||
|
2. **After Container Restarts**:
|
||||||
|
- Nginx may have cached old container IPs
|
||||||
|
- Solution: `docker restart picpeak-frontend`
|
||||||
|
- Always wait 30-60 seconds for health checks
|
||||||
|
|
||||||
|
3. **Backend Not Starting After Migrations**:
|
||||||
|
- The logs may only show migrations completed
|
||||||
|
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
|
||||||
|
- Should see `node server.js` process
|
||||||
|
|
||||||
|
4. **Login After Fresh Install**:
|
||||||
|
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
|
||||||
|
- Email: `admin@example.com` (or your custom admin email from .env)
|
||||||
|
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
|
||||||
|
- Remember: Password MUST be changed on first login
|
||||||
|
|
||||||
|
5. **Complete Fix Sequence**:
|
||||||
|
```bash
|
||||||
|
# 1. Fix your .env file URLs
|
||||||
|
# 2. Full restart
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 3. Wait for healthy status
|
||||||
|
sleep 60
|
||||||
|
docker ps # All should show (healthy)
|
||||||
|
|
||||||
|
# 4. Test backend directly
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
|
# 5. Test through frontend
|
||||||
|
curl http://localhost:3000/api/public/settings
|
||||||
|
```
|
||||||
|
|
||||||
#### Port Already in Use
|
#### Port Already in Use
|
||||||
```bash
|
```bash
|
||||||
# Check what's using the port
|
# Check what's using the port
|
||||||
@@ -336,6 +503,17 @@ FRONTEND_PORT=3002
|
|||||||
BACKEND_PORT=3003
|
BACKEND_PORT=3003
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Docker Compose Variable Substitution Errors
|
||||||
|
If you see warnings like:
|
||||||
|
```
|
||||||
|
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
|
||||||
|
```
|
||||||
|
|
||||||
|
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
|
||||||
|
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
|
||||||
|
2. **Alternative**: Escape `$` as `$$` in your .env file
|
||||||
|
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
|
||||||
|
|
||||||
#### Permission Errors
|
#### Permission Errors
|
||||||
```bash
|
```bash
|
||||||
# Fix ownership
|
# Fix ownership
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
|||||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||||
- 🔍 **Smart Search** - Find photos quickly
|
- 🔍 **Smart Search** - Find photos quickly
|
||||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||||
|
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||||
|
|
||||||
### Technical Excellence
|
### Technical Excellence
|
||||||
- 🐳 **Docker Ready** - Deploy in minutes
|
- 🐳 **Docker Ready** - Deploy in minutes
|
||||||
@@ -71,7 +72,7 @@ docker-compose up -d
|
|||||||
|
|
||||||
## 📖 Documentation
|
## 📖 Documentation
|
||||||
|
|
||||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||||
- 📜 [**License**](LICENSE) - MIT License
|
- 📜 [**License**](LICENSE) - MIT License
|
||||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||||
@@ -177,6 +178,17 @@ Organize and manage your photo galleries with intuitive event management tools.
|
|||||||
|
|
||||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||||
|
|
||||||
|
### 🚧 Beta Features (Use at your own risk)
|
||||||
|
|
||||||
|
These features are currently in beta testing and may have limited functionality or stability:
|
||||||
|
|
||||||
|
| Feature | Description | Status |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
|
||||||
|
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||||
|
|
||||||
|
### 📋 Future Enhancements
|
||||||
|
|
||||||
| Feature | Description | Priority | Status |
|
| Feature | Description | Priority | Status |
|
||||||
|---------|-------------|----------|---------|
|
|---------|-------------|----------|---------|
|
||||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||||
@@ -209,7 +221,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
|||||||
## 🚀 Ready to Get Started?
|
## 🚀 Ready to Get Started?
|
||||||
|
|
||||||
1. ⭐ **Star this repository** to show your support
|
1. ⭐ **Star this repository** to show your support
|
||||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
|
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
|
||||||
3. 🐛 Report issues or request features
|
3. 🐛 Report issues or request features
|
||||||
4. 🤝 Join our community and contribute!
|
4. 🤝 Join our community and contribute!
|
||||||
|
|
||||||
@@ -219,6 +231,6 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
|||||||
Made with ❤️ by photographers, for photographers
|
Made with ❤️ by photographers, for photographers
|
||||||
<br>
|
<br>
|
||||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -18,9 +18,13 @@ exports.up = async function(knex) {
|
|||||||
const generatedPassword = generateReadablePassword();
|
const generatedPassword = generateReadablePassword();
|
||||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||||
|
|
||||||
|
// Get admin credentials from environment or use defaults
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
|
|
||||||
await knex('admin_users').insert({
|
await knex('admin_users').insert({
|
||||||
username: 'admin',
|
username: adminUsername,
|
||||||
email: 'admin@example.com',
|
email: adminEmail,
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
created_at: new Date()
|
created_at: new Date()
|
||||||
});
|
});
|
||||||
@@ -36,7 +40,7 @@ PicPeak Admin Credentials
|
|||||||
|
|
||||||
Your admin account has been created with these credentials:
|
Your admin account has been created with these credentials:
|
||||||
|
|
||||||
Username: admin
|
Email: ${adminEmail}
|
||||||
Password: ${generatedPassword}
|
Password: ${generatedPassword}
|
||||||
|
|
||||||
IMPORTANT SECURITY NOTES:
|
IMPORTANT SECURITY NOTES:
|
||||||
@@ -47,6 +51,8 @@ IMPORTANT SECURITY NOTES:
|
|||||||
|
|
||||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||||
|
|
||||||
|
Login with the email address shown above
|
||||||
|
|
||||||
Generated on: ${new Date().toISOString()}
|
Generated on: ${new Date().toISOString()}
|
||||||
========================================
|
========================================
|
||||||
`;
|
`;
|
||||||
@@ -65,7 +71,7 @@ Generated on: ${new Date().toISOString()}
|
|||||||
console.log('\n========================================');
|
console.log('\n========================================');
|
||||||
console.log('✅ Admin user created successfully!');
|
console.log('✅ Admin user created successfully!');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('Username: admin');
|
console.log(`Email: ${adminEmail}`);
|
||||||
console.log(`Password: ${generatedPassword}`);
|
console.log(`Password: ${generatedPassword}`);
|
||||||
console.log('\n⚠️ IMPORTANT:');
|
console.log('\n⚠️ IMPORTANT:');
|
||||||
console.log('1. Save these credentials securely');
|
console.log('1. Save these credentials securely');
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Fix missing columns identified in GitHub issues
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Adding missing columns to database tables...');
|
||||||
|
|
||||||
|
// Add must_change_password column to admin_users table
|
||||||
|
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||||
|
if (!hasMustChangePassword) {
|
||||||
|
console.log('Adding must_change_password column to admin_users table...');
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.boolean('must_change_password').defaultTo(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add password_changed_at column to admin_users table
|
||||||
|
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||||
|
if (!hasPasswordChangedAt) {
|
||||||
|
console.log('Adding password_changed_at column to admin_users table...');
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.datetime('password_changed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add require_moderation column to event_feedback_settings table
|
||||||
|
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||||
|
if (hasEventFeedbackSettings) {
|
||||||
|
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||||
|
if (!hasRequireModeration) {
|
||||||
|
console.log('Adding require_moderation column to event_feedback_settings table...');
|
||||||
|
await knex.schema.table('event_feedback_settings', (table) => {
|
||||||
|
table.boolean('require_moderation').defaultTo(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add host_name column to events table if missing
|
||||||
|
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||||
|
if (!hasHostName) {
|
||||||
|
console.log('Adding host_name column to events table...');
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.string('host_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Missing columns have been added successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing added columns...');
|
||||||
|
|
||||||
|
// Remove must_change_password column from admin_users table
|
||||||
|
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||||
|
if (hasMustChangePassword) {
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.dropColumn('must_change_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove password_changed_at column from admin_users table
|
||||||
|
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||||
|
if (hasPasswordChangedAt) {
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.dropColumn('password_changed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove require_moderation column from event_feedback_settings table
|
||||||
|
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||||
|
if (hasEventFeedbackSettings) {
|
||||||
|
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||||
|
if (hasRequireModeration) {
|
||||||
|
await knex.schema.table('event_feedback_settings', (table) => {
|
||||||
|
table.dropColumn('require_moderation');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove host_name column from events table
|
||||||
|
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||||
|
if (hasHostName) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('host_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Columns removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Add download control features to events table
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Adding download control columns to events table...');
|
||||||
|
|
||||||
|
// Add download control columns to events table
|
||||||
|
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||||
|
if (!hasAllowDownloads) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.boolean('allow_downloads').defaultTo(true);
|
||||||
|
table.boolean('disable_right_click').defaultTo(false);
|
||||||
|
table.boolean('watermark_downloads').defaultTo(false);
|
||||||
|
table.text('watermark_text');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add download control settings to app_settings
|
||||||
|
const downloadSettingExists = await knex('app_settings')
|
||||||
|
.where('setting_key', 'default_allow_downloads')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!downloadSettingExists) {
|
||||||
|
await knex('app_settings').insert([
|
||||||
|
{
|
||||||
|
setting_key: 'default_allow_downloads',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'gallery'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'default_disable_right_click',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'gallery'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'default_watermark_downloads',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'gallery'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Download control features added successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing download control columns...');
|
||||||
|
|
||||||
|
// Remove app settings
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'default_allow_downloads',
|
||||||
|
'default_disable_right_click',
|
||||||
|
'default_watermark_downloads'
|
||||||
|
])
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Remove columns from events table
|
||||||
|
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||||
|
if (hasAllowDownloads) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('allow_downloads');
|
||||||
|
table.dropColumn('disable_right_click');
|
||||||
|
table.dropColumn('watermark_downloads');
|
||||||
|
table.dropColumn('watermark_text');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Download control columns removed');
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.98",
|
"version": "1.0.102",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.98",
|
"version": "1.0.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.98",
|
"version": "1.0.102",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ router.get('/health', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
const [failedEmails] = await db('email_queue')
|
const [failedEmails] = await db('email_queue')
|
||||||
.where('status', 'failed')
|
.where('status', 'failed')
|
||||||
.where('created_at', '>=', twentyFourHoursAgo.toISOString())
|
.where('scheduled_at', '>=', twentyFourHoursAgo.toISOString())
|
||||||
.count('* as count');
|
.count('* as count');
|
||||||
|
|
||||||
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, query, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
@@ -12,7 +13,6 @@ const { queueEmail } = require('../services/emailProcessor');
|
|||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
// formatDate import removed - dates are formatted by email processor
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
@@ -27,7 +27,11 @@ router.post('/', adminAuth, [
|
|||||||
body('color_theme').optional().trim(),
|
body('color_theme').optional().trim(),
|
||||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
body('host_name').notEmpty().trim()
|
body('host_name').notEmpty().trim(),
|
||||||
|
body('allow_downloads').optional().isBoolean(),
|
||||||
|
body('disable_right_click').optional().isBoolean(),
|
||||||
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
|
body('watermark_text').optional().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Create event request body:', req.body);
|
console.log('Create event request body:', req.body);
|
||||||
@@ -49,9 +53,26 @@ router.post('/', adminAuth, [
|
|||||||
color_theme = null,
|
color_theme = null,
|
||||||
expiration_days = 30,
|
expiration_days = 30,
|
||||||
allow_user_uploads = false,
|
allow_user_uploads = false,
|
||||||
upload_category_id = null
|
upload_category_id = null,
|
||||||
|
allow_downloads = true,
|
||||||
|
disable_right_click = false,
|
||||||
|
watermark_downloads = false,
|
||||||
|
watermark_text = null
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('Download control values:', {
|
||||||
|
allow_downloads,
|
||||||
|
disable_right_click,
|
||||||
|
watermark_downloads,
|
||||||
|
watermark_text,
|
||||||
|
types: {
|
||||||
|
allow_downloads: typeof allow_downloads,
|
||||||
|
disable_right_click: typeof disable_right_click,
|
||||||
|
watermark_downloads: typeof watermark_downloads
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
eventName: event_name
|
eventName: event_name
|
||||||
@@ -121,7 +142,11 @@ router.post('/', adminAuth, [
|
|||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
upload_category_id
|
upload_category_id,
|
||||||
|
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||||
|
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||||
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
|
watermark_text
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -341,7 +366,11 @@ router.put('/:id', adminAuth, [
|
|||||||
// Check if it's a number or can be converted to a valid integer
|
// Check if it's a number or can be converted to a valid integer
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
return !isNaN(num) && Number.isInteger(num);
|
return !isNaN(num) && Number.isInteger(num);
|
||||||
}).withMessage('hero_photo_id must be an integer or null')
|
}).withMessage('hero_photo_id must be an integer or null'),
|
||||||
|
body('allow_downloads').optional().isBoolean(),
|
||||||
|
body('disable_right_click').optional().isBoolean(),
|
||||||
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
|
body('watermark_text').optional().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -526,16 +555,15 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate new password
|
// Generate new password
|
||||||
const { generatePassword } = require('../utils/passwordGenerator');
|
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||||
const newPassword = generatePassword();
|
const newPassword = generateReadablePassword();
|
||||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
// Update event with new password
|
// Update event with new password
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
.update({
|
.update({
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash
|
||||||
updated_at: new Date()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
|
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug })
|
.where({ slug })
|
||||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link')
|
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
||||||
|
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -78,7 +79,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
is_active: event.is_active,
|
is_active: event.is_active,
|
||||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||||
requires_password: true,
|
requires_password: true,
|
||||||
color_theme: event.color_theme
|
color_theme: event.color_theme,
|
||||||
|
allow_downloads: event.allow_downloads !== false,
|
||||||
|
disable_right_click: event.disable_right_click === true,
|
||||||
|
watermark_downloads: event.watermark_downloads === true,
|
||||||
|
watermark_text: event.watermark_text
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching gallery info:', error);
|
console.error('Error fetching gallery info:', error);
|
||||||
@@ -125,7 +130,11 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
welcome_message: req.event.welcome_message,
|
welcome_message: req.event.welcome_message,
|
||||||
color_theme: req.event.color_theme,
|
color_theme: req.event.color_theme,
|
||||||
expires_at: req.event.expires_at,
|
expires_at: req.event.expires_at,
|
||||||
hero_photo_id: req.event.hero_photo_id
|
hero_photo_id: req.event.hero_photo_id,
|
||||||
|
allow_downloads: req.event.allow_downloads !== false,
|
||||||
|
disable_right_click: req.event.disable_right_click === true,
|
||||||
|
watermark_downloads: req.event.watermark_downloads === true,
|
||||||
|
watermark_text: req.event.watermark_text
|
||||||
},
|
},
|
||||||
categories: categories.map(cat => ({
|
categories: categories.map(cat => ({
|
||||||
id: cat.id,
|
id: cat.id,
|
||||||
@@ -157,6 +166,11 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (req.event.allow_downloads === false) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
.where({ id: photoId, event_id: req.event.id })
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
.first();
|
.first();
|
||||||
@@ -205,6 +219,11 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
// Download all photos as ZIP
|
// Download all photos as ZIP
|
||||||
router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (req.event.allow_downloads === false) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch photos with category information
|
// Fetch photos with category information
|
||||||
const photos = await db('photos')
|
const photos = await db('photos')
|
||||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Backend API URL
|
# Backend API URL
|
||||||
# For local development:
|
# For local development with Docker:
|
||||||
VITE_API_URL=http://localhost:3001
|
VITE_API_URL=http://localhost:3001/api
|
||||||
|
|
||||||
|
# For local development without Docker:
|
||||||
|
# VITE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
# For production behind reverse proxy (Traefik, nginx, etc):
|
# For production behind reverse proxy (Traefik, nginx, etc):
|
||||||
# VITE_API_URL=/api
|
# VITE_API_URL=/api
|
||||||
|
|||||||
+4
-4
@@ -39,7 +39,7 @@ server {
|
|||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -53,7 +53,7 @@ server {
|
|||||||
|
|
||||||
# Photo serving proxy
|
# Photo serving proxy
|
||||||
location /photos {
|
location /photos {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -67,7 +67,7 @@ server {
|
|||||||
|
|
||||||
# Thumbnail serving proxy
|
# Thumbnail serving proxy
|
||||||
location /thumbnails {
|
location /thumbnails {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -81,7 +81,7 @@ server {
|
|||||||
|
|
||||||
# Uploads serving proxy (logos, favicons, watermarks)
|
# Uploads serving proxy (logos, favicons, watermarks)
|
||||||
location /uploads {
|
location /uploads {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.98",
|
"version": "1.0.102",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.98",
|
"version": "1.0.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.98",
|
"version": "1.0.102",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -363,7 +363,11 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{event.share_link && (
|
{event.share_link && (
|
||||||
<a
|
<a
|
||||||
href={event.share_link}
|
href={
|
||||||
|
event.share_link.startsWith('http')
|
||||||
|
? event.share_link
|
||||||
|
: `/gallery/${event.share_link}`
|
||||||
|
}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
||||||
|
|||||||
@@ -479,7 +479,11 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
{event.share_link ? (
|
{event.share_link ? (
|
||||||
<a
|
<a
|
||||||
href={event.share_link}
|
href={
|
||||||
|
event.share_link.startsWith('http')
|
||||||
|
? event.share_link
|
||||||
|
: `/gallery/${event.share_link}`
|
||||||
|
}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
|
|||||||
Reference in New Issue
Block a user