Checkpoint, configuration can now be done through DB and the site. Server settings are still on the file system

This commit is contained in:
Tanishq Dubey 2024-11-05 19:03:04 -05:00
parent b46ec98115
commit 9abdd18f33
8 changed files with 770 additions and 289 deletions

613
app.py
View File

@ -1,123 +1,73 @@
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, init_db
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 import getLogger
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.middleware.proxy_fix import ProxyFix
from werkzeug.utils import secure_filename
from models import Photo
from models import Session as DBSession
from models import SiteConfig, init_db
from steganography import embed_message, extract_message
DEFAULT_CONFIG = {
"server": {"host": "0.0.0.0", "port": 5000},
"directories": {"upload": "uploads", "thumbnail": "thumbnails"},
"admin": {"password": secrets.token_urlsafe(16)}, # Generate secure random password
}
# Add this logging configuration before creating the Flask app
dictConfig({
'version': 1,
'formatters': {
'default': {
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
}
},
'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']
}
})
# Get logger for this module
logger = getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.urandom(24) app.secret_key = os.urandom(24)
config = load_or_create_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()
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): 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
@ -154,24 +104,172 @@ 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://",
)
# Generate a strong secret key at startup
app.secret_key = secrets.token_hex(32)
@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:
@ -182,50 +280,54 @@ def get_images():
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(
"admin.html",
photos=photos,
accent_color=config["appearance"]["accent_color"],
config=config,
)
@app.route("/admin/logout")
@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()
@ -233,35 +335,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
@ -270,7 +372,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))
@ -281,17 +385,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)
@ -300,69 +404,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(
{"verified": False, "message": "Image ownership could not be verified"}
)
except Exception as e: except Exception as e:
return jsonify({'verified': False, 'error': str(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 # 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
@ -371,7 +459,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:
@ -379,8 +469,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)
@ -389,7 +478,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:
@ -400,33 +489,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")
return redirect(url_for("admin"))
# Add a new route to handle config updates
@app.route("/admin/update_config", methods=["POST"])
def update_config():
if "logged_in" not in session:
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)
flash('Invalid file type')
return redirect(url_for('admin'))
# Initialize database tables # Initialize database tables
init_db() init_db()
if __name__ == '__main__': if __name__ == "__main__":
app.run( app.run(debug=False, port=config["server"]["port"], host=config["server"]["host"])
debug=True,
port=config['server']['port'],
host=config['server']['host']
)

View File

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

View File

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

View File

@ -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,16 +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)
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")
engine = create_engine('sqlite:///photos.db')
def init_db(): def init_db():
session = None
try: 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: except Exception as e:
# Tables already exist, skip creation # Tables already exist, skip creation
pass pass
finally:
if session:
session.close()
init_db() init_db()
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)

View File

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

View File

@ -115,6 +115,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 +209,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 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> </div>
<script> <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 +333,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>

View File

@ -0,0 +1,56 @@
<!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>
<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>

View File

@ -3,7 +3,7 @@
<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">
@ -172,31 +172,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>
<div class="sidebar"> <header>
<div class="sidebar-nav noto-sans-mono-font"> <div class="sidebar">
<div class="nav-toggle"></div> <div class="sidebar-nav noto-sans-mono-font">
<nav> <div class="nav-toggle"></div>
<ul> <nav>
<li><a href="/">Home</a></li> <ul>
<li><hr></li> <li><a href="/">Home</a></li>
<li>Powered by <a href="https://dws.rip">DWS</a></li> <li><a href="#" id="aboutLink">About</a></li>
</ul> <li><hr></li>
</nav> <li>Powered by <a href="https://dws.rip">DWS</a></li>
</ul>
</nav>
</div>
<div class="sidebar-title noto-sans-mono-font">
<h1>{{ site_title }}</h1>
</div>
</div> </div>
<div class="sidebar-title noto-sans-mono-font"> </header>
<h1>Tanishq Dubey Photography</h1>
</div>
</div>
<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">&times;</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 +433,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>