@@ -99,7 +68,7 @@
Liquids
-
+
@@ -125,7 +94,7 @@
Logs
@@ -136,65 +105,25 @@
Logs
Save
+
+
Delete page
+
Remove this page. I can't really recover it if you do :
+
+
{% endblock %}
diff --git a/apps/tanks_manager/templates/tanks_manager/hub.html b/apps/tanks_manager/templates/tanks_manager/hub.html
new file mode 100644
index 0000000..d74a489
--- /dev/null
+++ b/apps/tanks_manager/templates/tanks_manager/hub.html
@@ -0,0 +1,73 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Tanks · Snowsune{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
Vore Page Editor
+
+ Ever wanted your own version of VorePageEditor? Now you can!
+
+ This is all still pretty experimental! Please report any bugs or feedback you have! vixi@snowsune.net
+
+ And thank you to Seth for the initial artwork!
+
+
+ {% if not user.is_authenticated %}
+
+
You need to log in to create or edit vore pages for your character!
+
+
+ Log in
+ Register
+
+
+ {% else %}
+
+ {% if tanks_owned %}
+
+ Your pages
+
+ {% for site in tanks_owned %}
+ -
+
+ {{ site.character_name|default:site.slug }}
+
/tanks/{{ site.slug }}/
+
+
+ Edit
+ View
+
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+ {% if tanks_owned %}Add another character{% else %}Create your first page{% endif %}
+
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/apps/tanks_manager/templates/tanks_manager/tank_show.html b/apps/tanks_manager/templates/tanks_manager/tank_show.html
new file mode 100644
index 0000000..7618882
--- /dev/null
+++ b/apps/tanks_manager/templates/tanks_manager/tank_show.html
@@ -0,0 +1,64 @@
+{% load humanize %}
+{% load static %}
+
+
+
+
+
+
{% if tank_site.character_name %}{{ tank_site.character_name }} · Tank{% else %}Tank · /tanks/{{ tank_site.slug }}/{% endif %}
+
+
+
+
+ {% if tank_site.character_name %}
+
+ {% if tank_site.character_url %}
+
+ {% else %}
+ {{ tank_site.character_name }}
+ {% endif %}
+
+ {% endif %}
+
+
+

+ {% for row in liquid_layers %}
+
+ {% endfor %}
+

+
+
+
+
+
+
+
+
diff --git a/apps/tanks_manager/templatetags/__init__.py b/apps/tanks_manager/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/tanks_manager/templatetags/tanks_manager_tags.py b/apps/tanks_manager/templatetags/tanks_manager_tags.py
new file mode 100644
index 0000000..2a146c1
--- /dev/null
+++ b/apps/tanks_manager/templatetags/tanks_manager_tags.py
@@ -0,0 +1,24 @@
+import re
+
+from django import template
+
+register = template.Library()
+
+_HEX6 = re.compile(r"^[0-9a-fA-F]{6}$")
+
+
+@register.filter
+def hex_for_color_input(value):
+ """Normalize stored color to #rrggbb for HTML color inputs."""
+ if value is None:
+ return "#ffffff"
+ s = str(value).strip()
+ if not s:
+ return "#ffffff"
+ if s.startswith("#"):
+ s = s[1:]
+ if len(s) == 3 and all(c in "0123456789abcdefABCDEF" for c in s):
+ return "#" + "".join((c * 2) for c in s).lower()
+ if len(s) == 6 and _HEX6.match(s):
+ return "#" + s.lower()
+ return "#ffffff"
diff --git a/apps/tanks_manager/tests.py b/apps/tanks_manager/tests.py
index 7ce503c..7ecf2ac 100644
--- a/apps/tanks_manager/tests.py
+++ b/apps/tanks_manager/tests.py
@@ -1,3 +1,66 @@
+from io import BytesIO
+
from django.test import TestCase
+from PIL import Image
+
+from apps.tanks_manager.foreground_labels import (
+ STAGE_H,
+ STAGE_W,
+ analyze_stage_foreground,
+ compute_foreground_label_profile,
+ interpolate_stage_x_pct,
+)
+
+
+class ForegroundLabelProfileTests(TestCase):
+ def test_interpolate_brackets(self):
+ samples = [[0.0, 10.0], [50.0, 60.0], [100.0, 90.0]]
+ self.assertAlmostEqual(interpolate_stage_x_pct(samples, 0), 10.0)
+ self.assertAlmostEqual(interpolate_stage_x_pct(samples, 100), 90.0)
+ self.assertAlmostEqual(interpolate_stage_x_pct(samples, 25), 35.0)
+
+ def test_compute_finds_vertical_transparent_gap(self):
+ img = Image.new("RGBA", (400, 400), (255, 0, 0, 255))
+ for y in range(400):
+ for x in range(160, 240):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ profile = compute_foreground_label_profile(buf)
+ self.assertGreater(len(profile), 8)
+ mid_x = interpolate_stage_x_pct(profile, 50.0)
+ self.assertGreater(mid_x, 35.0)
+ self.assertLess(mid_x, 65.0)
+
+ def test_analyze_detects_tank_vertical_band(self):
+ img = Image.new("RGBA", (STAGE_W, STAGE_H), (10, 10, 10, 255))
+ for y in range(200, 650):
+ for x in range(STAGE_W):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ profile, margins = analyze_stage_foreground(buf)
+ self.assertIsNotNone(margins)
+ top, bot = margins
+ self.assertEqual(top, 200)
+ self.assertEqual(bot, STAGE_H - 650)
+ self.assertGreater(len(profile), 8)
-# Create your tests here.
+ def test_analyze_extent_covers_two_separate_alpha_zones(self):
+ img = Image.new("RGBA", (STAGE_W, STAGE_H), (10, 10, 10, 255))
+ for y in range(100, 151):
+ for x in range(STAGE_W):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ for y in range(500, 601):
+ for x in range(STAGE_W):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ _profile, margins = analyze_stage_foreground(buf)
+ self.assertIsNotNone(margins)
+ top, bot = margins
+ self.assertEqual(top, 100)
+ self.assertEqual(bot, STAGE_H - 601)
diff --git a/apps/tanks_manager/urls.py b/apps/tanks_manager/urls.py
index c3be8d6..ac9f597 100644
--- a/apps/tanks_manager/urls.py
+++ b/apps/tanks_manager/urls.py
@@ -5,6 +5,9 @@
app_name = "tanks_manager"
urlpatterns = [
- path("data.json", views.data_json, name="data_json"),
- path("", views.edit, name="edit"),
+ path("", views.tanks_hub, name="hub"),
+ path("create/", views.create_my_tank, name="create"),
+ path("
/delete/", views.delete_my_tank, name="delete"),
+ path("/edit/", views.edit, name="edit"),
+ path("/", views.tank_show, name="show"),
]
diff --git a/apps/tanks_manager/views.py b/apps/tanks_manager/views.py
index d58cbaa..67cccc9 100644
--- a/apps/tanks_manager/views.py
+++ b/apps/tanks_manager/views.py
@@ -1,95 +1,108 @@
-import json
import os
import re
import time
-from django.conf import settings
+from datetime import datetime, timedelta, timezone as dt_timezone
+
from django.contrib import messages
-from django.contrib.admin.views.decorators import staff_member_required
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile
-from django.db import transaction
-from django.http import HttpResponse
-from django.shortcuts import redirect, render
-from django.utils.safestring import mark_safe
-from django.views.decorators.http import require_GET
-
-from .models import TankClone, TankLiquid, TankLog, TankSettings
-
-CLONE_NAME_CHOICES = (
- "Azure",
- "Cyan",
- "Indigo",
- "Lapis",
- "Midnight",
- "Navy",
- "Royal",
- "Sapphire",
- "Sky",
-)
-
-
-def _settings():
- obj, _ = TankSettings.objects.get_or_create(
- pk=1,
- defaults=dict(tank_top_offset=360, tank_bottom_offset=101),
- )
- return obj
-
-
-def _image_json(obj):
- if obj.image_file:
- base = settings.SITE_URL.rstrip("/")
- return f"{base}{obj.image_file.url}"
- return obj.image or ""
-
-
-def _liquid_export(li):
- row = {
- "name": li.name,
- "volume": li.volume,
- "color": li.color,
- "url": li.url,
- }
- img = _image_json(li)
- if img:
- row["image"] = img
- return row
-
-
-def _data_from_db():
- s = _settings()
- return {
- "clones": [
+from django.db import IntegrityError, transaction
+from django.shortcuts import get_object_or_404, redirect, render
+from django.templatetags.static import static
+from django.utils.text import slugify
+from django.views.decorators.http import require_GET, require_POST
+
+from .foreground_labels import analyze_stage_foreground, band_anchor_stage_x_pct
+from .models import TankLiquid, TankLog, TankSite, tanks_for_user
+
+# Stage layout: offsets are for an 850px-tall design box (see tank_page.css aspect-ratio).
+_DESIGN_STAGE_HEIGHT = 850
+
+
+def _stage_art_urls(site, request):
+ """Absolute URLs for stage layers: uploads, else bundled static defaults."""
+ if site.stage_background:
+ bg = request.build_absolute_uri(site.stage_background.url)
+ else:
+ bg = request.build_absolute_uri(
+ static("tanks_manager/Alice_close_up_sheath_background.png")
+ )
+ if site.stage_foreground:
+ fg = request.build_absolute_uri(site.stage_foreground.url)
+ else:
+ fg = request.build_absolute_uri(
+ static("tanks_manager/Alice_close_up_sheath_shot.png")
+ )
+ return bg, fg
+
+
+def _liquid_layer_rows(liquids, tank_top, tank_bottom, request, fg_label_profile=None):
+ """Bottom + height as % of design stage height."""
+ fg_label_profile = fg_label_profile or []
+ design = _DESIGN_STAGE_HEIGHT
+ tank_h = max(0, design - tank_top - tank_bottom)
+ offset_px = 0.0
+ cumulative_vol = 0
+ rows = []
+ for li in liquids:
+ vol = int(li.volume)
+ h_px = (vol / 100.0) * tank_h
+ bottom_px = tank_bottom + offset_px
+ cumulative_vol += vol
+ if h_px <= 0:
+ offset_px += h_px
+ continue
+ if li.image_file:
+ img = request.build_absolute_uri(li.image_file.url)
+ elif li.image:
+ raw = li.image.strip()
+ if raw.startswith(("http://", "https://", "data:")):
+ img = raw
+ elif raw.startswith("/"):
+ img = request.build_absolute_uri(raw)
+ else:
+ img = request.build_absolute_uri(f"/{raw.lstrip('/')}")
+ else:
+ img = ""
+ bottom_pct = (bottom_px / design) * 100
+ height_pct = (h_px / design) * 100
+ label_fg_left_pct = None
+ if fg_label_profile:
+ y_top_lo = 100.0 - bottom_pct - height_pct
+ y_top_hi = 100.0 - bottom_pct
+ sx = band_anchor_stage_x_pct(fg_label_profile, y_top_lo, y_top_hi)
+ label_fg_left_pct = max(0.0, min(100.0, sx))
+
+ rows.append(
{
- "name": c.name,
- "banner": c.banner,
- "image": _image_json(c),
- "url": c.url,
+ "bottom_pct": bottom_pct,
+ "height_pct": height_pct,
+ "color": li.color or "#ffffff",
+ "image": img,
+ "name": li.name,
+ "volume": vol,
+ "url": li.url.strip(),
+ "label_nudge": cumulative_vol < 20,
+ "label_fg_left_pct": label_fg_left_pct,
}
- for c in TankClone.objects.all()
- ],
- "liquids": [_liquid_export(li) for li in TankLiquid.objects.all()],
- "settings": {
- "tankTopOffset": s.tank_top_offset,
- "tankBottomOffset": s.tank_bottom_offset,
- },
- "logs": [{"date": g.date, "text": g.text} for g in TankLog.objects.all()],
- }
+ )
+ offset_px += h_px
+ return rows
+
+def _logs_for_show(logs):
+ out = []
+ now = datetime.now(dt_timezone.utc)
+ for g in logs:
+ dt = datetime.fromtimestamp(int(g.date), tz=dt_timezone.utc)
+ is_new = (now - dt) < timedelta(hours=8)
+ out.append({"dt": dt, "text": g.text, "is_new": is_new})
+ return out
-def _editor_data():
- s = _settings()
+
+def _editor_data(site):
return {
- "clones": [
- {
- "pk": c.pk,
- "name": c.name,
- "banner": c.banner,
- "image": c.image,
- "url": c.url,
- "upload_preview_url": c.image_file.url if c.image_file else "",
- }
- for c in TankClone.objects.all()
- ],
"liquids": [
{
"pk": li.pk,
@@ -100,13 +113,24 @@ def _editor_data():
"image": li.image,
"upload_preview_url": li.image_file.url if li.image_file else "",
}
- for li in TankLiquid.objects.all()
+ for li in TankLiquid.objects.filter(tank_site=site)
],
"settings": {
- "tankTopOffset": s.tank_top_offset,
- "tankBottomOffset": s.tank_bottom_offset,
+ "tankTopOffset": site.tank_top_offset,
+ "tankBottomOffset": site.tank_bottom_offset,
+ "character_name": site.character_name,
+ "character_url": site.character_url,
+ "stage_background_url": site.stage_background.url
+ if site.stage_background
+ else "",
+ "stage_foreground_url": site.stage_foreground.url
+ if site.stage_foreground
+ else "",
},
- "logs": [{"date": g.date, "text": g.text} for g in TankLog.objects.all()],
+ "logs": [
+ {"date": g.date, "text": g.text}
+ for g in TankLog.objects.filter(tank_site=site)
+ ],
}
@@ -117,21 +141,7 @@ def _idx(post, files, letter):
def _build(post, files):
- clones, liquids, logs = [], [], []
- for i in _idx(post, files, "c"):
- raw_pk = post.get(f"c{i}_id", "").strip()
- row = {
- "pk": int(raw_pk) if raw_pk.isdigit() else None,
- "name": post.get(f"c{i}_name", "").strip(),
- "banner": post.get(f"c{i}_banner", "").strip(),
- "image": post.get(f"c{i}_image", "").strip(),
- "url": post.get(f"c{i}_url", "").strip(),
- "_clear_upload": f"c{i}_clearimg" in post,
- }
- k = f"c{i}_imgfile"
- if k in files and getattr(files[k], "name", ""):
- row["_upload"] = files[k]
- clones.append(row)
+ liquids, logs = [], []
for i in _idx(post, files, "l"):
raw_pk = post.get(f"l{i}_id", "").strip()
row = {
@@ -158,11 +168,12 @@ def _build(post, files):
d = now
logs.append({"date": d, "text": post.get(f"g{i}_text", "").strip()})
return {
- "clones": clones,
"liquids": liquids,
"settings": {
"tankTopOffset": int(post.get("top") or 0),
"tankBottomOffset": int(post.get("bot") or 0),
+ "character_name": post.get("char_name", "").strip()[:200],
+ "character_url": post.get("char_url", "").strip()[:500],
},
"logs": logs,
}
@@ -183,38 +194,21 @@ def _liquid_change_logs(old_vols, new_liquids):
return lines
-def _snapshot_image_files(model_cls):
+def _snapshot_image_files(model_cls, tank_site):
by_pk = {}
- for obj in model_cls.objects.all():
+ for obj in model_cls.objects.filter(tank_site=tank_site):
if obj.image_file:
with obj.image_file.open("rb") as fh:
by_pk[obj.pk] = (obj.image_file.name, fh.read())
return by_pk
-def _save_clone(row, snap):
- pk = row["pk"]
- up = row.get("_upload")
- clear = row.get("_clear_upload")
- obj = TankClone.objects.create(
- sort_order=row["_order"],
- name=row["name"],
- banner=row["banner"],
- image=row["image"],
- url=row["url"],
- )
- if up:
- obj.image_file.save(up.name, up, save=True)
- elif pk and snap.get(pk) and not clear:
- name, raw = snap[pk]
- obj.image_file.save(os.path.basename(name), ContentFile(raw), save=True)
-
-
-def _save_liquid(row, snap):
+def _save_liquid(row, snap, tank_site):
pk = row["pk"]
up = row.get("_upload")
clear = row.get("_clear_upload")
obj = TankLiquid.objects.create(
+ tank_site=tank_site,
sort_order=row["_order"],
name=row["name"],
volume=row["volume"],
@@ -229,69 +223,228 @@ def _save_liquid(row, snap):
obj.image_file.save(os.path.basename(name), ContentFile(raw), save=True)
-def _dumps(data):
- return json.dumps(data, indent=1, ensure_ascii=False) + "\n"
+@require_GET
+def tank_show(request, slug):
+ site = get_object_or_404(TankSite, slug=slug)
+ liquids = TankLiquid.objects.filter(tank_site=site)
+ logs = TankLog.objects.filter(tank_site=site)
+ bg_url, fg_url = _stage_art_urls(site, request)
+ return render(
+ request,
+ "tanks_manager/tank_show.html",
+ {
+ "tank_site": site,
+ "stage_background_url": bg_url,
+ "stage_foreground_url": fg_url,
+ "liquid_layers": _liquid_layer_rows(
+ liquids,
+ site.tank_top_offset,
+ site.tank_bottom_offset,
+ request,
+ fg_label_profile=site.stage_fg_label_profile or [],
+ ),
+ "logs": _logs_for_show(logs),
+ },
+ )
@require_GET
-def data_json(request):
- resp = HttpResponse(_dumps(_data_from_db()), content_type="application/json")
- resp["Access-Control-Allow-Origin"] = "*"
- return resp
+def tanks_hub(request):
+ tanks_owned = []
+ username_slug_hint = ""
+ if request.user.is_authenticated:
+ tanks_owned = list(tanks_for_user(request.user))
+ username_slug_hint = _tank_slug_from_username(request.user)
+ return render(
+ request,
+ "tanks_manager/hub.html",
+ {
+ "tanks_owned": tanks_owned,
+ "username_slug_hint": username_slug_hint,
+ },
+ )
+
+
+def _tank_slug_from_username(user):
+ """Preferred slug from login name (slug field rules)."""
+ return slugify(user.username) or f"user-{user.pk}"
+
+
+def _slug_candidates_for_new_tank(user):
+ """Yield slug guesses until one is unused (globally unique)."""
+ base = _tank_slug_from_username(user)
+ yield base
+ for n in range(2, 500):
+ yield f"{base}-{n}"
+
+
+@login_required
+@require_POST
+def create_my_tank(request):
+ redirect_to = "tanks_manager:hub"
+ requested = slugify((request.POST.get("slug") or "").strip())[:50]
+
+ if requested:
+ if TankSite.objects.filter(slug=requested).exists():
+ messages.error(
+ request,
+ "That URL slug is already taken. Pick a different one.",
+ )
+ return redirect(redirect_to)
+ try:
+ TankSite.objects.create(owner=request.user, slug=requested)
+ except IntegrityError:
+ messages.error(
+ request,
+ "Could not create that slug — try another.",
+ )
+ return redirect(redirect_to)
+ slug = requested
+ else:
+ slug = None
+ for cand in _slug_candidates_for_new_tank(request.user):
+ if TankSite.objects.filter(slug=cand).exists():
+ continue
+ try:
+ TankSite.objects.create(owner=request.user, slug=cand)
+ slug = cand
+ break
+ except IntegrityError:
+ continue
+ if slug is None:
+ messages.error(
+ request,
+ "Could not allocate a URL slug. Try entering one manually.",
+ )
+ return redirect(redirect_to)
+
+ messages.success(
+ request,
+ "Tank page created — customize it in the editor.",
+ )
+ return redirect("tanks_manager:edit", slug=slug)
@transaction.atomic
-def _persist(data):
- old_vols = {li.name: li.volume for li in TankLiquid.objects.order_by("sort_order")}
- clone_snap = _snapshot_image_files(TankClone)
- liquid_snap = _snapshot_image_files(TankLiquid)
-
- s = _settings()
- s.tank_top_offset = data["settings"]["tankTopOffset"]
- s.tank_bottom_offset = data["settings"]["tankBottomOffset"]
- s.save()
-
- TankClone.objects.all().delete()
- for i, row in enumerate(data["clones"]):
- row["_order"] = i
- _save_clone(row, clone_snap)
+def _persist(data, tank_site):
+ old_vols = {
+ li.name: li.volume
+ for li in TankLiquid.objects.filter(tank_site=tank_site).order_by("sort_order")
+ }
+ liquid_snap = _snapshot_image_files(TankLiquid, tank_site)
+
+ tank_site.tank_top_offset = data["settings"]["tankTopOffset"]
+ tank_site.tank_bottom_offset = data["settings"]["tankBottomOffset"]
+ tank_site.character_name = data["settings"]["character_name"]
+ tank_site.character_url = data["settings"]["character_url"]
+ tank_site.save(
+ update_fields=[
+ "tank_top_offset",
+ "tank_bottom_offset",
+ "character_name",
+ "character_url",
+ ]
+ )
- auto = _liquid_change_logs(old_vols, data["liquids"])
- TankLiquid.objects.all().delete()
+ TankLiquid.objects.filter(tank_site=tank_site).delete()
for i, row in enumerate(data["liquids"]):
row["_order"] = i
- _save_liquid(row, liquid_snap)
+ _save_liquid(row, liquid_snap, tank_site)
- TankLog.objects.all().delete()
+ auto = _liquid_change_logs(old_vols, data["liquids"])
+ TankLog.objects.filter(tank_site=tank_site).delete()
TankLog.objects.bulk_create(
- [TankLog(date=g["date"], text=g["text"]) for g in data["logs"]]
+ [
+ TankLog(tank_site=tank_site, date=g["date"], text=g["text"])
+ for g in data["logs"]
+ ]
)
if auto:
- TankLog.objects.bulk_create([TankLog(date=d, text=t) for d, t in auto])
-
-
-@staff_member_required
-def edit(request):
- err = None
- data = _editor_data()
+ TankLog.objects.bulk_create(
+ [TankLog(tank_site=tank_site, date=d, text=t) for d, t in auto]
+ )
+
+
+def _can_edit_tank(user, site):
+ return user.is_staff or (user.is_authenticated and user.pk == site.owner_id)
+
+
+def _refresh_stage_foreground_analysis(site):
+ """Rebuild label profile + tank top/bottom offsets from overlay transparency."""
+ if not site.stage_foreground:
+ site.stage_fg_label_profile = []
+ site.save(update_fields=["stage_fg_label_profile"])
+ return
+ try:
+ with site.stage_foreground.open("rb") as fh:
+ profile, margins = analyze_stage_foreground(fh)
+ except Exception:
+ profile, margins = [], None
+ site.stage_fg_label_profile = profile
+ update_fields = ["stage_fg_label_profile"]
+ if margins is not None:
+ top, bot = margins
+ site.tank_top_offset = top
+ site.tank_bottom_offset = bot
+ update_fields.extend(["tank_top_offset", "tank_bottom_offset"])
+ site.save(update_fields=update_fields)
+
+
+@login_required
+@require_POST
+def delete_my_tank(request, slug):
+ site = get_object_or_404(TankSite, slug=slug)
+ if not _can_edit_tank(request.user, site):
+ raise PermissionDenied
+ label = site.character_name or site.slug
+ site.delete()
+ messages.success(request, f"Deleted “{label}”.")
+ return redirect("tanks_manager:hub")
+
+
+@login_required
+def edit(request, slug):
+ site = get_object_or_404(TankSite, slug=slug)
+ if not _can_edit_tank(request.user, site):
+ raise PermissionDenied
+
+ data = _editor_data(site)
if request.method == "POST" and "save" in request.POST:
try:
data = _build(request.POST, request.FILES)
- _persist(data)
+ _persist(data, site)
+ site = get_object_or_404(TankSite, slug=slug)
+ if request.POST.get("clear_stage_bg"):
+ if site.stage_background:
+ site.stage_background.delete(save=False)
+ site.stage_background = None
+ site.save(update_fields=["stage_background"])
+ if request.POST.get("clear_stage_fg"):
+ if site.stage_foreground:
+ site.stage_foreground.delete(save=False)
+ site.stage_foreground = None
+ site.stage_fg_label_profile = []
+ site.save(update_fields=["stage_foreground", "stage_fg_label_profile"])
+ site = get_object_or_404(TankSite, slug=slug)
+ if f := request.FILES.get("stage_bg"):
+ if getattr(f, "name", ""):
+ site.stage_background.save(f.name, f, save=True)
+ if f := request.FILES.get("stage_fg"):
+ if getattr(f, "name", ""):
+ site.stage_foreground.save(f.name, f, save=True)
+ _refresh_stage_foreground_analysis(site)
messages.success(request, "Saved.")
- return redirect("tanks_manager:edit")
+ return redirect("tanks_manager:edit", slug=site.slug)
except Exception as e:
- err = str(e)
+ messages.error(request, str(e))
return render(
request,
"tanks_manager/edit.html",
{
+ "tank_site": site,
"data": data,
- "err": err,
"unix_now": int(time.time()),
- "clone_names": CLONE_NAME_CHOICES,
- "clone_names_json": mark_safe(json.dumps(CLONE_NAME_CHOICES)),
},
)
diff --git a/apps/users/templates/users/edit.html b/apps/users/templates/users/edit.html
index eaef1ce..ab882e1 100644
--- a/apps/users/templates/users/edit.html
+++ b/apps/users/templates/users/edit.html
@@ -175,12 +175,6 @@
{% block content %}
Edit your account
-{% if messages %}
-{% for message in messages %}
-{{ message }}
-{% endfor %}
-{% endif %}
-
Account Information
Username: {{ user.username }}
diff --git a/apps/users/utils.py b/apps/users/utils.py
index c820b7f..8a1a5da 100644
--- a/apps/users/utils.py
+++ b/apps/users/utils.py
@@ -1,10 +1,11 @@
import secrets
import hashlib
from urllib.parse import urlparse
-from django.utils import timezone
-from django.core.mail import send_mail
+
from django.conf import settings
-from django.template.loader import render_to_string
+from django.core.mail import send_mail
+from django.urls import reverse
+from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
@@ -41,14 +42,15 @@ def send_verification_email(user):
user.email_verified = False # Ensure they're unverified
user.save()
- # Create verification link
- verification_url = f"{settings.SITE_URL}/users/verify-email/{user.id}/{token}/"
+ # Create verification link (always canonical origin, not dev SITE_URL)
+ base = settings.EMAIL_VERIFICATION_CANONICAL_ORIGIN.rstrip("/")
+ path = reverse("verify-email", kwargs={"user_id": user.id, "token": token})
+ verification_url = f"{base}{path}"
# Send email
try:
- # Get the domain from SITE_URL for the subject
- parsed_url = urlparse(settings.SITE_URL)
- site_domain = parsed_url.netloc or parsed_url.path
+ parsed_url = urlparse(settings.EMAIL_VERIFICATION_CANONICAL_ORIGIN)
+ site_domain = parsed_url.netloc or parsed_url.path or "snowsune.net"
subject = f"Verify your email address on {site_domain}"
diff --git a/apps/users/views.py b/apps/users/views.py
index 7221b84..065d2fc 100644
--- a/apps/users/views.py
+++ b/apps/users/views.py
@@ -1,10 +1,14 @@
-from django.shortcuts import render, redirect, get_object_or_404
+from urllib.parse import urlparse
+
+from django.conf import settings
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django import forms
-from django.conf import settings
import logging
from .models import CustomUser
from .utils import send_verification_email, verify_email_token
@@ -192,6 +196,14 @@ def verify_email_view(request, user_id, token):
"""
Handle email verification link clicks.
"""
+ canonical = urlparse(settings.EMAIL_VERIFICATION_CANONICAL_ORIGIN)
+ canonical_host = (canonical.hostname or "").lower()
+ req_host = request.get_host().split(":")[0].lower()
+ if canonical_host and req_host != canonical_host:
+ base = settings.EMAIL_VERIFICATION_CANONICAL_ORIGIN.rstrip("/")
+ path = reverse("verify-email", kwargs={"user_id": user_id, "token": token})
+ return HttpResponseRedirect(f"{base}{path}")
+
user = get_object_or_404(CustomUser, id=user_id)
if verify_email_token(user, token):
diff --git a/media/tanks/stage/background.png b/media/tanks/stage/background.png
new file mode 100644
index 0000000..a0827f8
Binary files /dev/null and b/media/tanks/stage/background.png differ
diff --git a/media/tanks/stage/foreground.png b/media/tanks/stage/foreground.png
new file mode 100644
index 0000000..c88c46b
Binary files /dev/null and b/media/tanks/stage/foreground.png differ
diff --git a/media/tanks/stage/foreground_BcbZ3B2.png b/media/tanks/stage/foreground_BcbZ3B2.png
new file mode 100644
index 0000000..c88c46b
Binary files /dev/null and b/media/tanks/stage/foreground_BcbZ3B2.png differ
diff --git a/media/tanks/stage/foreground_QtEX0bB.png b/media/tanks/stage/foreground_QtEX0bB.png
new file mode 100644
index 0000000..c88c46b
Binary files /dev/null and b/media/tanks/stage/foreground_QtEX0bB.png differ
diff --git a/snowsune/middleware.py b/snowsune/middleware.py
index 5b97012..50366db 100644
--- a/snowsune/middleware.py
+++ b/snowsune/middleware.py
@@ -1,5 +1,6 @@
from django.http import HttpResponse
from django.shortcuts import render
+from django.utils.cache import patch_vary_headers
# Per-region reference link
REGION_451_REFERENCES = {
@@ -41,3 +42,25 @@ def __call__(self, request):
return HttpResponse(fallback, status=451)
return self.get_response(request)
+
+
+class PrivateHtmlNoStoreMiddleware:
+ """
+ Nav and login widgets (base.html) depend on the session cookie. Browsers and
+ upstream caches sometimes reuse a stored HTML document without sending
+ Cookie so users see an anonymous shell while logged in. Mark HTML as
+ private and uncached so each visit reflects the current session.
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ response = self.get_response(request)
+ if response.status_code != 200:
+ return response
+ if "text/html" not in response.get("Content-Type", ""):
+ return response
+ response["Cache-Control"] = "private, no-store"
+ patch_vary_headers(response, ["Cookie"])
+ return response
diff --git a/snowsune/settings.py b/snowsune/settings.py
index 2333f94..56b77b8 100644
--- a/snowsune/settings.py
+++ b/snowsune/settings.py
@@ -52,6 +52,13 @@
# Site URL for webhooks and external links
SITE_URL = os.getenv("SITE_URL", "https://snowsune.net")
+# Verification emails and /users/verify-email/ redirects: always use production so
+# dev/staging SITE_URL never ends up in inboxes or partial verifies on the wrong DB.
+EMAIL_VERIFICATION_CANONICAL_ORIGIN = os.getenv(
+ "EMAIL_VERIFICATION_CANONICAL_ORIGIN",
+ "https://snowsune.net",
+)
+
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
@@ -60,6 +67,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "django.contrib.humanize",
# Main app
"snowsune",
# Custom apps
@@ -74,7 +82,7 @@
"apps.bot_manager", # Fops Bot management app
"apps.bookclub", # Comic book club app
"apps.custompages", # Custom pages with markdown
- "apps.tanks_manager", # Cumtanks data.json editor
+ "apps.tanks_manager", # Per-user tank pages + editor
]
MIDDLEWARE = [
@@ -87,6 +95,7 @@
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "snowsune.middleware.PrivateHtmlNoStoreMiddleware",
]
# Trust reverse proxy headers
diff --git a/snowsune/templates/tools.html b/snowsune/templates/tools.html
index 34ffb7e..07f1529 100644
--- a/snowsune/templates/tools.html
+++ b/snowsune/templates/tools.html
@@ -23,13 +23,6 @@