diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..a901fbd --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -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 }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c59f0a --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb8f427 --- /dev/null +++ b/README.md @@ -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/` - Verify image authenticity + +#### Admin Endpoints +- `POST /admin/login` - Admin authentication +- `POST /admin/upload` - Upload new images +- `POST /admin/update_photo/` - Update image metadata +- `POST /admin/delete_photo/` - Delete image + +## Environment Variables + +- `FLASK_ENV`: Set to 'production' in production +- `WORKERS`: Number of Gunicorn workers (default: 4) +- `PORT`: Override default port (default: 5000) \ No newline at end of file diff --git a/app.py b/app.py index 67681fd..e1945ec 100644 --- a/app.py +++ b/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, diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..10c6503 --- /dev/null +++ b/config.example.toml @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac623fd --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 5055f74..0000000 --- a/main.py +++ /dev/null @@ -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/') -def serve_image(filename): - return send_from_directory(IMAGE_FOLDER, filename) - - -@app.route('/') -def index(): - return render_template('index.html') - - -@app.route('/thumbs/') -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') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e4218dc --- /dev/null +++ b/requirements.txt @@ -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