ready for prod
Some checks failed
Docker Build and Publish / build (pull_request) Failing after 50s

This commit is contained in:
Tanishq Dubey 2024-11-04 15:48:24 -05:00
parent 6335751f0f
commit ae804aaa42
8 changed files with 463 additions and 145 deletions

View 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
View 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
View 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
View File

@ -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
View 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
View 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
View File

@ -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
View 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