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

641
app.py
View File

@ -1,123 +1,73 @@
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, send_from_directory
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 atexit
import colorsys
from steganography import embed_message, extract_message
import hashlib
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import toml
import os
import random
import secrets
import threading
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.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
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.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):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@ -154,25 +104,173 @@ def generate_thumbnails(filename):
else:
img.save(thumb_path, optimize=True, quality=85)
def generate_all_thumbnails():
for filename in os.listdir(UPLOAD_FOLDER):
if allowed_file(filename):
generate_thumbnails(filename)
def load_or_create_config():
"""Load config from file or create with defaults if missing"""
config_path = Path("config.toml")
logger.info(f"Loading config from {config_path}")
scheduler.add_job(generate_all_thumbnails, 'interval', minutes=5)
scheduler.add_job(generate_all_thumbnails, 'date', run_date=datetime.now()) # Run once at startup
try:
# 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():
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():
page = int(request.args.get('page', 1))
page = int(request.args.get("page", 1))
per_page = 20
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 = []
for photo in photos:
factor = random.randint(2, 3)
@ -182,50 +280,54 @@ def get_images():
width, height = photo.height, photo.width
else:
width, height = photo.width, photo.height
images.append({
'imgSrc': f'/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}',
'width': width / factor,
'height': height / factor,
'caption': photo.input_filename,
'date': photo.date_taken.strftime('%y %m %d'),
'technicalInfo': f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}",
'highlightColor': photo.highlight_color
})
images.append(
{
"imgSrc": f"/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}",
"width": width / factor,
"height": height / factor,
"caption": photo.input_filename,
"date": photo.date_taken.strftime("%y %m %d"),
"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
db_session.close()
return jsonify({'images': images, 'hasMore': has_more})
@app.route('/admin')
return jsonify({"images": images, "hasMore": has_more})
# ----------------------------------------------------------------
# Admin routes
# ----------------------------------------------------------------
@app.route("/admin")
def admin():
if 'logged_in' not in session:
return redirect(url_for('admin_login'))
if "logged_in" not in session:
return redirect(url_for("admin_login"))
db_session = DBSession()
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all()
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():
session.pop('logged_in', None)
flash('You have been logged out')
return redirect(url_for('admin_login'))
session.pop("logged_in", None)
flash("You have been logged out")
return redirect(url_for("admin_login"))
@app.route('/static/thumbnails/<path:filename>')
def serve_thumbnail(filename):
return send_from_directory(THUMBNAIL_FOLDER, filename)
@app.route('/admin/update_photo/<int:photo_id>', methods=['POST'])
@app.route("/admin/update_photo/<int:photo_id>", methods=["POST"])
def update_photo(photo_id):
if 'logged_in' not in session:
return jsonify({'success': False, 'error': 'Not logged in'}), 401
if "logged_in" not in session:
return jsonify({"success": False, "error": "Not logged in"}), 401
data = request.json
db_session = DBSession()
@ -233,35 +335,35 @@ def update_photo(photo_id):
if not photo:
db_session.close()
return jsonify({'success': False, 'error': 'Photo not found'}), 404
return jsonify({"success": False, "error": "Photo not found"}), 404
try:
for field, value in data.items():
if field == 'date_taken':
value = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
elif field == 'iso':
if field == "date_taken":
value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
elif field == "iso":
value = int(value)
setattr(photo, field, value)
db_session.commit()
db_session.close()
return jsonify({'success': True})
return jsonify({"success": True})
except Exception as e:
db_session.rollback()
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):
if 'logged_in' not in session:
return jsonify({'success': False, 'error': 'Not logged in'}), 401
if "logged_in" not in session:
return jsonify({"success": False, "error": "Not logged in"}), 401
db_session = DBSession()
photo = db_session.query(Photo).get(photo_id)
if not photo:
db_session.close()
return jsonify({'success': False, 'error': 'Photo not found'}), 404
return jsonify({"success": False, "error": "Photo not found"}), 404
try:
# Delete the original file
@ -270,7 +372,9 @@ def delete_photo(photo_id):
os.remove(original_path)
# 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):
for thumb_file in os.listdir(thumb_dir):
os.remove(os.path.join(thumb_dir, thumb_file))
@ -281,115 +385,100 @@ def delete_photo(photo_id):
db_session.commit()
db_session.close()
return jsonify({'success': True})
return jsonify({"success": True})
except Exception as e:
db_session.rollback()
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):
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):
return jsonify({'verified': False, 'error': 'Image not found'})
return jsonify({"verified": False, "error": "Image not found"})
try:
extracted_key = extract_message(file_path, 16)
db_session = DBSession()
photo = db_session.query(Photo).filter_by(input_filename=filename).first()
db_session.close()
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:
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:
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
return jsonify({"verified": False, "error": str(e)})
# Add rate limiting to sensitive endpoints
@app.route('/admin/login', methods=['POST'])
@app.route("/admin/login", methods=["POST", "GET"])
@limiter.limit("5 per minute")
def admin_login():
if request.method == 'POST':
if request.form['password'] == config['admin']['password']:
session['logged_in'] = True
return redirect(url_for('admin'))
if request.method == "POST":
if request.form["password"] == config["admin"]["password"]:
session["logged_in"] = True
return redirect(url_for("admin"))
else:
flash('Invalid password')
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
flash("Invalid password")
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")
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 "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_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']
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]
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'))
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']
exposure_time = exif["ExposureTime"]
if isinstance(exposure_time, tuple):
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
else:
@ -400,33 +489,99 @@ def admin_upload():
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', '')),
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)),
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
highlight_color=get_highlight_color(
THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"
),
unique_key=unique_key,
)
db_session.add(new_photo)
db_session.commit()
db_session.close()
flash('File uploaded successfully')
return redirect(url_for('admin'))
flash('Invalid file type')
return redirect(url_for('admin'))
flash("File uploaded successfully")
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)
# Initialize database tables
init_db()
if __name__ == '__main__':
app.run(
debug=True,
port=config['server']['port'],
host=config['server']['host']
)
if __name__ == "__main__":
app.run(debug=False, port=config["server"]["port"], host=config["server"]["host"])

