28 Commits

Author SHA1 Message Date
0436d6e24a update app.py to provide nonce
All checks were successful
Docker Build and Publish / build (release) Successful in 6s
2025-01-31 18:41:44 -05:00
3e11c63e33 Fix admin page nonce
All checks were successful
Docker Build and Publish / build (release) Successful in 33s
2025-01-31 18:32:33 -05:00
5b0b30d69c Fix CSP Error
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
Docker Build and Publish / build (release) Successful in 7s
Getting rid of inline onclick calls and registering the handler in the
primary script ensure securty (XSS).
2024-12-08 18:03:44 -05:00
9022facac5 EXIF Error handling 2024-11-14 18:56:46 -05:00
07725c99b4 Add release process
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
Docker Build and Publish / build (release) Successful in 7s
2024-11-05 23:49:09 -05:00
05a184fcf7 Sizing Fix
All checks were successful
Docker Build and Publish / build (push) Successful in 7s
2024-11-05 22:11:45 -05:00
13e61b7bef remove duplicate secret key handling
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
2024-11-05 19:40:29 -05:00
4c993ebacd Secret Key
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
2024-11-05 19:36:44 -05:00
9c1e6f0e94 More frontend changes, ready for deploy
All checks were successful
Docker Build and Publish / build (push) Successful in 7s
2024-11-05 19:25:52 -05:00
9abdd18f33 Checkpoint, configuration can now be done through DB and the site. Server settings are still on the file system 2024-11-05 19:03:04 -05:00
b46ec98115 Reverse Proxy Changes 2024-11-05 17:16:32 -05:00
905e3c3977 Fix startup and init
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
2024-11-05 15:03:32 -05:00
6f2ecd9775 fix requirements
All checks were successful
Docker Build and Publish / build (push) Successful in 21s
2024-11-05 14:54:20 -05:00
dcd66c3c76 Merge pull request 'Rewrite' (#1) from gridlayout into main
All checks were successful
Docker Build and Publish / build (push) Successful in 12s
Reviewed-on: #1
2024-11-05 14:47:42 -05:00
6137d994e3 update secret
All checks were successful
Docker Build and Publish / build (pull_request) Successful in 22s
2024-11-05 14:46:10 -05:00
ae804aaa42 ready for prod
Some checks failed
Docker Build and Publish / build (pull_request) Failing after 50s
2024-11-05 14:02:21 -05:00
6335751f0f sticky sidebar 2024-11-05 14:02:03 -05:00
3dbdd354b5 Try height 2024-11-05 14:02:03 -05:00
906e52665a hamburgesa menu for small devices 2024-11-05 14:02:03 -05:00
6dcee44ee9 Optmial Image Loading 2024-11-05 14:02:03 -05:00
92cd2ec06d Image security (needs to be improved) 2024-11-05 14:02:03 -05:00
f435aedf2b More admin change 2024-11-05 14:02:03 -05:00
fee6f755a3 More styling 2024-11-05 14:02:00 -05:00
f8042571b2 gitignore 2024-11-05 14:00:46 -05:00
8dfe806c0e Admin endpoints work 2024-11-05 13:58:59 -05:00
cd75219985 loads and looks ok 2024-11-05 13:55:44 -05:00
b0176dc3a0 Some sidebar fixes 2024-11-05 13:55:44 -05:00
e539dc5bbb checkpt 2024-11-05 13:55:44 -05:00
20 changed files with 2109 additions and 147 deletions

View File

@ -0,0 +1,44 @@
name: Docker Build and Publish
on:
push:
branches: [ main ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ main ]
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: git.dws.rip/${{ github.repository }}
tags: |
type=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
- name: Login to Gitea Container Registry
uses: docker/login-action@v2
with:
registry: git.dws.rip
username: ${{ github.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ lib64
uploads/
thumbnails/
images/
static/
config.toml
__pycache__/

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.12-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -m -r -s /bin/bash appuser
# Create necessary directories
WORKDIR /app
RUN mkdir -p /app/uploads /app/thumbnails
RUN chown -R appuser:appuser /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# Copy application files
COPY --chown=appuser:appuser templates /app/templates
COPY --chown=appuser:appuser app.py config.py models.py steganography.py ./
# Switch to non-root user
USER appuser
# Use gunicorn for production
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "app:app"]

63
Makefile Normal file
View 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"

125
README.md Normal file
View File

@ -0,0 +1,125 @@
# Spectra
> A variation on the masonry grid image gallery with the row alighment constraint removed. Oh, it also has an admin interface so you can set it up and forget it.
## Features
- **Color Analysis**: Automatically extracts color palettes from images to create cohesive galleries
- **Smart Thumbnails**: Generates and caches responsive thumbnails in multiple sizes
- **EXIF Preservation**: Maintains all photo metadata through processing
- **Ownership Verification**: Embeds steganographic proofs in images
- **Live Configuration**: Hot-reload config changes without restarts
- **Production Ready**: Fully Dockerized with Traefik integration
## Quick Start
### Local Development
Clone the repository
```bash
git clone https://git.dws.rip/your-username/spectra
cd spectra
```
Set up Python virtual environment
```bash
python -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
```
Install dependencies
```bash
pip install -r requirements.txt
```
Create config from template
```bash
cp config.example.toml config.toml
```
Run development server
```bash
python app.py
```
### Production Deployment
Create required network
```bash
docker network create traefik-public
```
Configure your domain
```bash
sed -i 's/photos.dws.rip/your.domain.here/g' docker-compose.yml
```
Launch
```bash
docker-compose up -d
```
## Configuration
### Essential Settings
```toml
[server]
host = "0.0.0.0"
port = 5000
[security]
max_upload_size_mb = 80
rate_limit = 100 # requests per minute
[admin]
password = "change-this-password" # Required
```
See `config.example.toml` for all available options.
## Directory Structure
```
spectra/
├── app.py # Application entry point
├── config.py # Configuration management
├── models.py # Database models
├── steganography.py # Image verification
├── templates/ # Jinja2 templates
├── uploads/ # Original images
└── thumbnails/ # Generated thumbnails
```
## API Reference
### Endpoints
#### Public Endpoints
- `GET /` - Main gallery view
- `GET /api/images` - Get paginated image list
- `GET /verify/<filename>` - Verify image authenticity
#### Admin Endpoints
- `POST /admin/login` - Admin authentication
- `POST /admin/upload` - Upload new images
- `POST /admin/update_photo/<id>` - Update image metadata
- `POST /admin/delete_photo/<id>` - Delete image
## Environment Variables
- `FLASK_ENV`: Set to 'production' in production
- `WORKERS`: Number of Gunicorn workers (default: 4)
- `PORT`: Override default port (default: 5000)
## 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`

608
app.py Normal file
View File

@ -0,0 +1,608 @@
import atexit
import colorsys
import hashlib
import os
import random
import secrets
import threading
import time
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.util import get_remote_address
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
# Add this function to handle secret key persistence
def get_or_create_secret_key():
"""Get existing secret key or create a new one"""
secret_key_file = Path("secret.key")
try:
if secret_key_file.exists():
logger.info("Loading existing secret key")
return secret_key_file.read_bytes()
else:
logger.info("Generating new secret key")
secret_key = os.urandom(32) # Use 32 bytes for better security
secret_key_file.write_bytes(secret_key)
return secret_key
except Exception as e:
logger.error(f"Error handling secret key: {e}")
# Fallback to a memory-only key if file operations fail
return os.urandom(32)
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
}
# Configure logging
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__)
# Create Flask app with persistent secret key
app = Flask(__name__)
app.secret_key = get_or_create_secret_key()
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_highlight_color(image_path):
color_thief = ColorThief(image_path)
palette = color_thief.get_palette(color_count=6, quality=1)
# Convert RGB to HSV and find the color with the highest saturation
highlight_color = max(palette, key=lambda rgb: colorsys.rgb_to_hsv(*rgb)[1])
return '#{:02x}{:02x}{:02x}'.format(*highlight_color)
def generate_thumbnails(filename):
original_path = os.path.join(UPLOAD_FOLDER, filename)
thumb_dir = os.path.join(THUMBNAIL_FOLDER, os.path.splitext(filename)[0])
os.makedirs(thumb_dir, exist_ok=True)
for size in THUMBNAIL_SIZES:
thumb_path = os.path.join(thumb_dir, f"{size}_{filename}")
if not os.path.exists(thumb_path):
with Image.open(original_path) as img:
# Extract EXIF data
exif_data = None
if "exif" in img.info:
exif_data = img.info["exif"]
# Resize image
img.thumbnail((size, size), Image.LANCZOS)
# Save image with EXIF data
if exif_data:
img.save(thumb_path, exif=exif_data, optimize=True, quality=85)
else:
img.save(thumb_path, optimize=True, quality=85)
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}")
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
# 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():
return render_template(
"index.html",
accent_color=config["appearance"]["accent_color"],
site_title=config["appearance"]["site_title"],
about=config["about"],
nonce=g.csp_nonce,
)
# ----------------------------------------------------------------
# API routes
# ----------------------------------------------------------------
@app.route("/api/images")
def get_images():
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()
)
images = []
for photo in photos:
factor = random.randint(2, 3)
if photo.height < 4000 and photo.width < 4000:
factor = 1
if photo.orientation == 6 or photo.orientation == 8:
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,
}
)
has_more = db_session.query(Photo).count() > page * per_page
db_session.close()
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"))
db_session = DBSession()
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all()
db_session.close()
# Pass the full config to the template
return render_template(
"admin.html",
photos=photos,
accent_color=config["appearance"]["accent_color"],
config=config,
nonce=g.csp_nonce,
)
@app.route("/admin/logout")
def admin_logout():
session.pop("logged_in", None)
flash("You have been logged out")
return redirect(url_for("admin_login"))
@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
data = request.json
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
try:
for field, value in data.items():
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})
except Exception as e:
db_session.rollback()
db_session.close()
return jsonify({"success": False, "error": str(e)}), 500
@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
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
try:
# Delete the original file
original_path = os.path.join(UPLOAD_FOLDER, photo.input_filename)
if os.path.exists(original_path):
os.remove(original_path)
# Delete the thumbnail directory
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))
os.rmdir(thumb_dir)
# Delete the database entry
db_session.delete(photo)
db_session.commit()
db_session.close()
return jsonify({"success": True})
except Exception as e:
db_session.rollback()
db_session.close()
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/verify/<filename>", methods=["GET"])
def verify_image(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"})
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"})
else:
return jsonify(
{"verified": False, "message": "Image ownership could not be verified"}
)
except Exception as e:
return jsonify({"verified": False, "error": str(e)})
# Add rate limiting to sensitive endpoints
@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"))
else:
flash("Invalid password")
return render_template(
"admin_login.html", accent_color=config["appearance"]["accent_color"]
)
@app.route("/admin/upload", methods=["POST"])
@limiter.limit("10 per minute")
def admin_upload():
if "logged_in" not in session:
return redirect(url_for("admin_login"))
if "file" not in request.files:
flash("No file part")
return redirect(url_for("admin"))
file = request.files["file"]
if file.filename == "":
flash("No selected file")
return redirect(url_for("admin"))
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
file.save(file_path)
# Extract EXIF data with error handling
exif = {}
exifraw = None
width = height = 0
try:
with Image.open(file_path) as img:
width, height = img.size
if hasattr(img, '_getexif') and img._getexif() is not None:
exifraw = img.info.get("exif")
exif = {
ExifTags.TAGS[k]: v
for k, v in img._getexif().items()
if k in ExifTags.TAGS
}
except Exception as e:
logger.warning(f"Error reading EXIF data for {filename}: {str(e)}")
# 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)
# Handle exposure time with error handling
try:
exposure_time = exif.get("ExposureTime", 0)
if isinstance(exposure_time, tuple):
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
else:
exposure_fraction = f"1/{int(1/float(exposure_time))}" if exposure_time else "0"
except (TypeError, ZeroDivisionError):
exposure_fraction = "0"
# Create database entry with safe defaults
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", "0"))
),
aperture=str(exif.get("FNumber", "0")),
shutter_speed=exposure_fraction,
date_taken=datetime.strptime(
str(exif.get("DateTime", "1970:01:01 00:00:00")), "%Y:%m:%d %H:%M:%S"
),
iso=int(exif.get("ISOSpeedRatings", 0)),
orientation=int(exif.get("Orientation", 1)),
width=width,
height=height,
highlight_color=get_highlight_color(
THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"
),
unique_key=unique_key,
)
db_session.add(new_photo)
db_session.commit()
db_session.close()
flash("File uploaded successfully")
return redirect(url_for("admin"))
flash("Invalid file type")
return redirect(url_for("admin"))
# 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=False, port=config["server"]["port"], host=config["server"]["host"])

