This commit is contained in:
		
							
								
								
									
										42
									
								
								.gitea/workflows/docker-publish.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								.gitea/workflows/docker-publish.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
name: Docker Build and Publish
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches: [ main ]
 | 
			
		||||
    tags: [ 'v*' ]
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches: [ main ]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: actions/checkout@v3
 | 
			
		||||
 | 
			
		||||
      - name: Docker meta
 | 
			
		||||
        id: meta
 | 
			
		||||
        uses: docker/metadata-action@v4
 | 
			
		||||
        with:
 | 
			
		||||
          images: git.dws.rip/${{ github.repository }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            type=ref,event=branch
 | 
			
		||||
            type=ref,event=pr
 | 
			
		||||
            type=semver,pattern={{version}}
 | 
			
		||||
            type=semver,pattern={{major}}.{{minor}}
 | 
			
		||||
            type=sha
 | 
			
		||||
 | 
			
		||||
      - name: Login to Gitea Container Registry
 | 
			
		||||
        uses: docker/login-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          registry: git.dws.rip
 | 
			
		||||
          username: ${{ github.actor }}
 | 
			
		||||
          password: ${{ secrets.GITEA_TOKEN }}
 | 
			
		||||
 | 
			
		||||
      - name: Build and push
 | 
			
		||||
        uses: docker/build-push-action@v4
 | 
			
		||||
        with:
 | 
			
		||||
          context: .
 | 
			
		||||
          push: ${{ github.event_name != 'pull_request' }}
 | 
			
		||||
          tags: ${{ steps.meta.outputs.tags }}
 | 
			
		||||
          labels: ${{ steps.meta.outputs.labels }} 
 | 
			
		||||
							
								
								
									
										28
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
FROM python:3.12-slim
 | 
			
		||||
 | 
			
		||||
# Install system dependencies
 | 
			
		||||
RUN apt-get update && apt-get install -y --no-install-recommends \
 | 
			
		||||
    curl \
 | 
			
		||||
    && rm -rf /var/lib/apt/lists/*
 | 
			
		||||
 | 
			
		||||
# Create non-root user
 | 
			
		||||
RUN useradd -m -r -s /bin/bash appuser
 | 
			
		||||
 | 
			
		||||
# Create necessary directories
 | 
			
		||||
WORKDIR /app
 | 
			
		||||
RUN mkdir -p /app/uploads /app/thumbnails
 | 
			
		||||
RUN chown -R appuser:appuser /app
 | 
			
		||||
 | 
			
		||||
# Install Python dependencies
 | 
			
		||||
COPY requirements.txt .
 | 
			
		||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
 | 
			
		||||
 | 
			
		||||
# Copy application files
 | 
			
		||||
COPY --chown=appuser:appuser templates /app/templates
 | 
			
		||||
COPY --chown=appuser:appuser app.py config.py models.py steganography.py ./
 | 
			
		||||
 | 
			
		||||
# Switch to non-root user
 | 
			
		||||
USER appuser
 | 
			
		||||
 | 
			
		||||
# Use gunicorn for production
 | 
			
		||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "app:app"]
 | 
			
		||||
							
								
								
									
										117
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
			
		||||
# Spectra
 | 
			
		||||
 | 
			
		||||
> A variation on the masonry grid image gallery with the row alighment constraint removed. Oh, it also has an admin interface so you can set it up and forget it.
 | 
			
		||||
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
- **Color Analysis**: Automatically extracts color palettes from images to create cohesive galleries
 | 
			
		||||
- **Smart Thumbnails**: Generates and caches responsive thumbnails in multiple sizes
 | 
			
		||||
- **EXIF Preservation**: Maintains all photo metadata through processing
 | 
			
		||||
- **Ownership Verification**: Embeds steganographic proofs in images
 | 
			
		||||
- **Live Configuration**: Hot-reload config changes without restarts
 | 
			
		||||
- **Production Ready**: Fully Dockerized with Traefik integration
 | 
			
		||||
 | 
			
		||||
## Quick Start
 | 
			
		||||
 | 
			
		||||
### Local Development
 | 
			
		||||
 | 
			
		||||
Clone the repository
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
git clone https://git.dws.rip/your-username/spectra
 | 
			
		||||
cd spectra
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Set up Python virtual environment
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
python -m venv venv
 | 
			
		||||
source venv/bin/activate # or venv\Scripts\activate on Windows
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Install dependencies
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
pip install -r requirements.txt
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Create config from template
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
cp config.example.toml config.toml
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Run development server
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
python app.py
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Production Deployment
 | 
			
		||||
 | 
			
		||||
Create required network
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
docker network create traefik-public
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Configure your domain
 | 
			
		||||
```bash
 | 
			
		||||
sed -i 's/photos.dws.rip/your.domain.here/g' docker-compose.yml
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Launch
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
docker-compose up -d
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Configuration
 | 
			
		||||
 | 
			
		||||
### Essential Settings
 | 
			
		||||
```toml
 | 
			
		||||
[server]
 | 
			
		||||
host = "0.0.0.0"
 | 
			
		||||
port = 5000
 | 
			
		||||
[security]
 | 
			
		||||
max_upload_size_mb = 80
 | 
			
		||||
rate_limit = 100 # requests per minute
 | 
			
		||||
[admin]
 | 
			
		||||
password = "change-this-password" # Required
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
See `config.example.toml` for all available options.
 | 
			
		||||
 | 
			
		||||
## Directory Structure
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
spectra/
 | 
			
		||||
├── app.py # Application entry point
 | 
			
		||||
├── config.py # Configuration management
 | 
			
		||||
├── models.py # Database models
 | 
			
		||||
├── steganography.py # Image verification
 | 
			
		||||
├── templates/ # Jinja2 templates
 | 
			
		||||
├── uploads/ # Original images
 | 
			
		||||
└── thumbnails/ # Generated thumbnails
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## API Reference
 | 
			
		||||
 | 
			
		||||
### Endpoints
 | 
			
		||||
 | 
			
		||||
#### Public Endpoints
 | 
			
		||||
- `GET /` - Main gallery view
 | 
			
		||||
- `GET /api/images` - Get paginated image list
 | 
			
		||||
- `GET /verify/<filename>` - Verify image authenticity
 | 
			
		||||
 | 
			
		||||
#### Admin Endpoints
 | 
			
		||||
- `POST /admin/login` - Admin authentication
 | 
			
		||||
- `POST /admin/upload` - Upload new images
 | 
			
		||||
- `POST /admin/update_photo/<id>` - Update image metadata
 | 
			
		||||
- `POST /admin/delete_photo/<id>` - Delete image
 | 
			
		||||
 | 
			
		||||
## Environment Variables
 | 
			
		||||
 | 
			
		||||
- `FLASK_ENV`: Set to 'production' in production
 | 
			
		||||
- `WORKERS`: Number of Gunicorn workers (default: 4)
 | 
			
		||||
- `PORT`: Override default port (default: 5000)
 | 
			
		||||
							
								
								
									
										199
									
								
								app.py
									
									
									
									
									
								
							
							
						
						
									
										199
									
								
								app.py
									
									
									
									
									
								
							@@ -11,6 +11,15 @@ from colorthief import ColorThief
 | 
			
		||||
import colorsys
 | 
			
		||||
from steganography import embed_message, extract_message
 | 
			
		||||
import hashlib
 | 
			
		||||
from watchdog.observers import Observer
 | 
			
		||||
from watchdog.events import FileSystemEventHandler
 | 
			
		||||
import toml
 | 
			
		||||
import threading
 | 
			
		||||
import time
 | 
			
		||||
import atexit
 | 
			
		||||
from flask_limiter import Limiter
 | 
			
		||||
from flask_limiter.util import get_remote_address
 | 
			
		||||
import secrets
 | 
			
		||||
 | 
			
		||||
app = Flask(__name__)
 | 
			
		||||
app.secret_key = os.urandom(24)
 | 
			
		||||
@@ -32,6 +41,81 @@ app.config['MAX_CONTENT_LENGTH'] = 80 * 1024 * 1024  # 80MB limit
 | 
			
		||||
scheduler = BackgroundScheduler()
 | 
			
		||||
scheduler.start()
 | 
			
		||||
 | 
			
		||||
DEFAULT_CONFIG = {
 | 
			
		||||
    'server': {
 | 
			
		||||
        'host': '0.0.0.0',
 | 
			
		||||
        'port': 5000
 | 
			
		||||
    },
 | 
			
		||||
    'directories': {
 | 
			
		||||
        'upload': 'uploads',
 | 
			
		||||
        'thumbnail': 'thumbnails'
 | 
			
		||||
    },
 | 
			
		||||
    'admin': {
 | 
			
		||||
        'password': 'changeme'  # Default password
 | 
			
		||||
    },
 | 
			
		||||
    'appearance': {
 | 
			
		||||
        'accent_color': '#007bff'
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def merge_configs(default, user):
 | 
			
		||||
    """Recursively merge user config with default config"""
 | 
			
		||||
    result = default.copy()
 | 
			
		||||
    for key, value in user.items():
 | 
			
		||||
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
 | 
			
		||||
            result[key] = merge_configs(result[key], value)
 | 
			
		||||
        else:
 | 
			
		||||
            result[key] = value
 | 
			
		||||
    return result
 | 
			
		||||
 | 
			
		||||
class ConfigFileHandler(FileSystemEventHandler):
 | 
			
		||||
    def on_modified(self, event):
 | 
			
		||||
        if event.src_path.endswith('config.toml'):
 | 
			
		||||
            global config
 | 
			
		||||
            try:
 | 
			
		||||
                new_config = load_or_create_config()
 | 
			
		||||
                config.update(new_config)
 | 
			
		||||
                app.logger.info("Configuration reloaded successfully")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                app.logger.error(f"Error reloading configuration: {e}")
 | 
			
		||||
 | 
			
		||||
def load_or_create_config():
 | 
			
		||||
    config_path = 'config.toml'
 | 
			
		||||
    
 | 
			
		||||
    try:
 | 
			
		||||
        if os.path.exists(config_path):
 | 
			
		||||
            with open(config_path, 'r') as f:
 | 
			
		||||
                user_config = toml.load(f)
 | 
			
		||||
        else:
 | 
			
		||||
            user_config = {}
 | 
			
		||||
            
 | 
			
		||||
        # Merge with defaults
 | 
			
		||||
        final_config = merge_configs(DEFAULT_CONFIG, user_config)
 | 
			
		||||
        
 | 
			
		||||
        # Save complete config back to file
 | 
			
		||||
        with open(config_path, 'w') as f:
 | 
			
		||||
            toml.dump(final_config, f)
 | 
			
		||||
            
 | 
			
		||||
        return final_config
 | 
			
		||||
        
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        app.logger.error(f"Error loading config: {e}")
 | 
			
		||||
        return DEFAULT_CONFIG.copy()
 | 
			
		||||
 | 
			
		||||
def start_config_watcher():
 | 
			
		||||
    observer = Observer()
 | 
			
		||||
    observer.schedule(ConfigFileHandler(), path='.', recursive=False)
 | 
			
		||||
    observer.start()
 | 
			
		||||
    
 | 
			
		||||
    # Register cleanup on app shutdown
 | 
			
		||||
    def cleanup():
 | 
			
		||||
        observer.stop()
 | 
			
		||||
        observer.join()
 | 
			
		||||
    
 | 
			
		||||
    atexit.register(cleanup)
 | 
			
		||||
 | 
			
		||||
start_config_watcher()
 | 
			
		||||
 | 
			
		||||
def allowed_file(filename):
 | 
			
		||||
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
 | 
			
		||||
 | 
			
		||||
@@ -307,6 +391,121 @@ def verify_image(filename):
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        return jsonify({'verified': False, 'error': str(e)})
 | 
			
		||||
 | 
			
		||||
limiter = Limiter(
 | 
			
		||||
    app=app,
 | 
			
		||||
    key_func=get_remote_address,
 | 
			
		||||
    default_limits=["100 per minute"],
 | 
			
		||||
    storage_uri="memory://"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# Generate a strong secret key at startup
 | 
			
		||||
app.secret_key = secrets.token_hex(32)
 | 
			
		||||
 | 
			
		||||
# Add security headers middleware
 | 
			
		||||
@app.after_request
 | 
			
		||||
def add_security_headers(response):
 | 
			
		||||
    response.headers['X-Content-Type-Options'] = 'nosniff'
 | 
			
		||||
    response.headers['X-Frame-Options'] = 'SAMEORIGIN'
 | 
			
		||||
    response.headers['X-XSS-Protection'] = '1; mode=block'
 | 
			
		||||
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
 | 
			
		||||
    response.headers['Content-Security-Policy'] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';"
 | 
			
		||||
    return response
 | 
			
		||||
 | 
			
		||||
# Add rate limiting to sensitive endpoints
 | 
			
		||||
@app.route('/admin/login', methods=['POST'])
 | 
			
		||||
@limiter.limit("5 per minute")
 | 
			
		||||
def admin_login():
 | 
			
		||||
    if request.method == 'POST':
 | 
			
		||||
        if request.form['password'] == config['admin']['password']:
 | 
			
		||||
            session['logged_in'] = True
 | 
			
		||||
            return redirect(url_for('admin'))
 | 
			
		||||
        else:
 | 
			
		||||
            flash('Invalid password')
 | 
			
		||||
    return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
 | 
			
		||||
 | 
			
		||||
@app.route('/admin/upload', methods=['POST'])
 | 
			
		||||
@limiter.limit("10 per minute")
 | 
			
		||||
def admin_upload():
 | 
			
		||||
    if 'logged_in' not in session:
 | 
			
		||||
        return redirect(url_for('admin_login'))
 | 
			
		||||
    
 | 
			
		||||
    if 'file' not in request.files:
 | 
			
		||||
        flash('No file part')
 | 
			
		||||
        return redirect(url_for('admin'))
 | 
			
		||||
    
 | 
			
		||||
    file = request.files['file']
 | 
			
		||||
    if file.filename == '':
 | 
			
		||||
        flash('No selected file')
 | 
			
		||||
        return redirect(url_for('admin'))
 | 
			
		||||
    
 | 
			
		||||
    if file and allowed_file(file.filename):
 | 
			
		||||
        filename = secure_filename(file.filename)
 | 
			
		||||
        file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
 | 
			
		||||
        file.save(file_path)
 | 
			
		||||
 | 
			
		||||
        # Extract EXIF data
 | 
			
		||||
        exif = None
 | 
			
		||||
        exifraw = None
 | 
			
		||||
        with Image.open(file_path) as img:
 | 
			
		||||
            exifraw = img.info['exif']
 | 
			
		||||
            width, height = img.size
 | 
			
		||||
            exif = {
 | 
			
		||||
                ExifTags.TAGS[k]: v
 | 
			
		||||
                for k, v in img._getexif().items()
 | 
			
		||||
                if k in ExifTags.TAGS
 | 
			
		||||
            }
 | 
			
		||||
        
 | 
			
		||||
        # Generate a unique key for the image
 | 
			
		||||
        unique_key = hashlib.sha256(f"{filename}{datetime.now().isoformat()}".encode()).hexdigest()[:16]
 | 
			
		||||
        
 | 
			
		||||
        # Embed the unique key into the image
 | 
			
		||||
        try:
 | 
			
		||||
            embed_message(file_path, unique_key, exifraw)
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            flash(f"Error embedding key: {str(e)}")
 | 
			
		||||
            os.remove(file_path)
 | 
			
		||||
            return redirect(url_for('admin'))
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
        # Generate thumbnails
 | 
			
		||||
        generate_thumbnails(filename)
 | 
			
		||||
        
 | 
			
		||||
        # Get image dimensions
 | 
			
		||||
        with Image.open(file_path) as img:
 | 
			
		||||
            width, height = img.size
 | 
			
		||||
 | 
			
		||||
        exposure_time = exif['ExposureTime']
 | 
			
		||||
        if isinstance(exposure_time, tuple):
 | 
			
		||||
            exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
 | 
			
		||||
        else:
 | 
			
		||||
            exposure_fraction = f"1/{int(1/float(exposure_time))}"
 | 
			
		||||
 | 
			
		||||
        # Create database entry
 | 
			
		||||
        db_session = DBSession()
 | 
			
		||||
        new_photo = Photo(
 | 
			
		||||
            input_filename=filename,
 | 
			
		||||
            thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
 | 
			
		||||
            focal_length=str(exif.get('FocalLengthIn35mmFilm', exif.get('FocalLength', ''))),
 | 
			
		||||
            aperture=str(exif.get('FNumber', '')),
 | 
			
		||||
            shutter_speed=exposure_fraction,
 | 
			
		||||
            date_taken=datetime.strptime(str(exif.get('DateTime', '1970:01:01 00:00:00')), '%Y:%m:%d %H:%M:%S'),
 | 
			
		||||
            iso=int(exif.get('ISOSpeedRatings', 0)),
 | 
			
		||||
            orientation=int(exif.get('Orientation', 1)),
 | 
			
		||||
            width=width,
 | 
			
		||||
            height=height,
 | 
			
		||||
            highlight_color=get_highlight_color(THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"),
 | 
			
		||||
            unique_key=unique_key
 | 
			
		||||
        )
 | 
			
		||||
        db_session.add(new_photo)
 | 
			
		||||
        db_session.commit()
 | 
			
		||||
        db_session.close()
 | 
			
		||||
        
 | 
			
		||||
        flash('File uploaded successfully')
 | 
			
		||||
        return redirect(url_for('admin'))
 | 
			
		||||
    
 | 
			
		||||
    flash('Invalid file type')
 | 
			
		||||
    return redirect(url_for('admin'))
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    app.run(
 | 
			
		||||
        debug=True, 
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								config.example.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								config.example.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
[server]
 | 
			
		||||
host = "0.0.0.0"
 | 
			
		||||
port = 5000
 | 
			
		||||
 | 
			
		||||
[directories]
 | 
			
		||||
upload = "/app/uploads"
 | 
			
		||||
thumbnail = "/app/thumbnails"
 | 
			
		||||
 | 
			
		||||
[admin]
 | 
			
		||||
# Change this password!
 | 
			
		||||
password = "changeme"
 | 
			
		||||
 | 
			
		||||
[appearance]
 | 
			
		||||
accent_color = "#007bff"
 | 
			
		||||
 | 
			
		||||
[security]
 | 
			
		||||
# Add these security settings
 | 
			
		||||
max_upload_size_mb = 80
 | 
			
		||||
allowed_extensions = ["jpg", "jpeg", "png", "gif"]
 | 
			
		||||
rate_limit = 100  # requests per minute 
 | 
			
		||||
							
								
								
									
										34
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
			
		||||
version: '3.8'
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  web:
 | 
			
		||||
    build: .
 | 
			
		||||
    labels:
 | 
			
		||||
      - "traefik.enable=true"
 | 
			
		||||
      - "traefik.http.routers.photos.rule=Host(`photos.dws.rip`)"
 | 
			
		||||
      - "traefik.http.services.photos.loadbalancer.server.port=5000"
 | 
			
		||||
      - "traefik.http.routers.photos.tls=true"
 | 
			
		||||
      - "traefik.http.routers.photos.tls.certresolver=default"
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ./uploads:/app/uploads
 | 
			
		||||
      - ./thumbnails:/app/thumbnails
 | 
			
		||||
      - ./config.toml:/app/config.toml
 | 
			
		||||
      - ./photos.db:/app/photos.db
 | 
			
		||||
    environment:
 | 
			
		||||
      - PYTHONUNBUFFERED=1
 | 
			
		||||
      - FLASK_ENV=production
 | 
			
		||||
      - WORKERS=4
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    networks:
 | 
			
		||||
      - traefik-public
 | 
			
		||||
      - default
 | 
			
		||||
    healthcheck:
 | 
			
		||||
      test: ["CMD", "curl", "-f", "http://localhost:5000/"]
 | 
			
		||||
      interval: 30s
 | 
			
		||||
      timeout: 10s
 | 
			
		||||
      retries: 3
 | 
			
		||||
      start_period: 40s
 | 
			
		||||
 | 
			
		||||
networks:
 | 
			
		||||
  traefik-public:
 | 
			
		||||
    external: true
 | 
			
		||||
							
								
								
									
										145
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										145
									
								
								main.py
									
									
									
									
									
								
							@@ -1,145 +0,0 @@
 | 
			
		||||
from flask import Flask, jsonify, request, send_from_directory, render_template
 | 
			
		||||
import os
 | 
			
		||||
from PIL import Image, ExifTags
 | 
			
		||||
from apscheduler.schedulers.background import BackgroundScheduler
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
import piexif
 | 
			
		||||
import io
 | 
			
		||||
import random
 | 
			
		||||
from colorthief import ColorThief
 | 
			
		||||
import colorsys
 | 
			
		||||
 | 
			
		||||
app = Flask(__name__)
 | 
			
		||||
 | 
			
		||||
IMAGE_FOLDER = '/home/dubey/projects/photoportfolio/pythonserver/images/'
 | 
			
		||||
THUMBS_FOLDER = '/home/dubey/projects/photoportfolio/pythonserver/thumbs/'
 | 
			
		||||
IMAGES_PER_PAGE = 5
 | 
			
		||||
THUMBNAIL_SIZES = [256, 512, 768, 1024, 1536, 2048]
 | 
			
		||||
 | 
			
		||||
scheduler = BackgroundScheduler()
 | 
			
		||||
scheduler.start()
 | 
			
		||||
 | 
			
		||||
def generate_thumbnails():
 | 
			
		||||
    for filename in os.listdir(IMAGE_FOLDER):
 | 
			
		||||
        if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')):
 | 
			
		||||
            original_path = os.path.join(IMAGE_FOLDER, filename)
 | 
			
		||||
            for size in THUMBNAIL_SIZES:
 | 
			
		||||
                thumb_path = os.path.join(THUMBS_FOLDER, f"{size}_{filename}")
 | 
			
		||||
                if not os.path.exists(thumb_path):
 | 
			
		||||
                    with Image.open(original_path) as img:
 | 
			
		||||
                        # Extract EXIF data
 | 
			
		||||
                        exif_data = None
 | 
			
		||||
                        if "exif" in img.info:
 | 
			
		||||
                            exif_data = img.info["exif"]
 | 
			
		||||
 | 
			
		||||
                        # Resize image
 | 
			
		||||
                        img.thumbnail((size, size), Image.LANCZOS)
 | 
			
		||||
 | 
			
		||||
                        # Save image with EXIF data
 | 
			
		||||
                        if exif_data:
 | 
			
		||||
                            img.save(thumb_path, exif=exif_data, optimize=True, quality=85)
 | 
			
		||||
                        else:
 | 
			
		||||
                            img.save(thumb_path, optimize=True, quality=85)
 | 
			
		||||
 | 
			
		||||
scheduler.add_job(generate_thumbnails, 'interval', minutes=5)
 | 
			
		||||
scheduler.add_job(generate_thumbnails, 'date', run_date=datetime.now() + timedelta(seconds=1))  # Run once at startup
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_highlight_color(image_path):
 | 
			
		||||
    color_thief = ColorThief(image_path)
 | 
			
		||||
    palette = color_thief.get_palette(color_count=6, quality=1)
 | 
			
		||||
    
 | 
			
		||||
    # Convert RGB to HSV and find the color with the highest saturation
 | 
			
		||||
    highlight_color = max(palette, key=lambda rgb: colorsys.rgb_to_hsv(*rgb)[1])
 | 
			
		||||
    
 | 
			
		||||
    return '#{:02x}{:02x}{:02x}'.format(*highlight_color)
 | 
			
		||||
 | 
			
		||||
def get_image_info(filename):
 | 
			
		||||
    path = os.path.join(IMAGE_FOLDER, filename)
 | 
			
		||||
    thumb_path = os.path.join(THUMBS_FOLDER, f"256_{filename}")
 | 
			
		||||
    exif = None
 | 
			
		||||
    with Image.open(path) as img:
 | 
			
		||||
        width, height = img.size
 | 
			
		||||
        exif = {
 | 
			
		||||
            ExifTags.TAGS[k]: v
 | 
			
		||||
            for k, v in img._getexif().items()
 | 
			
		||||
            if k in ExifTags.TAGS
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    if str(exif['Orientation']) == "6" or str(exif['Orientation']) == "8":
 | 
			
		||||
        width, height = height, width
 | 
			
		||||
 | 
			
		||||
    exposure_time = exif['ExposureTime']
 | 
			
		||||
    if isinstance(exposure_time, tuple):
 | 
			
		||||
        exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
 | 
			
		||||
    else:
 | 
			
		||||
        exposure_fraction = f"1/{int(1/float(exposure_time))}"
 | 
			
		||||
    
 | 
			
		||||
    date_obj = datetime.strptime(exif['DateTime'], '%Y:%m:%d %H:%M:%S')
 | 
			
		||||
    date = date_obj.strftime('%y %m %d')  # Format: YY MM DD
 | 
			
		||||
    technical_info = f"{exif['FocalLengthIn35mmFilm']}MM | F/{exif['FNumber']} | {exposure_fraction} | ISO{exif['ISOSpeedRatings']}"
 | 
			
		||||
 | 
			
		||||
    factor = random.randint(2, 3)
 | 
			
		||||
    if height < 4000 or width < 4000:
 | 
			
		||||
        factor = 1
 | 
			
		||||
 | 
			
		||||
    highlight_color = get_highlight_color(thumb_path)
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        'imgSrc': f'/thumbs/1536_{filename}',
 | 
			
		||||
        'fullSizeImgSrc': f'/images/{filename}',
 | 
			
		||||
        'date': date,
 | 
			
		||||
        'technicalInfo': technical_info,
 | 
			
		||||
        'width': width/factor,
 | 
			
		||||
        'height': height/factor,
 | 
			
		||||
        'highlightColor': highlight_color
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
def get_image_taken_date(filename):
 | 
			
		||||
    path = os.path.join(IMAGE_FOLDER, filename)
 | 
			
		||||
    with Image.open(path) as img:
 | 
			
		||||
        exif = {
 | 
			
		||||
            ExifTags.TAGS[k]: v
 | 
			
		||||
            for k, v in img._getexif().items()
 | 
			
		||||
            if k in ExifTags.TAGS
 | 
			
		||||
        }
 | 
			
		||||
    date_str = exif.get('DateTime', exif.get('DateTimeOriginal', ''))
 | 
			
		||||
    if date_str:
 | 
			
		||||
        return datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
 | 
			
		||||
    return datetime.fromtimestamp(os.path.getmtime(path))  # Fallback to file modification time
 | 
			
		||||
 | 
			
		||||
@app.route('/api/images')
 | 
			
		||||
def get_images():
 | 
			
		||||
    page = int(request.args.get('page', 1))
 | 
			
		||||
    all_images = sorted(
 | 
			
		||||
        [f for f in os.listdir(IMAGE_FOLDER) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif'))],
 | 
			
		||||
        key=get_image_taken_date,
 | 
			
		||||
        reverse=True
 | 
			
		||||
    )
 | 
			
		||||
    start = (page - 1) * IMAGES_PER_PAGE
 | 
			
		||||
    end = start + IMAGES_PER_PAGE
 | 
			
		||||
    page_images = all_images[start:end]
 | 
			
		||||
 | 
			
		||||
    return jsonify({
 | 
			
		||||
        'images': [get_image_info(img) for img in page_images],
 | 
			
		||||
        'hasMore': end < len(all_images)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.route('/images/<path:filename>')
 | 
			
		||||
def serve_image(filename):
 | 
			
		||||
    return send_from_directory(IMAGE_FOLDER, filename)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.route('/')
 | 
			
		||||
def index():
 | 
			
		||||
    return render_template('index.html')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.route('/thumbs/<path:filename>')
 | 
			
		||||
def serve_thumbnail(filename):
 | 
			
		||||
    return send_from_directory(THUMBS_FOLDER, filename)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    app.run(debug=True, port=5001, host='0.0.0.0')
 | 
			
		||||
							
								
								
									
										23
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
APScheduler==3.10.4
 | 
			
		||||
blinker==1.8.2
 | 
			
		||||
click==8.1.7
 | 
			
		||||
colorthief==0.2.1
 | 
			
		||||
exif==1.6.0
 | 
			
		||||
Flask==3.0.3
 | 
			
		||||
greenlet==3.1.1
 | 
			
		||||
itsdangerous==2.2.0
 | 
			
		||||
Jinja2==3.1.4
 | 
			
		||||
MarkupSafe==3.0.1
 | 
			
		||||
numpy==2.1.2
 | 
			
		||||
piexif==1.1.3
 | 
			
		||||
pillow==11.0.0
 | 
			
		||||
plum-py==0.8.7
 | 
			
		||||
pytz==2024.2
 | 
			
		||||
six==1.16.0
 | 
			
		||||
SQLAlchemy==2.0.36
 | 
			
		||||
toml==0.10.2
 | 
			
		||||
typing_extensions==4.12.2
 | 
			
		||||
tzlocal==5.2
 | 
			
		||||
watchdog==6.0.0
 | 
			
		||||
Werkzeug==3.0.4
 | 
			
		||||
gunicorn
 | 
			
		||||
		Reference in New Issue
	
	Block a user