View File

@ -12,6 +12,7 @@ password = "changeme"
[appearance]
accent_color = "#007bff"
site_title = "Spectra"
[security]
# Add these security settings

View File

@ -1,32 +1,24 @@
import toml
import os
import secrets
CONFIG_FILE = 'config.toml'
import toml
CONFIG_FILE = "config.toml"
def load_or_create_config():
if not os.path.exists(CONFIG_FILE):
admin_password = secrets.token_urlsafe(16)
config = {
'admin': {
'password': admin_password
},
'directories': {
'upload': 'uploads',
'thumbnail': 'thumbnails'
},
'appearance': {
'accent_color': '#ff6600'
},
'server': {
'host': '0.0.0.0',
'port': 5002
}
"admin": {"password": admin_password},
"directories": {"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)
print(f"Generated new config file with admin password: {admin_password}")
else:
with open(CONFIG_FILE, 'r') as f:
with open(CONFIG_FILE, "r") as f:
config = toml.load(f)
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.orm import sessionmaker
Base = declarative_base()
class Photo(Base):
__tablename__ = 'photos'
__tablename__ = "photos"
id = Column(Integer, primary_key=True, autoincrement=True)
input_filename = Column(String, nullable=False)
@ -19,16 +23,50 @@ class Photo(Base):
height = Column(Integer)
highlight_color = Column(String)
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():
session = None
try:
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)

View File

@ -1,8 +1,10 @@
from PIL import Image
import numpy as np
from PIL import Image
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):
# Open the image
@ -15,7 +17,7 @@ def embed_message(image_path, message, exifraw):
# Convert message to binary
binary_message = string_to_binary(message)
# Check if the message can fit in the image
if len(binary_message) > len(flat_array):
raise ValueError("Message is too long to be embedded in this image")
@ -28,11 +30,12 @@ def embed_message(image_path, message, exifraw):
stego_array = flat_array.reshape(img_array.shape)
# 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
stego_img.save(image_path, exif=exifraw)
def extract_message(image_path, message_length):
# Open the image
img = Image.open(image_path)
@ -43,9 +46,16 @@ def extract_message(image_path, message_length):
flat_array = img_array.flatten()
# 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
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

View File

@ -115,6 +115,42 @@
.delete-btn:hover {
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>
</head>
<body>
@ -173,8 +209,55 @@
{% endfor %}
</tbody>
</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>
<script>
<script nonce="{{ g.csp_nonce }}">
function makeEditable(element) {
const value = element.textContent;
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>
</body>
</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>
<meta charset="UTF-8">
<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.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
@ -172,31 +172,119 @@
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>
</head>
<body>
<div class="sidebar">
<div class="sidebar-nav noto-sans-mono-font">
<div class="nav-toggle"></div>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><hr></li>
<li>Powered by <a href="https://dws.rip">DWS</a></li>
</ul>
</nav>
<header>
<div class="sidebar">
<div class="sidebar-nav noto-sans-mono-font">
<div class="nav-toggle"></div>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="#" id="aboutLink">About</a></li>
<li><hr></li>
<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 class="sidebar-title noto-sans-mono-font">
<h1>Tanishq Dubey Photography</h1>
</div>
</div>
</header>
<div class="main-content">
<div class="grid-container" id="polaroid-grid"></div>
<div id="loading">Loading more images...</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 loadingIndicator = document.getElementById('loading');
const baseSize = 110; // Size of one grid cell in pixels
@ -345,6 +433,32 @@
}
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>
</body>
</html>