21
config.example.toml Normal file
View File

@ -0,0 +1,21 @@
[server]
host = "0.0.0.0"
port = 5000
[directories]
upload = "/app/uploads"
thumbnail = "/app/thumbnails"
[admin]
# Change this password!
password = "changeme"
[appearance]
accent_color = "#007bff"
site_title = "Spectra"
[security]
# Add these security settings
max_upload_size_mb = 80
allowed_extensions = ["jpg", "jpeg", "png", "gif"]
rate_limit = 100 # requests per minute

24
config.py Normal file
View File

@ -0,0 +1,24 @@
import os
import secrets
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},
}
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:
config = toml.load(f)
return config

22
docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
version: '3.8'
services:
web:
image: git.dws.rip/dws/spectra:main
volumes:
- ./uploads:/app/uploads
- ./thumbnails:/app/thumbnails
- ./config.toml:/app/config.toml
- ./photos.db:/app/photos.db
- ./static:/app/static
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
- WORKERS=4
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View File

@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tanishq Dubey Photography</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">
<link href="styles/styles.css" rel="stylesheet" />
</head>
<body>
<div class="sidebar">
<h1>Tanishq Dubey Photography</h1>
</div>
<div class="photocollage">
<div class="photo">
<div class="photocontent">
<div class="photoimage">
<img class="photoimagesrc" src="images/1.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
<div class="photo">
<div class="photocontent">
<div class = "photoimage">
<img class="photoimagesrc" src="images/2.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
<div class="photo">
<div class="photocontent">
<div class = "photoimage">
<img class="photoimagesrc" src="images/3.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
<div class="photo">
<div class="photocontent">
<div class = "photoimage">
<img class="photoimagesrc" src="images/4.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
</div>
<script src="scripts/script.js"></script>
</body>
</html>

