11 Commits

Author SHA1 Message Date
2ea5131739 Fix multiple buttons
All checks were successful
Docker Build and Publish / build (release) Successful in 6s
2025-02-01 11:23:58 -05:00
c10ac8669c Deletes work, tested on DWS
All checks were successful
Docker Build and Publish / build (release) Successful in 6s
2025-01-31 18:54:04 -05:00
682cdfa95c update listener model 2025-01-31 18:49:44 -05:00
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
4 changed files with 118 additions and 48 deletions

View File

@ -3,9 +3,11 @@ name: Docker Build and Publish
on: on:
push: push:
branches: [ main ] branches: [ main ]
tags: [ 'v*' ] tags: [ 'v*.*.*' ]
pull_request: pull_request:
branches: [ main ] branches: [ main ]
release:
types: [published]
jobs: jobs:
build: build:
@ -20,11 +22,11 @@ jobs:
with: with:
images: git.dws.rip/${{ github.repository }} images: git.dws.rip/${{ github.repository }}
tags: | tags: |
type=ref,event=branch type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha,format=long
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v2

View File

@ -114,4 +114,12 @@ spectra/
- `FLASK_ENV`: Set to 'production' in production - `FLASK_ENV`: Set to 'production' in production
- `WORKERS`: Number of Gunicorn workers (default: 4) - `WORKERS`: Number of Gunicorn workers (default: 4)
- `PORT`: Override default port (default: 5000) - `PORT`: Override default port (default: 5000)
## Release Process
To create a release:
- Create and push a tag: `git tag v1.0.0 && git push origin v1.0.0`
- Create a release in Gitea UI using that tag
- The workflow will build and push the Docker image with appropriate version tags
- The Docker image will be available at: `git.dws.rip/your-repo/image:v1.0.0`

79
app.py
View File

