Compare commits
	
		
			10 Commits
		
	
	
		
			gridlayout
			...
			07725c99b4
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 07725c99b4 | |||
| 05a184fcf7 | |||
| 13e61b7bef | |||
| 4c993ebacd | |||
| 9c1e6f0e94 | |||
| 9abdd18f33 | |||
| b46ec98115 | |||
| 905e3c3977 | |||
| 6f2ecd9775 | |||
| dcd66c3c76 | 
| @ -3,9 +3,11 @@ name: Docker Build and Publish | |||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: [ main ] |     branches: [ main ] | ||||||
|     tags: [ 'v*' ] |     tags: [ 'v*.*.*' ] | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: [ main ] |     branches: [ main ] | ||||||
|  |   release: | ||||||
|  |     types: [published] | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
| @ -20,11 +22,11 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           images: git.dws.rip/${{ github.repository }} |           images: git.dws.rip/${{ github.repository }} | ||||||
|           tags: | |           tags: | | ||||||
|             type=ref,event=branch |             type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} | ||||||
|  |             type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} | ||||||
|  |             type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} | ||||||
|  |             type=sha,format=long | ||||||
|             type=ref,event=pr |             type=ref,event=pr | ||||||
|             type=semver,pattern={{version}} |  | ||||||
|             type=semver,pattern={{major}}.{{minor}} |  | ||||||
|             type=sha |  | ||||||
|  |  | ||||||
|       - name: Login to Gitea Container Registry |       - name: Login to Gitea Container Registry | ||||||
|         uses: docker/login-action@v2 |         uses: docker/login-action@v2 | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -10,6 +10,7 @@ lib64 | |||||||
| uploads/ | uploads/ | ||||||
| thumbnails/ | thumbnails/ | ||||||
| images/ | images/ | ||||||
|  | static/ | ||||||
|  |  | ||||||
| config.toml | config.toml | ||||||
| __pycache__/ | __pycache__/ | ||||||
|  | |||||||
							
								
								
									
										63
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,63 @@ | |||||||
|  | .PHONY: build run clean run-docker stop-docker logs-docker | ||||||
|  |  | ||||||
|  | # Docker image details | ||||||
|  | IMAGE_NAME = git.dws.rip/dubey/spectra | ||||||
|  | TAG = main | ||||||
|  |  | ||||||
|  | # Local development settings | ||||||
|  | PYTHON = python3 | ||||||
|  | PIP = pip3 | ||||||
|  | PORT = 5000 | ||||||
|  |  | ||||||
|  | build: | ||||||
|  | 	docker build -t $(IMAGE_NAME):$(TAG) . | ||||||
|  |  | ||||||
|  | run: | ||||||
|  | 	$(PYTHON) app.py | ||||||
|  |  | ||||||
|  | install: | ||||||
|  | 	$(PIP) install -r requirements.txt | ||||||
|  |  | ||||||
|  | clean: | ||||||
|  | 	find . -type d -name "__pycache__" -exec rm -r {} + | ||||||
|  | 	find . -type f -name "*.pyc" -delete | ||||||
|  | 	rm -rf thumbnails/* | ||||||
|  | 	rm -rf uploads/* | ||||||
|  |  | ||||||
|  | run-docker: | ||||||
|  | 	docker run -d \ | ||||||
|  | 		--name spectra \ | ||||||
|  | 		-p $(PORT):5000 \ | ||||||
|  | 		-v $(PWD)/uploads:/app/uploads \ | ||||||
|  | 		-v $(PWD)/thumbnails:/app/thumbnails \ | ||||||
|  | 		-v $(PWD)/photos.db:/app/photos.db \ | ||||||
|  | 		$(IMAGE_NAME):$(TAG) | ||||||
|  |  | ||||||
|  | run-docker-attached: | ||||||
|  | 	docker run -it \ | ||||||
|  | 		--name spectra \ | ||||||
|  | 		-p $(PORT):5000 \ | ||||||
|  | 		-v $(PWD)/uploads:/app/uploads \ | ||||||
|  | 		-v $(PWD)/thumbnails:/app/thumbnails \ | ||||||
|  | 		-v $(PWD)/photos.db:/app/photos.db \ | ||||||
|  | 		$(IMAGE_NAME):$(TAG) | ||||||
|  |  | ||||||
|  | stop-docker: | ||||||
|  | 	docker stop spectra | ||||||
|  | 	docker rm spectra | ||||||
|  |  | ||||||
|  | logs-docker: | ||||||
|  | 	docker logs -f spectra | ||||||
|  |  | ||||||
|  | rebuild: clean build run-docker | ||||||
|  |  | ||||||
|  | help: | ||||||
|  | 	@echo "Available commands:" | ||||||
|  | 	@echo "  make build         - Build Docker image" | ||||||
|  | 	@echo "  make run          - Run locally using Python" | ||||||
|  | 	@echo "  make install      - Install Python dependencies" | ||||||
|  | 	@echo "  make clean        - Remove cache files and generated content" | ||||||
|  | 	@echo "  make run-docker   - Run in Docker container" | ||||||
|  | 	@echo "  make stop-docker  - Stop and remove Docker container" | ||||||
|  | 	@echo "  make logs-docker  - View Docker container logs" | ||||||
|  | 	@echo "  make rebuild      - Clean, rebuild and run Docker container"  | ||||||
| @ -115,3 +115,11 @@ spectra/ | |||||||
| - `FLASK_ENV`: Set to 'production' in production | - `FLASK_ENV`: Set to 'production' in production | ||||||
| - `WORKERS`: Number of Gunicorn workers (default: 4) | - `WORKERS`: Number of Gunicorn workers (default: 4) | ||||||
| - `PORT`: Override default port (default: 5000) | - `PORT`: Override default port (default: 5000) | ||||||
|  |  | ||||||
|  | ## Release Process | ||||||
|  |  | ||||||
|  | To create a release: | ||||||
|  | - Create and push a tag: `git tag v1.0.0 && git push origin v1.0.0` | ||||||
|  | - Create a release in Gitea UI using that tag | ||||||
|  | - The workflow will build and push the Docker image with appropriate version tags | ||||||
|  | - The Docker image will be available at: `git.dws.rip/your-repo/image:v1.0.0` | ||||||
|  | |||||||
							
								
								
									
										706
									
								
								app.py
									
									
									
									
									
								
							
							
						
						
									
										706
									
								
								app.py
									
									
									
									
									
								
							| @ -1,120 +1,91 @@ | |||||||
| from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, send_from_directory | import atexit | ||||||
| from werkzeug.utils import secure_filename |  | ||||||
| from models import Session as DBSession, Photo |  | ||||||
| from config import load_or_create_config |  | ||||||
| import os |  | ||||||
| from datetime import datetime |  | ||||||
| from PIL import Image, ExifTags |  | ||||||
| from apscheduler.schedulers.background import BackgroundScheduler |  | ||||||
| import random |  | ||||||
| from colorthief import ColorThief |  | ||||||
| import colorsys | import colorsys | ||||||
| from steganography import embed_message, extract_message |  | ||||||
| import hashlib | import hashlib | ||||||
| from watchdog.observers import Observer | import os | ||||||
| from watchdog.events import FileSystemEventHandler | import random | ||||||
| import toml | import secrets | ||||||
| import threading | import threading | ||||||
| import time | import time | ||||||
| import atexit | from datetime import UTC | ||||||
|  | from datetime import datetime | ||||||
|  | from pathlib import Path | ||||||
|  | from logging import getLogger | ||||||
|  | import logging | ||||||
|  | from logging.config import dictConfig | ||||||
|  |  | ||||||
|  | import toml | ||||||
|  | from apscheduler.schedulers.background import BackgroundScheduler | ||||||
|  | from colorthief import ColorThief | ||||||
|  | from flask import (Flask, flash, g, jsonify, redirect, render_template, | ||||||
|  |                    request, send_from_directory, session, url_for) | ||||||
| from flask_limiter import Limiter | from flask_limiter import Limiter | ||||||
| from flask_limiter.util import get_remote_address | from flask_limiter.util import get_remote_address | ||||||
| import secrets | from PIL import ExifTags, Image | ||||||
|  | from watchdog.events import FileSystemEventHandler | ||||||
|  | from werkzeug.middleware.proxy_fix import ProxyFix | ||||||
|  | from werkzeug.utils import secure_filename | ||||||
|  |  | ||||||
| app = Flask(__name__) | from models import Photo | ||||||
| app.secret_key = os.urandom(24) | from models import Session as DBSession | ||||||
| config = load_or_create_config() | from models import SiteConfig, init_db | ||||||
|  | from steganography import embed_message, extract_message | ||||||
|  |  | ||||||
| UPLOAD_FOLDER = config['directories']['upload'] | # Add this function to handle secret key persistence | ||||||
| THUMBNAIL_FOLDER = config['directories']['thumbnail'] | def get_or_create_secret_key(): | ||||||
| ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} |     """Get existing secret key or create a new one""" | ||||||
| THUMBNAIL_SIZES = [256, 512, 768, 1024, 1536, 2048] |     secret_key_file = Path("secret.key") | ||||||
|  |     try: | ||||||
| # Create upload and thumbnail directories if they don't exist |         if secret_key_file.exists(): | ||||||
| os.makedirs(UPLOAD_FOLDER, exist_ok=True) |             logger.info("Loading existing secret key") | ||||||
| os.makedirs(THUMBNAIL_FOLDER, exist_ok=True) |             return secret_key_file.read_bytes() | ||||||
|  |         else: | ||||||
| app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER |             logger.info("Generating new secret key") | ||||||
| app.config['THUMBNAIL_FOLDER'] = THUMBNAIL_FOLDER |             secret_key = os.urandom(32)  # Use 32 bytes for better security | ||||||
| app.config['MAX_CONTENT_LENGTH'] = 80 * 1024 * 1024  # 80MB limit |             secret_key_file.write_bytes(secret_key) | ||||||
|  |             return secret_key | ||||||
| scheduler = BackgroundScheduler() |     except Exception as e: | ||||||
| scheduler.start() |         logger.error(f"Error handling secret key: {e}") | ||||||
|  |         # Fallback to a memory-only key if file operations fail | ||||||
|  |         return os.urandom(32) | ||||||
|  |  | ||||||
| DEFAULT_CONFIG = { | DEFAULT_CONFIG = { | ||||||
|     'server': { |     "server": {"host": "0.0.0.0", "port": 5000}, | ||||||
|         'host': '0.0.0.0', |     "directories": {"upload": "uploads", "thumbnail": "thumbnails"}, | ||||||
|         'port': 5000 |     "admin": {"password": secrets.token_urlsafe(16)},  # Generate secure random password | ||||||
|     }, |  | ||||||
|     'directories': { |  | ||||||
|         'upload': 'uploads', |  | ||||||
|         'thumbnail': 'thumbnails' |  | ||||||
|     }, |  | ||||||
|     'admin': { |  | ||||||
|         'password': 'changeme'  # Default password |  | ||||||
|     }, |  | ||||||
|     'appearance': { |  | ||||||
|         'accent_color': '#007bff' |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| def merge_configs(default, user): | # Configure logging | ||||||
|     """Recursively merge user config with default config""" | dictConfig({ | ||||||
|     result = default.copy() |     'version': 1, | ||||||
|     for key, value in user.items(): |     'formatters': { | ||||||
|         if key in result and isinstance(result[key], dict) and isinstance(value, dict): |         'default': { | ||||||
|             result[key] = merge_configs(result[key], value) |             'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', | ||||||
|         else: |         } | ||||||
|             result[key] = value |     }, | ||||||
|     return result |     'handlers': { | ||||||
|  |         'console': { | ||||||
|  |             'class': 'logging.StreamHandler', | ||||||
|  |             'stream': 'ext://sys.stdout', | ||||||
|  |             'formatter': 'default' | ||||||
|  |         }, | ||||||
|  |         'file': { | ||||||
|  |             'class': 'logging.FileHandler', | ||||||
|  |             'filename': 'spectra.log', | ||||||
|  |             'formatter': 'default' | ||||||
|  |         } | ||||||
|  |     }, | ||||||
|  |     'root': { | ||||||
|  |         'level': 'INFO', | ||||||
|  |         'handlers': ['console', 'file'] | ||||||
|  |     } | ||||||
|  | }) | ||||||
|  |  | ||||||
| class ConfigFileHandler(FileSystemEventHandler): | # Get logger for this module | ||||||
|     def on_modified(self, event): | logger = getLogger(__name__) | ||||||
|         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(): | # Create Flask app with persistent secret key | ||||||
|     config_path = 'config.toml' | app = Flask(__name__) | ||||||
|      | app.secret_key = get_or_create_secret_key() | ||||||
|     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): | def allowed_file(filename): | ||||||
|     return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS |     return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS | ||||||
| @ -151,166 +122,227 @@ def generate_thumbnails(filename): | |||||||
|                 else: |                 else: | ||||||
|                     img.save(thumb_path, optimize=True, quality=85) |                     img.save(thumb_path, optimize=True, quality=85) | ||||||
|  |  | ||||||
| def generate_all_thumbnails(): | def load_or_create_config(): | ||||||
|     for filename in os.listdir(UPLOAD_FOLDER): |     """Load config from file or create with defaults if missing""" | ||||||
|         if allowed_file(filename): |     config_path = Path("config.toml") | ||||||
|             generate_thumbnails(filename) |     logger.info(f"Loading config from {config_path}") | ||||||
|  |  | ||||||
| scheduler.add_job(generate_all_thumbnails, 'interval', minutes=5) |     try: | ||||||
| scheduler.add_job(generate_all_thumbnails, 'date', run_date=datetime.now())  # Run once at startup |         # If config doesn't exist or is empty, create it with defaults | ||||||
|  |         if not config_path.exists() or config_path.stat().st_size == 0: | ||||||
|  |             config_data = DEFAULT_CONFIG.copy() | ||||||
|  |             with open(config_path, "w") as f: | ||||||
|  |                 toml.dump(config_data, f) | ||||||
|  |             logger.info(f"Created new config file with defaults at {config_path}") | ||||||
|  |             logger.warning("Please update the config file with your own values.") | ||||||
|  |             return config_data | ||||||
|  |  | ||||||
| @app.route('/') |         # Load existing config | ||||||
|  |         with open(config_path, "r") as f: | ||||||
|  |             config_data = toml.load(f) | ||||||
|  |         logger.info(f"Loaded config from {config_path}") | ||||||
|  |  | ||||||
|  |         # Verify required fields | ||||||
|  |         required_sections = ["server", "directories", "admin"] | ||||||
|  |         required_fields = { | ||||||
|  |             "server": ["host", "port"], | ||||||
|  |             "directories": ["upload", "thumbnail"], | ||||||
|  |             "admin": ["password"], | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for section in required_sections: | ||||||
|  |             if section not in config_data: | ||||||
|  |                 raise ValueError(f"Missing required section: {section}") | ||||||
|  |             for field in required_fields[section]: | ||||||
|  |                 if field not in config_data[section]: | ||||||
|  |                     raise ValueError(f"Missing required field: {section}.{field}") | ||||||
|  |  | ||||||
|  |         logger.info("Config verification passed") | ||||||
|  |         return config_data | ||||||
|  |  | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Failed to load config: {str(e)}") | ||||||
|  |         raise RuntimeError(f"Failed to load config: {str(e)}") | ||||||
|  |  | ||||||
|  | def get_site_config(): | ||||||
|  |     """Get the current site configuration""" | ||||||
|  |     db_session = DBSession() | ||||||
|  |     site_config = db_session.query(SiteConfig).first() | ||||||
|  |     if not site_config: | ||||||
|  |         site_config = SiteConfig() | ||||||
|  |         db_session.add(site_config) | ||||||
|  |         db_session.commit() | ||||||
|  |     config_dict = { | ||||||
|  |         "appearance": { | ||||||
|  |             "accent_color": site_config.accent_color, | ||||||
|  |             "site_title": site_config.site_title, | ||||||
|  |         }, | ||||||
|  |         "about": { | ||||||
|  |             "name": site_config.author_name, | ||||||
|  |             "location": site_config.author_location, | ||||||
|  |             "profile_image": site_config.profile_image, | ||||||
|  |             "bio": site_config.bio, | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |     db_session.close() | ||||||
|  |     return config_dict | ||||||
|  |  | ||||||
|  | def init_app_config(): | ||||||
|  |     """Initialize application configuration""" | ||||||
|  |     # Load hard config from file | ||||||
|  |     hard_config = load_or_create_config() | ||||||
|  |      | ||||||
|  |     # Load site config from database | ||||||
|  |     site_config = get_site_config() | ||||||
|  |      | ||||||
|  |     # Merge configs | ||||||
|  |     config = hard_config.copy() | ||||||
|  |     config.update(site_config) | ||||||
|  |      | ||||||
|  |     return config | ||||||
|  |  | ||||||
|  | # Initialize config before any routes | ||||||
|  | config = init_app_config() | ||||||
|  |  | ||||||
|  | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) | ||||||
|  |  | ||||||
|  | UPLOAD_FOLDER = config["directories"]["upload"] | ||||||
|  | THUMBNAIL_FOLDER = config["directories"]["thumbnail"] | ||||||
|  | ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif"} | ||||||
|  | THUMBNAIL_SIZES = [256, 512, 768, 1024, 1536, 2048] | ||||||
|  |  | ||||||
|  | # Create upload and thumbnail directories if they don't exist | ||||||
|  | os.makedirs(UPLOAD_FOLDER, exist_ok=True) | ||||||
|  | os.makedirs(THUMBNAIL_FOLDER, exist_ok=True) | ||||||
|  |  | ||||||
|  | app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER | ||||||
|  | app.config["THUMBNAIL_FOLDER"] = THUMBNAIL_FOLDER | ||||||
|  | app.config["MAX_CONTENT_LENGTH"] = 80 * 1024 * 1024  # 80MB limit | ||||||
|  |  | ||||||
|  | scheduler = BackgroundScheduler() | ||||||
|  | scheduler.start() | ||||||
|  |  | ||||||
|  | limiter = Limiter( | ||||||
|  |     app=app, | ||||||
|  |     key_func=get_remote_address, | ||||||
|  |     default_limits=["100 per minute"], | ||||||
|  |     storage_uri="memory://", | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | @app.before_request | ||||||
|  | def before_request(): | ||||||
|  |     g.csp_nonce = secrets.token_hex(16) | ||||||
|  |  | ||||||
|  | # Update the security headers middleware | ||||||
|  | @app.after_request | ||||||
|  | def add_security_headers(response): | ||||||
|  |     nonce = g.csp_nonce  # Use the nonce from the before_request hook | ||||||
|  |  | ||||||
|  |     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"] = ( | ||||||
|  |         f"default-src 'self'; " | ||||||
|  |         f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; " | ||||||
|  |         f"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " | ||||||
|  |         f"font-src 'self' https://fonts.gstatic.com; " | ||||||
|  |         f"img-src 'self' data:; " | ||||||
|  |         f"connect-src 'self';" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Make nonce available to templates | ||||||
|  |     return response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # ---------------------------------------------------------------- | ||||||
|  | # Public routes | ||||||
|  | # ----------------------------------------------------------------   | ||||||
|  | @app.route("/") | ||||||
| def index(): | def index(): | ||||||
|     return render_template('index.html', accent_color=config['appearance']['accent_color']) |     return render_template( | ||||||
|  |         "index.html", | ||||||
|  |         accent_color=config["appearance"]["accent_color"], | ||||||
|  |         site_title=config["appearance"]["site_title"], | ||||||
|  |         about=config["about"], | ||||||
|  |         nonce=g.csp_nonce, | ||||||
|  |     ) | ||||||
|  |  | ||||||
| @app.route('/api/images') | # ---------------------------------------------------------------- | ||||||
|  | # API routes | ||||||
|  | # ----------------------------------------------------------------   | ||||||
|  | @app.route("/api/images") | ||||||
| def get_images(): | def get_images(): | ||||||
|     page = int(request.args.get('page', 1)) |     page = int(request.args.get("page", 1)) | ||||||
|     per_page = 20 |     per_page = 20 | ||||||
|     db_session = DBSession() |     db_session = DBSession() | ||||||
|     photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).offset((page - 1) * per_page).limit(per_page).all() |     photos = ( | ||||||
|  |         db_session.query(Photo) | ||||||
|  |         .order_by(Photo.date_taken.desc()) | ||||||
|  |         .offset((page - 1) * per_page) | ||||||
|  |         .limit(per_page) | ||||||
|  |         .all() | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     images = [] |     images = [] | ||||||
|     for photo in photos: |     for photo in photos: | ||||||
|         factor = random.randint(2, 3) |         factor = random.randint(2, 3) | ||||||
|         if photo.height < 4000 or photo.width < 4000: |         if photo.height < 4000 and photo.width < 4000: | ||||||
|             factor = 1 |             factor = 1 | ||||||
|         if photo.orientation == 6 or photo.orientation == 8: |         if photo.orientation == 6 or photo.orientation == 8: | ||||||
|             width, height = photo.height, photo.width |             width, height = photo.height, photo.width | ||||||
|         else: |         else: | ||||||
|             width, height = photo.width, photo.height |             width, height = photo.width, photo.height | ||||||
|         images.append({ |         images.append( | ||||||
|             'imgSrc': f'/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}', |             { | ||||||
|             'width': width / factor, |                 "imgSrc": f"/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}", | ||||||
|             'height': height / factor, |                 "width": width / factor, | ||||||
|             'caption': photo.input_filename, |                 "height": height / factor, | ||||||
|             'date': photo.date_taken.strftime('%y %m %d'), |                 "caption": photo.input_filename, | ||||||
|             'technicalInfo': f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}", |                 "date": photo.date_taken.strftime("%y %m %d"), | ||||||
|             'highlightColor': photo.highlight_color |                 "technicalInfo": f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}", | ||||||
|         }) |                 "highlightColor": photo.highlight_color, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     has_more = db_session.query(Photo).count() > page * per_page |     has_more = db_session.query(Photo).count() > page * per_page | ||||||
|     db_session.close() |     db_session.close() | ||||||
|  |  | ||||||
|     return jsonify({'images': images, 'hasMore': has_more}) |     return jsonify({"images": images, "hasMore": has_more}) | ||||||
|  |  | ||||||
| @app.route('/admin') |  | ||||||
|  | # ---------------------------------------------------------------- | ||||||
|  | # Admin routes | ||||||
|  | # ----------------------------------------------------------------   | ||||||
|  | @app.route("/admin") | ||||||
| def admin(): | def admin(): | ||||||
|     if 'logged_in' not in session: |     if "logged_in" not in session: | ||||||
|         return redirect(url_for('admin_login')) |         return redirect(url_for("admin_login")) | ||||||
|  |  | ||||||
|     db_session = DBSession() |     db_session = DBSession() | ||||||
|     photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all() |     photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all() | ||||||
|     db_session.close() |     db_session.close() | ||||||
|  |  | ||||||
|     return render_template('admin.html', photos=photos, accent_color=config['appearance']['accent_color']) |     # Pass the full config to the template | ||||||
|  |     return render_template( | ||||||
| @app.route('/admin/login', methods=['GET', 'POST']) |         "admin.html", | ||||||
| def admin_login(): |         photos=photos, | ||||||
|     if request.method == 'POST': |         accent_color=config["appearance"]["accent_color"], | ||||||
|         if request.form['password'] == config['admin']['password']: |         config=config, | ||||||
|             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']) |  | ||||||
| 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') | @app.route("/admin/logout") | ||||||
|         return redirect(url_for('admin')) |  | ||||||
|      |  | ||||||
|     flash('Invalid file type') |  | ||||||
|     return redirect(url_for('admin')) |  | ||||||
|  |  | ||||||
| @app.route('/admin/logout') |  | ||||||
| def admin_logout(): | def admin_logout(): | ||||||
|     session.pop('logged_in', None) |     session.pop("logged_in", None) | ||||||
|     flash('You have been logged out') |     flash("You have been logged out") | ||||||
|     return redirect(url_for('admin_login')) |     return redirect(url_for("admin_login")) | ||||||
|  |  | ||||||
| @app.route('/static/thumbnails/<path:filename>') | @app.route("/admin/update_photo/<int:photo_id>", methods=["POST"]) | ||||||
| def serve_thumbnail(filename): |  | ||||||
|     return send_from_directory(THUMBNAIL_FOLDER, filename) |  | ||||||
|  |  | ||||||
| @app.route('/admin/update_photo/<int:photo_id>', methods=['POST']) |  | ||||||
| def update_photo(photo_id): | def update_photo(photo_id): | ||||||
|     if 'logged_in' not in session: |     if "logged_in" not in session: | ||||||
|         return jsonify({'success': False, 'error': 'Not logged in'}), 401 |         return jsonify({"success": False, "error": "Not logged in"}), 401 | ||||||
|  |  | ||||||
|     data = request.json |     data = request.json | ||||||
|     db_session = DBSession() |     db_session = DBSession() | ||||||
| @ -318,35 +350,35 @@ def update_photo(photo_id): | |||||||
|  |  | ||||||
|     if not photo: |     if not photo: | ||||||
|         db_session.close() |         db_session.close() | ||||||
|         return jsonify({'success': False, 'error': 'Photo not found'}), 404 |         return jsonify({"success": False, "error": "Photo not found"}), 404 | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         for field, value in data.items(): |         for field, value in data.items(): | ||||||
|             if field == 'date_taken': |             if field == "date_taken": | ||||||
|                 value = datetime.strptime(value, '%Y-%m-%d %H:%M:%S') |                 value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") | ||||||
|             elif field == 'iso': |             elif field == "iso": | ||||||
|                 value = int(value) |                 value = int(value) | ||||||
|             setattr(photo, field, value) |             setattr(photo, field, value) | ||||||
|  |  | ||||||
|         db_session.commit() |         db_session.commit() | ||||||
|         db_session.close() |         db_session.close() | ||||||
|         return jsonify({'success': True}) |         return jsonify({"success": True}) | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         db_session.rollback() |         db_session.rollback() | ||||||
|         db_session.close() |         db_session.close() | ||||||
|         return jsonify({'success': False, 'error': str(e)}), 500 |         return jsonify({"success": False, "error": str(e)}), 500 | ||||||
|  |  | ||||||
| @app.route('/admin/delete_photo/<int:photo_id>', methods=['POST']) | @app.route("/admin/delete_photo/<int:photo_id>", methods=["POST"]) | ||||||
| def delete_photo(photo_id): | def delete_photo(photo_id): | ||||||
|     if 'logged_in' not in session: |     if "logged_in" not in session: | ||||||
|         return jsonify({'success': False, 'error': 'Not logged in'}), 401 |         return jsonify({"success": False, "error": "Not logged in"}), 401 | ||||||
|  |  | ||||||
|     db_session = DBSession() |     db_session = DBSession() | ||||||
|     photo = db_session.query(Photo).get(photo_id) |     photo = db_session.query(Photo).get(photo_id) | ||||||
|  |  | ||||||
|     if not photo: |     if not photo: | ||||||
|         db_session.close() |         db_session.close() | ||||||
|         return jsonify({'success': False, 'error': 'Photo not found'}), 404 |         return jsonify({"success": False, "error": "Photo not found"}), 404 | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         # Delete the original file |         # Delete the original file | ||||||
| @ -355,7 +387,9 @@ def delete_photo(photo_id): | |||||||
|             os.remove(original_path) |             os.remove(original_path) | ||||||
|  |  | ||||||
|         # Delete the thumbnail directory |         # Delete the thumbnail directory | ||||||
|         thumb_dir = os.path.join(THUMBNAIL_FOLDER, os.path.splitext(photo.input_filename)[0]) |         thumb_dir = os.path.join( | ||||||
|  |             THUMBNAIL_FOLDER, os.path.splitext(photo.input_filename)[0] | ||||||
|  |         ) | ||||||
|         if os.path.exists(thumb_dir): |         if os.path.exists(thumb_dir): | ||||||
|             for thumb_file in os.listdir(thumb_dir): |             for thumb_file in os.listdir(thumb_dir): | ||||||
|                 os.remove(os.path.join(thumb_dir, thumb_file)) |                 os.remove(os.path.join(thumb_dir, thumb_file)) | ||||||
| @ -366,17 +400,17 @@ def delete_photo(photo_id): | |||||||
|         db_session.commit() |         db_session.commit() | ||||||
|         db_session.close() |         db_session.close() | ||||||
|  |  | ||||||
|         return jsonify({'success': True}) |         return jsonify({"success": True}) | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         db_session.rollback() |         db_session.rollback() | ||||||
|         db_session.close() |         db_session.close() | ||||||
|         return jsonify({'success': False, 'error': str(e)}), 500 |         return jsonify({"success": False, "error": str(e)}), 500 | ||||||
|  |  | ||||||
| @app.route('/verify/<filename>', methods=['GET']) | @app.route("/verify/<filename>", methods=["GET"]) | ||||||
| def verify_image(filename): | def verify_image(filename): | ||||||
|     file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) |     file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) | ||||||
|     if not os.path.exists(file_path): |     if not os.path.exists(file_path): | ||||||
|         return jsonify({'verified': False, 'error': 'Image not found'}) |         return jsonify({"verified": False, "error": "Image not found"}) | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         extracted_key = extract_message(file_path, 16) |         extracted_key = extract_message(file_path, 16) | ||||||
| @ -385,69 +419,53 @@ def verify_image(filename): | |||||||
|         db_session.close() |         db_session.close() | ||||||
|  |  | ||||||
|         if photo and photo.unique_key == extracted_key: |         if photo and photo.unique_key == extracted_key: | ||||||
|             return jsonify({'verified': True, 'message': 'Image ownership verified'}) |             return jsonify({"verified": True, "message": "Image ownership verified"}) | ||||||
|         else: |         else: | ||||||
|             return jsonify({'verified': False, 'message': 'Image ownership could not be verified'}) |             return jsonify( | ||||||
|     except Exception as e: |                 {"verified": False, "message": "Image ownership could not be verified"} | ||||||
|         return jsonify({'verified': False, 'error': str(e)}) |  | ||||||
|  |  | ||||||
| limiter = Limiter( |  | ||||||
|     app=app, |  | ||||||
|     key_func=get_remote_address, |  | ||||||
|     default_limits=["100 per minute"], |  | ||||||
|     storage_uri="memory://" |  | ||||||
|             ) |             ) | ||||||
|  |     except Exception as e: | ||||||
| # Generate a strong secret key at startup |         return jsonify({"verified": False, "error": str(e)}) | ||||||
| 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 | # Add rate limiting to sensitive endpoints | ||||||
| @app.route('/admin/login', methods=['POST']) | @app.route("/admin/login", methods=["POST", "GET"]) | ||||||
| @limiter.limit("5 per minute") | @limiter.limit("5 per minute") | ||||||
| def admin_login(): | def admin_login(): | ||||||
|     if request.method == 'POST': |     if request.method == "POST": | ||||||
|         if request.form['password'] == config['admin']['password']: |         if request.form["password"] == config["admin"]["password"]: | ||||||
|             session['logged_in'] = True |             session["logged_in"] = True | ||||||
|             return redirect(url_for('admin')) |             return redirect(url_for("admin")) | ||||||
|         else: |         else: | ||||||
|             flash('Invalid password') |             flash("Invalid password") | ||||||
|     return render_template('admin_login.html', accent_color=config['appearance']['accent_color']) |     return render_template( | ||||||
|  |         "admin_login.html", accent_color=config["appearance"]["accent_color"] | ||||||
|  |     ) | ||||||
|  |  | ||||||
| @app.route('/admin/upload', methods=['POST']) | @app.route("/admin/upload", methods=["POST"]) | ||||||
| @limiter.limit("10 per minute") | @limiter.limit("10 per minute") | ||||||
| def admin_upload(): | def admin_upload(): | ||||||
|     if 'logged_in' not in session: |     if "logged_in" not in session: | ||||||
|         return redirect(url_for('admin_login')) |         return redirect(url_for("admin_login")) | ||||||
|  |  | ||||||
|     if 'file' not in request.files: |     if "file" not in request.files: | ||||||
|         flash('No file part') |         flash("No file part") | ||||||
|         return redirect(url_for('admin')) |         return redirect(url_for("admin")) | ||||||
|  |  | ||||||
|     file = request.files['file'] |     file = request.files["file"] | ||||||
|     if file.filename == '': |     if file.filename == "": | ||||||
|         flash('No selected file') |         flash("No selected file") | ||||||
|         return redirect(url_for('admin')) |         return redirect(url_for("admin")) | ||||||
|  |  | ||||||
|     if file and allowed_file(file.filename): |     if file and allowed_file(file.filename): | ||||||
|         filename = secure_filename(file.filename) |         filename = secure_filename(file.filename) | ||||||
|         file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) |         file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) | ||||||
|         file.save(file_path) |         file.save(file_path) | ||||||
|  |  | ||||||
|         # Extract EXIF data |         # Extract EXIF data | ||||||
|         exif = None |         exif = None | ||||||
|         exifraw = None |         exifraw = None | ||||||
|         with Image.open(file_path) as img: |         with Image.open(file_path) as img: | ||||||
|             exifraw = img.info['exif'] |             exifraw = img.info["exif"] | ||||||
|             width, height = img.size |             width, height = img.size | ||||||
|             exif = { |             exif = { | ||||||
|                 ExifTags.TAGS[k]: v |                 ExifTags.TAGS[k]: v | ||||||
| @ -456,7 +474,9 @@ def admin_upload(): | |||||||
|             } |             } | ||||||
|  |  | ||||||
|         # Generate a unique key for the image |         # Generate a unique key for the image | ||||||
|         unique_key = hashlib.sha256(f"{filename}{datetime.now().isoformat()}".encode()).hexdigest()[:16] |         unique_key = hashlib.sha256( | ||||||
|  |             f"{filename}{datetime.now().isoformat()}".encode() | ||||||
|  |         ).hexdigest()[:16] | ||||||
|  |  | ||||||
|         # Embed the unique key into the image |         # Embed the unique key into the image | ||||||
|         try: |         try: | ||||||
| @ -464,8 +484,7 @@ def admin_upload(): | |||||||
|         except ValueError as e: |         except ValueError as e: | ||||||
|             flash(f"Error embedding key: {str(e)}") |             flash(f"Error embedding key: {str(e)}") | ||||||
|             os.remove(file_path) |             os.remove(file_path) | ||||||
|             return redirect(url_for('admin')) |             return redirect(url_for("admin")) | ||||||
|  |  | ||||||
|  |  | ||||||
|         # Generate thumbnails |         # Generate thumbnails | ||||||
|         generate_thumbnails(filename) |         generate_thumbnails(filename) | ||||||
| @ -474,7 +493,7 @@ def admin_upload(): | |||||||
|         with Image.open(file_path) as img: |         with Image.open(file_path) as img: | ||||||
|             width, height = img.size |             width, height = img.size | ||||||
|  |  | ||||||
|         exposure_time = exif['ExposureTime'] |         exposure_time = exif["ExposureTime"] | ||||||
|         if isinstance(exposure_time, tuple): |         if isinstance(exposure_time, tuple): | ||||||
|             exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}" |             exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}" | ||||||
|         else: |         else: | ||||||
| @ -485,30 +504,99 @@ def admin_upload(): | |||||||
|         new_photo = Photo( |         new_photo = Photo( | ||||||
|             input_filename=filename, |             input_filename=filename, | ||||||
|             thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}", |             thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}", | ||||||
|             focal_length=str(exif.get('FocalLengthIn35mmFilm', exif.get('FocalLength', ''))), |             focal_length=str( | ||||||
|             aperture=str(exif.get('FNumber', '')), |                 exif.get("FocalLengthIn35mmFilm", exif.get("FocalLength", "")) | ||||||
|  |             ), | ||||||
|  |             aperture=str(exif.get("FNumber", "")), | ||||||
|             shutter_speed=exposure_fraction, |             shutter_speed=exposure_fraction, | ||||||
|             date_taken=datetime.strptime(str(exif.get('DateTime', '1970:01:01 00:00:00')), '%Y:%m:%d %H:%M:%S'), |             date_taken=datetime.strptime( | ||||||
|             iso=int(exif.get('ISOSpeedRatings', 0)), |                 str(exif.get("DateTime", "1970:01:01 00:00:00")), "%Y:%m:%d %H:%M:%S" | ||||||
|             orientation=int(exif.get('Orientation', 1)), |             ), | ||||||
|  |             iso=int(exif.get("ISOSpeedRatings", 0)), | ||||||
|  |             orientation=int(exif.get("Orientation", 1)), | ||||||
|             width=width, |             width=width, | ||||||
|             height=height, |             height=height, | ||||||
|             highlight_color=get_highlight_color(THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"), |             highlight_color=get_highlight_color( | ||||||
|             unique_key=unique_key |                 THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}" | ||||||
|  |             ), | ||||||
|  |             unique_key=unique_key, | ||||||
|         ) |         ) | ||||||
|         db_session.add(new_photo) |         db_session.add(new_photo) | ||||||
|         db_session.commit() |         db_session.commit() | ||||||
|         db_session.close() |         db_session.close() | ||||||
|  |  | ||||||
|         flash('File uploaded successfully') |         flash("File uploaded successfully") | ||||||
|         return redirect(url_for('admin')) |         return redirect(url_for("admin")) | ||||||
|  |  | ||||||
|     flash('Invalid file type') |     flash("Invalid file type") | ||||||
|     return redirect(url_for('admin')) |     return redirect(url_for("admin")) | ||||||
|  |  | ||||||
| if __name__ == '__main__': | # Add a new route to handle config updates | ||||||
|     app.run( | @app.route("/admin/update_config", methods=["POST"]) | ||||||
|         debug=True,  | def update_config(): | ||||||
|         port=config['server']['port'],  |     if "logged_in" not in session: | ||||||
|         host=config['server']['host'] |         return jsonify({"success": False, "error": "Not logged in"}), 401 | ||||||
|  |  | ||||||
|  |     try: | ||||||
|  |         data = request.json | ||||||
|  |         db_session = DBSession() | ||||||
|  |         site_config = db_session.query(SiteConfig).first() | ||||||
|  |  | ||||||
|  |         # Map form fields to SiteConfig attributes | ||||||
|  |         field_mapping = { | ||||||
|  |             "appearance.site_title": "site_title", | ||||||
|  |             "appearance.accent_color": "accent_color", | ||||||
|  |             "about.name": "author_name", | ||||||
|  |             "about.location": "author_location", | ||||||
|  |             "about.profile_image": "profile_image", | ||||||
|  |             "about.bio": "bio", | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         # Update site config | ||||||
|  |         for form_field, db_field in field_mapping.items(): | ||||||
|  |             if form_field in data: | ||||||
|  |                 setattr(site_config, db_field, data[form_field]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         site_config.updated_at = datetime.now(UTC) | ||||||
|  |         db_session.commit() | ||||||
|  |         db_session.close() | ||||||
|  |         # Reload config | ||||||
|  |         site_config = get_site_config() | ||||||
|  |         config.update(site_config) | ||||||
|  |         return jsonify({"success": True}) | ||||||
|  |     except Exception as e: | ||||||
|  |         return jsonify({"success": False, "error": str(e)}), 500 | ||||||
|  |  | ||||||
|  | # Add a new endpoint to view config history | ||||||
|  | @app.route("/admin/config_history") | ||||||
|  | def config_history(): | ||||||
|  |     if "logged_in" not in session: | ||||||
|  |         return redirect(url_for("admin_login")) | ||||||
|  |  | ||||||
|  |     db_session = DBSession() | ||||||
|  |     site_configs = ( | ||||||
|  |         db_session.query(SiteConfig).order_by(SiteConfig.updated_at.desc()).all() | ||||||
|     ) |     ) | ||||||
|  |     db_session.close() | ||||||
|  |  | ||||||
|  |     return render_template( | ||||||
|  |         "config_history.html", | ||||||
|  |         site_configs=site_configs, | ||||||
|  |         accent_color=config["appearance"]["accent_color"], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  | # ---------------------------------------------------------------- | ||||||
|  | # Static routes | ||||||
|  | # ----------------------------------------------------------------     | ||||||
|  | @app.route("/static/thumbnails/<path:filename>") | ||||||
|  | def serve_thumbnail(filename): | ||||||
|  |     return send_from_directory(THUMBNAIL_FOLDER, filename) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Initialize database tables | ||||||
|  | init_db() | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     app.run(debug=False, port=config["server"]["port"], host=config["server"]["host"]) | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ password = "changeme" | |||||||
|  |  | ||||||
| [appearance] | [appearance] | ||||||
| accent_color = "#007bff" | accent_color = "#007bff" | ||||||
|  | site_title = "Spectra" | ||||||
|  |  | ||||||
| [security] | [security] | ||||||
| # Add these security settings | # Add these security settings | ||||||
|  | |||||||
							
								
								
									
										28
									
								
								config.py
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								config.py
									
									
									
									
									
								
							| @ -1,32 +1,24 @@ | |||||||
| import toml |  | ||||||
| import os | import os | ||||||
| import secrets | import secrets | ||||||
|  |  | ||||||
| CONFIG_FILE = 'config.toml' | import toml | ||||||
|  |  | ||||||
|  | CONFIG_FILE = "config.toml" | ||||||
|  |  | ||||||
|  |  | ||||||
| def load_or_create_config(): | def load_or_create_config(): | ||||||
|     if not os.path.exists(CONFIG_FILE): |     if not os.path.exists(CONFIG_FILE): | ||||||
|         admin_password = secrets.token_urlsafe(16) |         admin_password = secrets.token_urlsafe(16) | ||||||
|         config = { |         config = { | ||||||
|             'admin': { |             "admin": {"password": admin_password}, | ||||||
|                 'password': admin_password |             "directories": {"upload": "uploads", "thumbnail": "thumbnails"}, | ||||||
|             }, |             "appearance": {"accent_color": "#ff6600"}, | ||||||
|             'directories': { |             "server": {"host": "0.0.0.0", "port": 5002}, | ||||||
|                 'upload': 'uploads', |  | ||||||
|                 'thumbnail': 'thumbnails' |  | ||||||
|             }, |  | ||||||
|             'appearance': { |  | ||||||
|                 'accent_color': '#ff6600' |  | ||||||
|             }, |  | ||||||
|             'server': { |  | ||||||
|                 'host': '0.0.0.0', |  | ||||||
|                 'port': 5002 |  | ||||||
|         } |         } | ||||||
|         } |         with open(CONFIG_FILE, "w") as f: | ||||||
|         with open(CONFIG_FILE, 'w') as f: |  | ||||||
|             toml.dump(config, f) |             toml.dump(config, f) | ||||||
|         print(f"Generated new config file with admin password: {admin_password}") |         print(f"Generated new config file with admin password: {admin_password}") | ||||||
|     else: |     else: | ||||||
|         with open(CONFIG_FILE, 'r') as f: |         with open(CONFIG_FILE, "r") as f: | ||||||
|             config = toml.load(f) |             config = toml.load(f) | ||||||
|     return config |     return config | ||||||
|  | |||||||
| @ -2,33 +2,21 @@ version: '3.8' | |||||||
|  |  | ||||||
| services: | services: | ||||||
|   web: |   web: | ||||||
|     build: . |     image: git.dws.rip/dws/spectra:main | ||||||
|     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: |     volumes: | ||||||
|       - ./uploads:/app/uploads |       - ./uploads:/app/uploads | ||||||
|       - ./thumbnails:/app/thumbnails |       - ./thumbnails:/app/thumbnails | ||||||
|       - ./config.toml:/app/config.toml |       - ./config.toml:/app/config.toml | ||||||
|       - ./photos.db:/app/photos.db |       - ./photos.db:/app/photos.db | ||||||
|  |       - ./static:/app/static | ||||||
|     environment: |     environment: | ||||||
|       - PYTHONUNBUFFERED=1 |       - PYTHONUNBUFFERED=1 | ||||||
|       - FLASK_ENV=production |       - FLASK_ENV=production | ||||||
|       - WORKERS=4 |       - WORKERS=4 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     networks: |  | ||||||
|       - traefik-public |  | ||||||
|       - default |  | ||||||
|     healthcheck: |     healthcheck: | ||||||
|       test: ["CMD", "curl", "-f", "http://localhost:5000/"] |       test: ["CMD", "curl", "-f", "http://localhost:5000/"] | ||||||
|       interval: 30s |       interval: 30s | ||||||
|       timeout: 10s |       timeout: 10s | ||||||
|       retries: 3 |       retries: 3 | ||||||
|       start_period: 40s |       start_period: 40s | ||||||
|  |  | ||||||
| networks: |  | ||||||
|   traefik-public: |  | ||||||
|     external: true |  | ||||||
							
								
								
									
										54
									
								
								models.py
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								models.py
									
									
									
									
									
								
							| @ -1,11 +1,15 @@ | |||||||
| from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float | from datetime import datetime | ||||||
|  |  | ||||||
|  | from sqlalchemy import (Column, DateTime, Float, Integer, String, Text, | ||||||
|  |                         create_engine) | ||||||
| from sqlalchemy.ext.declarative import declarative_base | from sqlalchemy.ext.declarative import declarative_base | ||||||
| from sqlalchemy.orm import sessionmaker | from sqlalchemy.orm import sessionmaker | ||||||
|  |  | ||||||
| Base = declarative_base() | Base = declarative_base() | ||||||
|  |  | ||||||
|  |  | ||||||
| class Photo(Base): | class Photo(Base): | ||||||
|     __tablename__ = 'photos' |     __tablename__ = "photos" | ||||||
|  |  | ||||||
|     id = Column(Integer, primary_key=True, autoincrement=True) |     id = Column(Integer, primary_key=True, autoincrement=True) | ||||||
|     input_filename = Column(String, nullable=False) |     input_filename = Column(String, nullable=False) | ||||||
| @ -19,8 +23,50 @@ class Photo(Base): | |||||||
|     height = Column(Integer) |     height = Column(Integer) | ||||||
|     highlight_color = Column(String) |     highlight_color = Column(String) | ||||||
|     orientation = Column(Integer) |     orientation = Column(Integer) | ||||||
|     unique_key = Column(String(16), nullable=False)  # Add this line |     unique_key = Column(String(16), nullable=False) | ||||||
|  |  | ||||||
| engine = create_engine('sqlite:///photos.db') |  | ||||||
|  | class SiteConfig(Base): | ||||||
|  |     __tablename__ = "site_config" | ||||||
|  |  | ||||||
|  |     id = Column(Integer, primary_key=True, autoincrement=True) | ||||||
|  |     # Appearance | ||||||
|  |     site_title = Column(String, nullable=False, default="Spectra") | ||||||
|  |     accent_color = Column(String, nullable=False, default="#007bff") | ||||||
|  |  | ||||||
|  |     # About | ||||||
|  |     author_name = Column(String, nullable=False, default="Your Name") | ||||||
|  |     author_location = Column(String, nullable=False, default="Your Location") | ||||||
|  |     profile_image = Column(String, nullable=False, default="/static/profile.jpg") | ||||||
|  |     bio = Column( | ||||||
|  |         Text, | ||||||
|  |         nullable=False, | ||||||
|  |         default="Write your bio in *markdown* here.\n\nSupports **multiple** paragraphs.", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Metadata | ||||||
|  |     updated_at = Column(DateTime, default=datetime.utcnow) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | engine = create_engine("sqlite:///photos.db") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def init_db(): | ||||||
|  |     session = None | ||||||
|  |     try: | ||||||
|         Base.metadata.create_all(engine) |         Base.metadata.create_all(engine) | ||||||
|  |         # Create default site config if none exists | ||||||
|  |         session = Session() | ||||||
|  |         if not session.query(SiteConfig).first(): | ||||||
|  |             session.add(SiteConfig()) | ||||||
|  |             session.commit() | ||||||
|  |     except Exception as e: | ||||||
|  |         # Tables already exist, skip creation | ||||||
|  |         pass | ||||||
|  |     finally: | ||||||
|  |         if session: | ||||||
|  |             session.close() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | init_db() | ||||||
| Session = sessionmaker(bind=engine) | Session = sessionmaker(bind=engine) | ||||||
|  | |||||||
| @ -2,17 +2,28 @@ APScheduler==3.10.4 | |||||||
| blinker==1.8.2 | blinker==1.8.2 | ||||||
| click==8.1.7 | click==8.1.7 | ||||||
| colorthief==0.2.1 | colorthief==0.2.1 | ||||||
|  | Deprecated==1.2.14 | ||||||
| exif==1.6.0 | exif==1.6.0 | ||||||
| Flask==3.0.3 | Flask==3.0.3 | ||||||
|  | Flask-Limiter==3.8.0 | ||||||
| greenlet==3.1.1 | greenlet==3.1.1 | ||||||
|  | gunicorn==23.0.0 | ||||||
|  | importlib_resources==6.4.5 | ||||||
| itsdangerous==2.2.0 | itsdangerous==2.2.0 | ||||||
| Jinja2==3.1.4 | Jinja2==3.1.4 | ||||||
|  | limits==3.13.0 | ||||||
|  | markdown-it-py==3.0.0 | ||||||
| MarkupSafe==3.0.1 | MarkupSafe==3.0.1 | ||||||
|  | mdurl==0.1.2 | ||||||
| numpy==2.1.2 | numpy==2.1.2 | ||||||
|  | ordered-set==4.1.0 | ||||||
|  | packaging==24.1 | ||||||
| piexif==1.1.3 | piexif==1.1.3 | ||||||
| pillow==11.0.0 | pillow==11.0.0 | ||||||
| plum-py==0.8.7 | plum-py==0.8.7 | ||||||
|  | Pygments==2.18.0 | ||||||
| pytz==2024.2 | pytz==2024.2 | ||||||
|  | rich==13.9.4 | ||||||
| six==1.16.0 | six==1.16.0 | ||||||
| SQLAlchemy==2.0.36 | SQLAlchemy==2.0.36 | ||||||
| toml==0.10.2 | toml==0.10.2 | ||||||
| @ -20,4 +31,4 @@ typing_extensions==4.12.2 | |||||||
| tzlocal==5.2 | tzlocal==5.2 | ||||||
| watchdog==6.0.0 | watchdog==6.0.0 | ||||||
| Werkzeug==3.0.4 | Werkzeug==3.0.4 | ||||||
| gunicorn | wrapt==1.16.0 | ||||||
|  | |||||||
| @ -1,8 +1,10 @@ | |||||||
| from PIL import Image |  | ||||||
| import numpy as np | import numpy as np | ||||||
|  | from PIL import Image | ||||||
|  |  | ||||||
|  |  | ||||||
| def string_to_binary(message): | def string_to_binary(message): | ||||||
|     return ''.join(format(ord(char), '08b') for char in message) |     return "".join(format(ord(char), "08b") for char in message) | ||||||
|  |  | ||||||
|  |  | ||||||
| def embed_message(image_path, message, exifraw): | def embed_message(image_path, message, exifraw): | ||||||
|     # Open the image |     # Open the image | ||||||
| @ -28,11 +30,12 @@ def embed_message(image_path, message, exifraw): | |||||||
|     stego_array = flat_array.reshape(img_array.shape) |     stego_array = flat_array.reshape(img_array.shape) | ||||||
|  |  | ||||||
|     # Create a new image from the modified array |     # Create a new image from the modified array | ||||||
|     stego_img = Image.fromarray(stego_array.astype('uint8'), img.mode) |     stego_img = Image.fromarray(stego_array.astype("uint8"), img.mode) | ||||||
|  |  | ||||||
|     # Save the image |     # Save the image | ||||||
|     stego_img.save(image_path, exif=exifraw) |     stego_img.save(image_path, exif=exifraw) | ||||||
|  |  | ||||||
|  |  | ||||||
| def extract_message(image_path, message_length): | def extract_message(image_path, message_length): | ||||||
|     # Open the image |     # Open the image | ||||||
|     img = Image.open(image_path) |     img = Image.open(image_path) | ||||||
| @ -43,9 +46,16 @@ def extract_message(image_path, message_length): | |||||||
|     flat_array = img_array.flatten() |     flat_array = img_array.flatten() | ||||||
|  |  | ||||||
|     # Extract the binary message |     # Extract the binary message | ||||||
|     binary_message = ''.join([str(pixel & 1) for pixel in flat_array[:message_length * 8]]) |     binary_message = "".join( | ||||||
|  |         [str(pixel & 1) for pixel in flat_array[: message_length * 8]] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     # Convert binary to string |     # Convert binary to string | ||||||
|     message = ''.join([chr(int(binary_message[i:i+8], 2)) for i in range(0, len(binary_message), 8)]) |     message = "".join( | ||||||
|  |         [ | ||||||
|  |             chr(int(binary_message[i : i + 8], 2)) | ||||||
|  |             for i in range(0, len(binary_message), 8) | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     return message |     return message | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ | |||||||
|     <link rel="preconnect" href="https://fonts.googleapis.com"> |     <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|     <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet"> |     <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet"> | ||||||
|  |     <link rel="icon" href="data:;base64,iVBORw0KGgo="> | ||||||
|     <style> |     <style> | ||||||
|         body { |         body { | ||||||
|             margin: 0; |             margin: 0; | ||||||
| @ -115,6 +116,42 @@ | |||||||
|         .delete-btn:hover { |         .delete-btn:hover { | ||||||
|             background-color: #ff1a1a; |             background-color: #ff1a1a; | ||||||
|         } |         } | ||||||
|  |         .config-section { | ||||||
|  |             margin: 2rem 0; | ||||||
|  |             padding: 1rem; | ||||||
|  |             background: #fff; | ||||||
|  |             border-radius: 4px; | ||||||
|  |             box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||||||
|  |         } | ||||||
|  |         .config-editor fieldset { | ||||||
|  |             margin-bottom: 1.5rem; | ||||||
|  |             border: 1px solid #ddd; | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-radius: 4px; | ||||||
|  |         } | ||||||
|  |         .config-editor legend { | ||||||
|  |             font-weight: bold; | ||||||
|  |             padding: 0 0.5rem; | ||||||
|  |         } | ||||||
|  |         .form-group { | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |         } | ||||||
|  |         .form-group label { | ||||||
|  |             display: block; | ||||||
|  |             margin-bottom: 0.5rem; | ||||||
|  |             font-weight: 500; | ||||||
|  |         } | ||||||
|  |         .form-group input, | ||||||
|  |         .form-group textarea { | ||||||
|  |             width: 100%; | ||||||
|  |             padding: 0.5rem; | ||||||
|  |             border: 1px solid #ddd; | ||||||
|  |             border-radius: 4px; | ||||||
|  |             font-size: 1rem; | ||||||
|  |         } | ||||||
|  |         .form-group textarea { | ||||||
|  |             font-family: monospace; | ||||||
|  |         } | ||||||
|     </style> |     </style> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| @ -173,8 +210,55 @@ | |||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|             </tbody> |             </tbody> | ||||||
|         </table> |         </table> | ||||||
|  |         <div class="config-section"> | ||||||
|  |             <h2>Configuration</h2> | ||||||
|  |             <div class="config-editor"> | ||||||
|  |                 <form id="configForm"> | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                     <!-- Appearance --> | ||||||
|  |                     <fieldset> | ||||||
|  |                         <legend>Appearance</legend> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="appearance.accent_color">Accent Color:</label> | ||||||
|  |                             <input style="min-height: 2rem;" type="color" id="appearance.accent_color" name="appearance.accent_color" value="{{ config.appearance.accent_color }}"> | ||||||
|                         </div> |                         </div> | ||||||
|     <script> |                         <div class="form-group"> | ||||||
|  |                             <label for="appearance.site_title">Site Title:</label> | ||||||
|  |                             <input type="text" id="appearance.site_title" name="appearance.site_title" value="{{ config.appearance.site_title }}"> | ||||||
|  |                         </div> | ||||||
|  |                     </fieldset> | ||||||
|  |  | ||||||
|  |                     <!-- About Section --> | ||||||
|  |                     <fieldset> | ||||||
|  |                         <legend>About</legend> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="about.name">Name:</label> | ||||||
|  |                             <input type="text" id="about.name" name="about.name" value="{{ config.about.name }}"> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="about.location">Location:</label> | ||||||
|  |                             <input type="text" id="about.location" name="about.location" value="{{ config.about.location }}"> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="about.profile_image">Profile Image Path:</label> | ||||||
|  |                             <input type="text" id="about.profile_image" name="about.profile_image" value="{{ config.about.profile_image }}"> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="about.bio">Bio (Markdown):</label> | ||||||
|  |                             <textarea id="about.bio" name="about.bio" rows="4">{{ config.about.bio }}</textarea> | ||||||
|  |                         </div> | ||||||
|  |                     </fieldset> | ||||||
|  |  | ||||||
|  |                     <button type="submit" class="btn btn-primary">Save Configuration</button> | ||||||
|  |                 </form> | ||||||
|  |             </div> | ||||||
|  |             <div class="config-history-link" style="margin-top: 1rem;"> | ||||||
|  |                 <a href="{{ url_for('config_history') }}">View Configuration History</a> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  |     <script nonce="{{ g.csp_nonce }}"> | ||||||
|         function makeEditable(element) { |         function makeEditable(element) { | ||||||
|             const value = element.textContent; |             const value = element.textContent; | ||||||
|             const input = document.createElement('input'); |             const input = document.createElement('input'); | ||||||
| @ -250,6 +334,38 @@ | |||||||
|                 }); |                 }); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         document.getElementById('configForm').addEventListener('submit', async (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |              | ||||||
|  |             const formData = {}; | ||||||
|  |             const inputs = e.target.querySelectorAll('input, textarea'); | ||||||
|  |              | ||||||
|  |             inputs.forEach(input => { | ||||||
|  |                 formData[input.name] = input.value; | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             try { | ||||||
|  |                 const response = await fetch('/admin/update_config', { | ||||||
|  |                     method: 'POST', | ||||||
|  |                     headers: { | ||||||
|  |                         'Content-Type': 'application/json' | ||||||
|  |                     }, | ||||||
|  |                     body: JSON.stringify(formData) | ||||||
|  |                 }); | ||||||
|  |                  | ||||||
|  |                 const result = await response.json(); | ||||||
|  |                  | ||||||
|  |                 if (result.success) { | ||||||
|  |                     alert('Configuration saved successfully'); | ||||||
|  |                     location.reload(); | ||||||
|  |                 } else { | ||||||
|  |                     alert('Error saving configuration: ' + result.error); | ||||||
|  |                 } | ||||||
|  |             } catch (error) { | ||||||
|  |                 alert('Error saving configuration: ' + error); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|     </script> |     </script> | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ | |||||||
|     <link rel="preconnect" href="https://fonts.googleapis.com"> |     <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|     <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet"> |     <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet"> | ||||||
|  |     <link rel="icon" href="data:;base64,iVBORw0KGgo="> | ||||||
|     <style> |     <style> | ||||||
|         body { |         body { | ||||||
|             margin: 0; |             margin: 0; | ||||||
|  | |||||||
							
								
								
									
										57
									
								
								templates/config_history.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								templates/config_history.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,57 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <title>Site Configuration History</title> | ||||||
|  |     <link rel="icon" href="data:;base64,iVBORw0KGgo="> | ||||||
|  |     <style> | ||||||
|  |         .config-entry { | ||||||
|  |             background: white; | ||||||
|  |             padding: 1rem; | ||||||
|  |             margin-bottom: 1rem; | ||||||
|  |             border-radius: 4px; | ||||||
|  |             box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||||||
|  |         } | ||||||
|  |         .config-date { | ||||||
|  |             color: #666; | ||||||
|  |             font-size: 0.9rem; | ||||||
|  |         } | ||||||
|  |         pre { | ||||||
|  |             background: #f5f5f5; | ||||||
|  |             padding: 1rem; | ||||||
|  |             border-radius: 4px; | ||||||
|  |             overflow-x: auto; | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |     <div class="container"> | ||||||
|  |         <div class="logout"> | ||||||
|  |             <a href="{{ url_for('admin') }}">Back to Admin</a> | | ||||||
|  |             <a href="{{ url_for('admin_logout') }}">Logout</a> | ||||||
|  |         </div> | ||||||
|  |         <h1>Site Configuration History</h1> | ||||||
|  |          | ||||||
|  |         {% for config in site_configs %} | ||||||
|  |         <div class="config-entry"> | ||||||
|  |             <div class="config-date"> | ||||||
|  |                 Updated: {{ config.updated_at.strftime('%Y-%m-%d %H:%M:%S UTC') }} | ||||||
|  |             </div> | ||||||
|  |             <pre>{ | ||||||
|  |     "appearance": { | ||||||
|  |         "site_title": "{{ config.site_title }}", | ||||||
|  |         "accent_color": "{{ config.accent_color }}" | ||||||
|  |     }, | ||||||
|  |     "about": { | ||||||
|  |         "name": "{{ config.author_name }}", | ||||||
|  |         "location": "{{ config.author_location }}", | ||||||
|  |         "profile_image": "{{ config.profile_image }}", | ||||||
|  |         "bio": {{ config.bio | tojson }} | ||||||
|  |     } | ||||||
|  | }</pre> | ||||||
|  |         </div> | ||||||
|  |         {% endfor %} | ||||||
|  |     </div> | ||||||
|  | </body> | ||||||
|  | </html>  | ||||||
| @ -3,10 +3,11 @@ | |||||||
| <head> | <head> | ||||||
|     <meta charset="UTF-8"> |     <meta charset="UTF-8"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|     <title>Tanishq Dubey Photography</title> |     <title>{{ site_title }}</title> | ||||||
|     <link rel="preconnect" href="https://fonts.googleapis.com"> |     <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|     <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet"> |     <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet"> | ||||||
|  |     <link rel="icon" href="data:;base64,iVBORw0KGgo="> | ||||||
|     <style> |     <style> | ||||||
|         body { |         body { | ||||||
|             margin: 0; |             margin: 0; | ||||||
| @ -172,31 +173,119 @@ | |||||||
|                 display: block; |                 display: block; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         .modal { | ||||||
|  |             display: none; | ||||||
|  |             position: fixed; | ||||||
|  |             z-index: 1000; | ||||||
|  |             left: 0; | ||||||
|  |             top: 0; | ||||||
|  |             width: 100%; | ||||||
|  |             height: 100%; | ||||||
|  |             background-color: rgba(0,0,0,0.5); | ||||||
|  |             backdrop-filter: blur(5px); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .modal-content { | ||||||
|  |             background-color: #f0f0f0; | ||||||
|  |             margin: 10% auto; | ||||||
|  |             padding: 20px; | ||||||
|  |             width: 80%; | ||||||
|  |             max-width: 600px; | ||||||
|  |             border-radius: 8px; | ||||||
|  |             position: relative; | ||||||
|  |             display: flex; | ||||||
|  |             flex-direction: column; | ||||||
|  |             gap: 1rem; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .modal-header { | ||||||
|  |             display: flex; | ||||||
|  |             align-items: center; | ||||||
|  |             gap: 1rem; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .profile-image { | ||||||
|  |             width: 100px; | ||||||
|  |             height: 100px; | ||||||
|  |             border-radius: 50%; | ||||||
|  |             object-fit: cover; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .profile-info { | ||||||
|  |             flex-grow: 1; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .profile-name { | ||||||
|  |             font-size: 1.5rem; | ||||||
|  |             margin: 0; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .profile-location { | ||||||
|  |             color: #666; | ||||||
|  |             margin: 0; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .modal-bio { | ||||||
|  |             line-height: 1.6; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .close { | ||||||
|  |             position: absolute; | ||||||
|  |             right: 20px; | ||||||
|  |             top: 20px; | ||||||
|  |             font-size: 1.5rem; | ||||||
|  |             cursor: pointer; | ||||||
|  |             color: #666; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         .close:hover { | ||||||
|  |             color: #000; | ||||||
|  |         } | ||||||
|     </style> |     </style> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
|  |     <header> | ||||||
|         <div class="sidebar"> |         <div class="sidebar"> | ||||||
|             <div class="sidebar-nav noto-sans-mono-font"> |             <div class="sidebar-nav noto-sans-mono-font"> | ||||||
|                 <div class="nav-toggle">☰</div> |                 <div class="nav-toggle">☰</div> | ||||||
|                 <nav> |                 <nav> | ||||||
|                     <ul> |                     <ul> | ||||||
|                         <li><a href="/">Home</a></li> |                         <li><a href="/">Home</a></li> | ||||||
|  |                         <li><a href="#" id="aboutLink">About</a></li> | ||||||
|                         <li><hr></li> |                         <li><hr></li> | ||||||
|                         <li>Powered by <a href="https://dws.rip">DWS</a></li> |                         <li>Powered by <a href="https://dws.rip">DWS</a></li> | ||||||
|                     </ul> |                     </ul> | ||||||
|                 </nav> |                 </nav> | ||||||
|             </div> |             </div> | ||||||
|             <div class="sidebar-title noto-sans-mono-font"> |             <div class="sidebar-title noto-sans-mono-font"> | ||||||
|             <h1>Tanishq Dubey Photography</h1> |                 <h1>{{ site_title }}</h1> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
|  |     </header> | ||||||
|  |  | ||||||
|     <div class="main-content"> |     <div class="main-content"> | ||||||
|       <div class="grid-container" id="polaroid-grid"></div> |       <div class="grid-container" id="polaroid-grid"></div> | ||||||
|       <div id="loading">Loading more images...</div> |       <div id="loading">Loading more images...</div> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <script> |     <div id="aboutModal" class="modal"> | ||||||
|  |         <div class="modal-content"> | ||||||
|  |             <span class="close">×</span> | ||||||
|  |             <div class="modal-header"> | ||||||
|  |                 <img src="{{ about.profile_image }}" alt="{{ about.name }}" class="profile-image"> | ||||||
|  |                 <div class="profile-info"> | ||||||
|  |                     <h2 class="profile-name">{{ about.name }}</h2> | ||||||
|  |                     <p class="profile-location">{{ about.location }}</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             <div class="modal-bio" id="bio-content"></div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" nonce="{{ nonce }}"></script> | ||||||
|  |  | ||||||
|  |     <script nonce="{{ nonce }}"> | ||||||
|         const gridContainer = document.getElementById('polaroid-grid'); |         const gridContainer = document.getElementById('polaroid-grid'); | ||||||
|         const loadingIndicator = document.getElementById('loading'); |         const loadingIndicator = document.getElementById('loading'); | ||||||
|         const baseSize = 110; // Size of one grid cell in pixels |         const baseSize = 110; // Size of one grid cell in pixels | ||||||
| @ -345,6 +434,32 @@ | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         setupNavToggle(); |         setupNavToggle(); | ||||||
|  |  | ||||||
|  |         // Modal functionality | ||||||
|  |         const modal = document.getElementById('aboutModal'); | ||||||
|  |         const aboutLink = document.getElementById('aboutLink'); | ||||||
|  |         const closeBtn = document.querySelector('.close'); | ||||||
|  |         const bioContent = document.getElementById('bio-content'); | ||||||
|  |  | ||||||
|  |         // Render markdown content | ||||||
|  |         bioContent.innerHTML = marked.parse(`{{ about.bio | safe }}`); | ||||||
|  |  | ||||||
|  |         aboutLink.onclick = function() { | ||||||
|  |             modal.style.display = 'block'; | ||||||
|  |             document.body.style.overflow = 'hidden'; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         closeBtn.onclick = function() { | ||||||
|  |             modal.style.display = 'none'; | ||||||
|  |             document.body.style.overflow = 'auto'; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         window.onclick = function(event) { | ||||||
|  |             if (event.target == modal) { | ||||||
|  |                 modal.style.display = 'none'; | ||||||
|  |                 document.body.style.overflow = 'auto'; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|     </script> |     </script> | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user