72
models.py Normal file
View File

@ -0,0 +1,72 @@
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"
id = Column(Integer, primary_key=True, autoincrement=True)
input_filename = Column(String, nullable=False)
thumbnail_filename = Column(String, nullable=False)
focal_length = Column(String)
aperture = Column(String)
shutter_speed = Column(String)
date_taken = Column(DateTime)
iso = Column(Integer)
width = Column(Integer)
height = Column(Integer)
highlight_color = Column(String)
orientation = Column(Integer)
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")
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)

5
pyvenv.cfg Normal file
View File

@ -0,0 +1,5 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.7
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/dubey/projects/photoportfolio/pythonserver

34
requirements.txt Normal file
View File

@ -0,0 +1,34 @@
APScheduler==3.10.4
blinker==1.8.2
click==8.1.7
colorthief==0.2.1
Deprecated==1.2.14
exif==1.6.0
Flask==3.0.3
Flask-Limiter==3.8.0
greenlet==3.1.1
gunicorn==23.0.0
importlib_resources==6.4.5
itsdangerous==2.2.0
Jinja2==3.1.4
limits==3.13.0
markdown-it-py==3.0.0
MarkupSafe==3.0.1
mdurl==0.1.2
numpy==2.1.2
ordered-set==4.1.0
packaging==24.1
piexif==1.1.3
pillow==11.0.0
plum-py==0.8.7
Pygments==2.18.0
pytz==2024.2
rich==13.9.4
six==1.16.0
SQLAlchemy==2.0.36
toml==0.10.2
typing_extensions==4.12.2
tzlocal==5.2
watchdog==6.0.0
Werkzeug==3.0.4
wrapt==1.16.0