@ -11,7 +11,6 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from logging import getLogger from logging import getLogger
import logging import logging
from logging import getLogger
from logging.config import dictConfig from logging.config import dictConfig
import toml import toml
@ -31,13 +30,31 @@ from models import Session as DBSession
from models import SiteConfig, init_db from models import SiteConfig, init_db
from steganography import embed_message, extract_message 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 = { DEFAULT_CONFIG = {
"server": {"host": "0.0.0.0", "port": 5000}, "server": {"host": "0.0.0.0", "port": 5000},
"directories": {"upload": "uploads", "thumbnail": "thumbnails"}, "directories": {"upload": "uploads", "thumbnail": "thumbnails"},
"admin": {"password": secrets.token_urlsafe(16)}, # Generate secure random password "admin": {"password": secrets.token_urlsafe(16)}, # Generate secure random password
} }
# Add this logging configuration before creating the Flask app # Configure logging
dictConfig({ dictConfig({
'version': 1, 'version': 1,
'formatters': { 'formatters': {
@ -66,8 +83,9 @@ dictConfig({
# Get logger for this module # Get logger for this module
logger = getLogger(__name__) logger = getLogger(__name__)
# Create Flask app with persistent secret key
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.urandom(24) app.secret_key = get_or_create_secret_key()
def allowed_file(filename): def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@ -211,9 +229,6 @@ limiter = Limiter(
storage_uri="memory://", storage_uri="memory://",
) )
# Generate a strong secret key at startup
app.secret_key = secrets.token_hex(32)
@app.before_request @app.before_request
def before_request(): def before_request():
g.csp_nonce = secrets.token_hex(16) g.csp_nonce = secrets.token_hex(16)
@ -274,7 +289,7 @@ def get_images():
images = [] images = []
for photo in photos: for photo in photos:
factor = random.randint(2, 3) factor = random.randint(2, 3)
if photo.height < 4000 or photo.width < 4000: if photo.height < 4000 and photo.width < 4000:
factor = 1 factor = 1
if photo.orientation == 6 or photo.orientation == 8: if photo.orientation == 6 or photo.orientation == 8:
width, height = photo.height, photo.width width, height = photo.height, photo.width
@ -316,6 +331,7 @@ def admin():
photos=photos, photos=photos,
accent_color=config["appearance"]["accent_color"], accent_color=config["appearance"]["accent_color"],
config=config, config=config,
nonce=g.csp_nonce,
) )
@app.route("/admin/logout") @app.route("/admin/logout")
@ -446,17 +462,22 @@ def admin_upload():
file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
file.save(file_path) file.save(file_path)
# Extract EXIF data # Extract EXIF data with error handling
exif = None exif = {}
exifraw = None exifraw = None
with Image.open(file_path) as img: width = height = 0
exifraw = img.info["exif"] try:
width, height = img.size with Image.open(file_path) as img:
exif = { width, height = img.size
ExifTags.TAGS[k]: v if hasattr(img, '_getexif') and img._getexif() is not None:
for k, v in img._getexif().items() exifraw = img.info.get("exif")
if k in ExifTags.TAGS 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 # Generate a unique key for the image
unique_key = hashlib.sha256( unique_key = hashlib.sha256(
@ -474,25 +495,25 @@ def admin_upload():
# Generate thumbnails # Generate thumbnails
generate_thumbnails(filename) generate_thumbnails(filename)
# Get image dimensions # Handle exposure time with error handling
with Image.open(file_path) as img: try:
width, height = img.size 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"
exposure_time = exif["ExposureTime"] # Create database entry with safe defaults
if isinstance(exposure_time, tuple):
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
else:
exposure_fraction = f"1/{int(1/float(exposure_time))}"
# Create database entry
db_session = DBSession() db_session = DBSession()
new_photo = Photo( new_photo = Photo(
input_filename=filename, input_filename=filename,
thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}", thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
focal_length=str( focal_length=str(
exif.get("FocalLengthIn35mmFilm", exif.get("FocalLength", "")) exif.get("FocalLengthIn35mmFilm", exif.get("FocalLength", "0"))
), ),
aperture=str(exif.get("FNumber", "")), aperture=str(exif.get("FNumber", "0")),
shutter_speed=exposure_fraction, shutter_speed=exposure_fraction,
date_taken=datetime.strptime( date_taken=datetime.strptime(
str(exif.get("DateTime", "1970:01:01 00:00:00")), "%Y:%m:%d %H:%M:%S" str(exif.get("DateTime", "1970:01:01 00:00:00")), "%Y:%m:%d %H:%M:%S"

View File

@ -203,8 +203,8 @@
<td class="editable" data-field="iso">{{ photo.iso }}</td> <td class="editable" data-field="iso">{{ photo.iso }}</td>
<td>{{ photo.width }}x{{ photo.height }}</td> <td>{{ photo.width }}x{{ photo.height }}</td>
<td> <td>
<button onclick="saveChanges(this)">Save</button> <button class="save-btn">Save</button>
<button onclick="deletePhoto(this)" class="delete-btn">Delete</button> <button class="delete-btn">Delete</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -241,8 +241,11 @@
<input type="text" id="about.location" name="about.location" value="{{ config.about.location }}"> <input type="text" id="about.location" name="about.location" value="{{ config.about.location }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="about.profile_image">Profile Image Path:</label> <label for="about.profile_image">Profile Image:</label>
<input type="text" id="about.profile_image" name="about.profile_image" value="{{ config.about.profile_image }}"> <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" class="profile-image-upload" accept="image/jpeg,image/png" style="flex: 1;">
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="about.bio">Bio (Markdown):</label> <label for="about.bio">Bio (Markdown):</label>
@ -258,7 +261,7 @@
</div> </div>
</div> </div>
</div> </div>
<script nonce="{{ g.csp_nonce }}"> <script nonce="{{ nonce }}">
function makeEditable(element) { function makeEditable(element) {
const value = element.textContent; const value = element.textContent;
const input = document.createElement('input'); const input = document.createElement('input');
@ -281,7 +284,8 @@
}); });
}); });
function saveChanges(button) { function saveChanges(event) {
const button = event.target;
const row = button.closest('tr'); const row = button.closest('tr');
const photoId = row.dataset.id; const photoId = row.dataset.id;
const updatedData = {}; const updatedData = {};
@ -311,8 +315,9 @@
}); });
} }
function deletePhoto(button) { function deletePhoto(event) {
if (confirm('Are you sure you want to delete this photo?')) { if (confirm('Are you sure you want to delete this photo?')) {
const button = event.target;
const row = button.closest('tr'); const row = button.closest('tr');
const photoId = row.dataset.id; const photoId = row.dataset.id;
@ -335,16 +340,50 @@
} }
} }
document.querySelectorAll('.delete-btn').forEach(button => {
button.addEventListener('click', (event) => deletePhoto(event));
});
document.querySelectorAll('.save-btn').forEach(button => {
button.addEventListener('click', (event) => saveChanges(event));
});
document.querySelectorAll('.profile-image-upload').forEach(input => {
input.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) => { document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const formData = {}; const formData = {};
const inputs = e.target.querySelectorAll('input, textarea'); const inputs = e.target.querySelectorAll('input:not([type="file"]), textarea');
inputs.forEach(input => { inputs.forEach(input => {
formData[input.name] = input.value; formData[input.name] = input.value;
}); });
try { try {
const response = await fetch('/admin/update_config', { const response = await fetch('/admin/update_config', {
method: 'POST', method: 'POST',
@ -353,9 +392,9 @@
}, },
body: JSON.stringify(formData) body: JSON.stringify(formData)
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
alert('Configuration saved successfully'); alert('Configuration saved successfully');
location.reload(); location.reload();