View File

61
steganography.py Normal file
View File

@ -0,0 +1,61 @@
import numpy as np
from PIL import Image
def string_to_binary(message):
return "".join(format(ord(char), "08b") for char in message)
def embed_message(image_path, message, exifraw):
# Open the image
img = Image.open(image_path)
# Convert image to numpy array
img_array = np.array(img)
# Flatten the array
flat_array = img_array.flatten()
# 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")
# Embed the message
for i, bit in enumerate(binary_message):
flat_array[i] = (flat_array[i] & 0xFE) | int(bit)
# Reshape the array back to the original image shape
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)
# 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)
# Convert image to numpy array
img_array = np.array(img)
# Flatten the array
flat_array = img_array.flatten()
# Extract the binary message
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)
]
)
return message

View File

@ -1,78 +0,0 @@
html {
margin: 0;
height: 100vh;
}
body {
min-height: 100vh;
margin: 0;
}
.photocollage {
display: flex;
justify-content: flex-start;
align-items: flex-start;
/* flex-flow: row wrap; */
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
height: 100%;
padding: 15px;
gap: 5px;
}
.noto-sans-mono-font {
font-family: "Noto Sans Mono", monospace;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings:
"wdth" 100;
}
.photo {
background-color: #eeeeee;
filter: drop-shadow(0px 3px 3px #888888);
display: inline-block;
padding: 1vh;
margin: 1vh;
}
.photocontent {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: normal;
align-content: normal;
}
.photoimage {
display: block;
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
align-self: center;
order: 0;
}
.photoimagesrc {
max-width:40vw;
max-height:40vw;
width: auto;
height: auto;
}
.photodetails {
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: flex-end;
order: 0;
text-align: right;
line-height: 70%;
font-size: 0.75rem;
}

401
templates/admin.html Normal file
View File

@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Interface - Tanishq Dubey Photography</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">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<style>
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans Mono', monospace;
background-color: #f0f0f0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: {{ accent_color }};
margin-bottom: 1.5rem;
}
.upload-form {
background-color: #ffffff;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
input[type="file"] {
margin-right: 1rem;
}
input[type="submit"] {
background-color: {{ accent_color }};
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
}
input[type="submit"]:hover {
background-color: {{ accent_color }}e0; /* Slightly darker version of the accent color */
}
table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background-color: #f5f5f5;
font-weight: bold;
color: #333;
}
tr:hover {
background-color: #f9f9f9;
}
.thumbnail {
max-width: 100px;
max-height: 100px;
object-fit: cover;
}
.flash-messages {
margin-bottom: 1rem;
}
.flash-messages p {
background-color: #4CAF50;
color: white;
padding: 0.5rem;
border-radius: 4px;
}
.logout {
text-align: right;
margin-bottom: 1rem;
}
.logout a {
color: {{ accent_color }};
text-decoration: none;
}
.logout a:hover {
text-decoration: underline;
}
.editable {
cursor: pointer;
}
.editable:hover {
background-color: #f0f0f0;
}
.editing {
background-color: #fff;
border: 1px solid #ccc;
padding: 2px;
}
.delete-btn {
background-color: #ff4136;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
margin-left: 0.5rem;
}
.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>
<div class="container">
<div class="logout">
<a href="{{ url_for('admin_logout') }}">Logout</a>
</div>
<h1>Admin Interface</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="upload-form">
<h2>Upload New Image</h2>
<form action="{{ url_for('admin_upload') }}" method="POST" enctype="multipart/form-data">
<input type="file" name="file" accept=".jpg,.jpeg,.png,.gif" required>
<input type="submit" value="Upload">
</form>
</div>
<h2>Uploaded Images</h2>
<table>
<thead>
<tr>
<th>Thumbnail</th>
<th>Filename</th>
<th>Date Taken</th>
<th>Focal Length</th>
<th>Aperture</th>
<th>Shutter Speed</th>
<th>ISO</th>
<th>Dimensions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for photo in photos %}
<tr data-id="{{ photo.id }}">
<td><img src="{{ url_for('serve_thumbnail', filename=photo.thumbnail_filename) }}" alt="Thumbnail" class="thumbnail"></td>
<td class="editable" data-field="input_filename">{{ photo.input_filename }}</td>
<td class="editable" data-field="date_taken">{{ photo.date_taken.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="editable" data-field="focal_length">{{ photo.focal_length }}</td>
<td class="editable" data-field="aperture">{{ photo.aperture }}</td>
<td class="editable" data-field="shutter_speed">{{ photo.shutter_speed }}</td>
<td class="editable" data-field="iso">{{ photo.iso }}</td>
<td>{{ photo.width }}x{{ photo.height }}</td>
<td>
<button id="save-btn">Save</button>
<button class="delete-btn" id="delete-btn">Delete</button>
</td>
</tr>
{% 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:</label>
<div style="display: flex; align-items: center; gap: 1rem;">
<img id="profile-preview" src="/static/profile.jpeg" alt="Profile" style="width: 100px; height: 100px; object-fit: cover; border-radius: 50%;">
<input type="file" id="profile_image_upload" accept="image/jpeg,image/png" style="flex: 1;">
</div>
</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="{{ nonce }}">
function makeEditable(element) {
const value = element.textContent;
const input = document.createElement('input');
input.value = value;
input.classList.add('editing');
element.textContent = '';
element.appendChild(input);
input.focus();
input.addEventListener('blur', function() {
element.textContent = this.value;
element.classList.remove('editing');
});
}
document.querySelectorAll('.editable').forEach(el => {
el.addEventListener('click', function() {
if (!this.classList.contains('editing')) {
makeEditable(this);
}
});
});
function saveChanges(button) {
const row = button.closest('tr');
const photoId = row.dataset.id;
const updatedData = {};
row.querySelectorAll('.editable').forEach(el => {
updatedData[el.dataset.field] = el.textContent;
});
fetch('/admin/update_photo/' + photoId, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedData),
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Changes saved successfully!');
} else {
alert('Error saving changes: ' + data.error);
}
})
.catch((error) => {
console.error('Error:', error);
alert('An error occurred while saving changes.');
});
}
function deletePhoto(button) {
if (confirm('Are you sure you want to delete this photo?')) {
const row = button.closest('tr');
const photoId = row.dataset.id;
fetch('/admin/delete_photo/' + photoId, {
method: 'POST',
})
.then(response => response.json())
.then(data => {
if (data.success) {
row.remove();
alert('Photo deleted successfully!');
} else {
alert('Error deleting photo: ' + data.error);
}
})
.catch((error) => {
console.error('Error:', error);
alert('An error occurred while deleting the photo.');
});
}
}
document.getElementById('delete-btn').addEventListener('click', deletePhoto);
document.getElementById('save-btn').addEventListener('click', saveChanges);
document.getElementById('profile_image_upload').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('profile_image', file);
try {
const response = await fetch('/admin/upload_profile', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
document.getElementById('profile-preview').src = '/static/profile.jpeg?' + new Date().getTime();
} else {
alert('Error uploading profile image: ' + result.error);
}
} catch (error) {
alert('Error uploading profile image: ' + error);
}
});
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {};
const inputs = e.target.querySelectorAll('input:not([type="file"]), 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,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Tanishq Dubey Photography</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">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<style>
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans Mono', monospace;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
background-color: #ffffff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
color: {{ accent_color }};
margin-bottom: 1.5rem;
}
input[type="password"] {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
}
input[type="submit"] {
background-color: {{ accent_color }};
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
}
input[type="submit"]:hover {
background-color: #e55c00;
}
.flash-messages {
margin-bottom: 1rem;
color: #ff0000;
}
</style>
</head>
<body>
<div class="login-container">
<h1>Admin Login</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST">
<input type="password" name="password" required placeholder="Enter password">
<input type="submit" value="Login">
</form>
</div>
</body>
</html>

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

465
templates/index.html Normal file
View File

@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f0f0f0;
display: flex;
flex-direction: row;
min-height: 100vh;
}
.main-content {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10px, 1fr));
grid-auto-rows: 10px;
grid-auto-flow: dense;
gap: 15px;
max-width: 100%;
box-sizing: border-box;
}
.polaroid {
position: relative;
background-color: #e8e8ea;
box-shadow: 0 0 2px 0 #f4f4f6 inset,
1px 5px 5px 0px #99999955;
border-radius: 2px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
transition: background-color 0.3s ease;
max-width: 100%;
box-sizing: border-box;
}
.polaroid img {
max-width: 100%;
height: auto;
display: block;
flex-grow: 1;
object-fit: contain;
}
.date-overlay {
position: absolute;
bottom: 4rem;
font-size: 1rem;
right: 1rem;
text-align: right;
color: #ec5a11;
font-family: "Noto Sans Mono", monospace;
font-size: 0.7rem;
opacity: 0;
text-shadow: 0 0 2px #ec5a11;
transition: opacity 0.3s ease;
}
.polaroid:hover .date-overlay {
opacity: 0.8;
}
.polaroid .caption {
margin-top: 10px;
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: flex-end;
order: 0;
text-align: right;
line-height: 70%;
font-size: 0.75rem;
}
.noto-sans-mono-font {
font-family: "Noto Sans Mono", monospace;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings:
"wdth" 100;
}
.sidebar {
width: 20rem;
background-color: #f0f0f0;
padding: 20px;
box-sizing: border-box;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.sidebar-title {
font-size: 1rem;
text-align: right;
}
.sidebar-nav {
font-size: 1rem;
flex-grow: 1;
justify-content: center;
text-align: right;
}
.sidebar-nav ul {
list-style-type: none;
padding: 0;
}
.sidebar-nav a {
text-decoration: none;
color: {{ accent_color }};
}
.nav-toggle {
display: none;
cursor: pointer;
font-size: 1.5rem;
text-align: right;
}
@media (max-width: 768px) {
.main-content {
padding: 10px;
}
.grid-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.polaroid {
width: 100%;
max-width: 100vw;
height: auto;
}
}
@media (max-width: 1024px) {
body {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
position: static;
padding: 10px;
overflow-y: visible;
}
.sidebar-nav ul {
display: none;
}
.nav-toggle {
display: block;
}
.sidebar-nav.active ul {
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>
<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>
</header>
<div class="main-content">
<div class="grid-container" id="polaroid-grid"></div>
<div id="loading">Loading more images...</div>
</div>
<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
let page = 1;
let isLoading = false;
let hasMore = true;
function createPolaroid(polaroid) {
const polaroidElement = document.createElement('div');
polaroidElement.className = 'polaroid';
if (polaroid.height > polaroid.width) {
polaroidElement.classList.add('vertical');
}
polaroidElement.style.backgroundColor = `${polaroid.highlightColor}33`;
const img = document.createElement('img');
img.alt = polaroid.caption;
img.setAttribute('data-original-width', polaroid.width);
img.setAttribute('data-original-height', polaroid.height);
img.setAttribute('data-base-src', polaroid.imgSrc);
img.src = polaroid.imgSrc;
//img.onload = () => loadOptimalThumbnail(img); // Load optimal thumbnail after initial load
const dateOverlay = document.createElement('div');
dateOverlay.className = 'date-overlay';
dateOverlay.textContent = polaroid.date;
const caption = document.createElement('div');
caption.className = 'caption noto-sans-mono-font';
caption.textContent = polaroid.technicalInfo;
polaroidElement.appendChild(img);
polaroidElement.appendChild(dateOverlay);
polaroidElement.appendChild(caption);
return polaroidElement;
}
function calculateGridSpan(dimension) {
return Math.ceil(dimension / baseSize);
}
function positionPolaroid(polaroidElement, polaroid) {
const width = calculateGridSpan(polaroid.width + 20); // Add 20px for padding
const height = calculateGridSpan(polaroid.height + 40) + 1; // Add 40px for padding and caption
polaroidElement.style.gridColumnEnd = `span ${width}`;
polaroidElement.style.gridRowEnd = `span ${height}`;
}
function getOptimalThumbnailSize(width, height) {
const sizes = [256, 512, 768, 1024, 1536, 2048];
const maxDimension = Math.max(width, height);
return sizes.find(size => size >= maxDimension) || sizes[sizes.length - 1];
}
function loadOptimalThumbnail(img) {
// Use a small delay to ensure the image has rendered
setTimeout(() => {
const containerWidth = img.offsetWidth;
const containerHeight = img.offsetHeight;
const devicePixelRatio = window.devicePixelRatio || 1;
// If dimensions are still 0, use the original image dimensions
const width = containerWidth || parseInt(img.getAttribute('data-original-width'));
const height = containerHeight || parseInt(img.getAttribute('data-original-height'));
const optimalSize = getOptimalThumbnailSize(
width * devicePixelRatio,
height * devicePixelRatio
);
const newSrc = img.getAttribute('data-base-src').replace('1536_', `${optimalSize}_`);
if (newSrc !== img.src) {
const tempImg = new Image();
tempImg.onload = function() {
img.src = newSrc;
img.style.opacity = 1;
};
tempImg.src = newSrc;
img.style.opacity = 0.5;
}
}, 100); // 100ms delay
}
function handleResize() {
const polaroids = document.querySelectorAll('.polaroid');
polaroids.forEach(polaroid => {
const img = polaroid.querySelector('img');
const width = parseInt(img.getAttribute('data-original-width'));
const height = parseInt(img.getAttribute('data-original-height'));
positionPolaroid(polaroid, { width, height });
//loadOptimalThumbnail(img);
});
}
async function loadImages() {
if (isLoading || !hasMore) return;
isLoading = true;
loadingIndicator.style.display = 'block';
try {
const response = await fetch(`/api/images?page=${page}`);
const data = await response.json();
data.images.forEach(polaroid => {
const polaroidElement = createPolaroid(polaroid);
positionPolaroid(polaroidElement, polaroid);
gridContainer.appendChild(polaroidElement);
// loadOptimalThumbnail is now called in the img.onload event
});
hasMore = data.hasMore;
page++;
} catch (error) {
console.error('Error loading images:', error);
} finally {
isLoading = false;
loadingIndicator.style.display = 'none';
}
}
function handleScroll() {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
loadImages();
}
}
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
loadImages(); // Initial load
function setupNavToggle() {
const navToggle = document.querySelector('.nav-toggle');
const sidebarNav = document.querySelector('.sidebar-nav');
navToggle.addEventListener('click', () => {
sidebarNav.classList.toggle('active');
});
}
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>