import hashlib import hmac import json import logging from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import ( HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound, JsonResponse, ) from django.shortcuts import redirect, render from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST from coulomb_social.apps.core.principal import build_principal from . import pageops from .content import ( clear_content_cache_for_repo, clear_content_cache_for_space, forgejo_blob_url, forgejo_edit_url, forgejo_repo_url, load_space_page, ) from .models import Space from .services import ( archive_space, copy_space_page, create_space, get_space_for_member, list_space_pages, put_space_page, spaces_for_member, transfer_space_page, update_space, ) logger = logging.getLogger(__name__) def _principal_or_forbid(request: HttpRequest, resource: str): principal = build_principal(request, authz_resource_id=resource) if not principal["authz_allow"]: return None, HttpResponseForbidden( f"Not authorized ({principal['authz_reason']})." ) return principal, None @login_required @require_http_methods(["GET", "POST"]) def space_create(request: HttpRequest) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "space_create") if forbid: return forbid member = principal.get("member") if request.method == "POST": title = (request.POST.get("title") or "").strip() abstractor = (request.POST.get("abstractor") or "").strip() slug = (request.POST.get("slug") or "").strip() or None visual = (request.POST.get("visual") or "").strip() try: space = create_space( member, title=title, abstractor=abstractor, slug=slug, visual=visual, ) messages.success(request, f"Space “{space.title}” created.") return redirect("spaces:detail", slug=space.slug) except pageops.PageOpsError as exc: messages.error(request, exc.message) return render( request, "spaces/form.html", { "principal": principal, "display_name": principal["display_name"], "form_title": "Create space", "space": None, "submit_label": "Create space", }, ) @login_required @require_http_methods(["GET", "POST"]) def space_edit(request: HttpRequest, slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "space_edit") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") if request.method == "POST": try: update_space( member, space, title=(request.POST.get("title") or "").strip(), abstractor=(request.POST.get("abstractor") or "").strip(), visual=(request.POST.get("visual") or "").strip(), ) messages.success(request, "Space updated.") return redirect("spaces:detail", slug=space.slug) except pageops.PageOpsError as exc: messages.error(request, exc.message) return render( request, "spaces/form.html", { "principal": principal, "display_name": principal["display_name"], "form_title": f"Edit {space.title}", "space": space, "submit_label": "Save", }, ) @login_required @require_POST def space_archive(request: HttpRequest, slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "space_archive") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") try: archive_space(member, space) messages.success(request, f"Space “{space.title}” archived.") except pageops.PageOpsError as exc: messages.error(request, exc.message) return redirect("core:app_home") @login_required def space_detail(request: HttpRequest, slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "space_detail") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") page = request.GET.get("page") or "index" rendered = load_space_page(space, page) pages = list_space_pages(space) return render( request, "spaces/detail.html", { "principal": principal, "display_name": principal["display_name"], "space": space, "page": rendered, "pages": pages, "forgejo_repo_url": forgejo_repo_url(space), "forgejo_edit_url": forgejo_edit_url(space, page), "forgejo_blob_url": forgejo_blob_url(space, page), }, ) @login_required @require_http_methods(["GET", "POST"]) def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> HttpResponse: principal, forbid = _principal_or_forbid(request, "page_edit") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") if page_slug != "index": try: page_slug = pageops.normalize_slug(page_slug) except pageops.PageOpsError as exc: messages.error(request, exc.message) return redirect("spaces:detail", slug=slug) doc = pageops.read_page(space.slug, page_slug) if request.method == "POST": title = (request.POST.get("title") or "").strip() abstractor = (request.POST.get("abstractor") or "").strip() visual = (request.POST.get("visual") or "").strip() body = request.POST.get("body") or "" new_slug = (request.POST.get("slug") or page_slug).strip() try: if page_slug == "index": target_slug = "index" else: target_slug = pageops.normalize_slug(new_slug) if target_slug != page_slug and page_slug != "index": # rename: write new, delete old if pageops.page_file(space.slug, target_slug).is_file(): raise pageops.PageOpsError("A page with that slug already exists") put_space_page( member, space, target_slug, title=title, abstractor=abstractor, visual=visual, body=body, ) pageops.delete_page(space.slug, page_slug) page_slug = target_slug else: put_space_page( member, space, target_slug, title=title, abstractor=abstractor, visual=visual, body=body, ) page_slug = target_slug clear_content_cache_for_space(space) messages.success(request, "Page saved.") target = reverse("spaces:detail", kwargs={"slug": space.slug}) if page_slug != "index": target = f"{target}?page={page_slug}" return redirect(target) except pageops.PageOpsError as exc: messages.error(request, exc.message) doc = pageops.PageDoc( slug=page_slug, title=title, abstractor=abstractor, visual=visual, body=body, path=doc.path, ) return render( request, "spaces/page_form.html", { "principal": principal, "display_name": principal["display_name"], "space": space, "doc": doc, "is_new": not doc.exists, "form_title": "Edit page" if doc.exists else "New page", }, ) @login_required @require_http_methods(["GET", "POST"]) def page_new(request: HttpRequest, slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "page_new") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") title = "" abstractor = "" visual = "" body = "" page_slug = "" if request.method == "POST": title = (request.POST.get("title") or "").strip() abstractor = (request.POST.get("abstractor") or "").strip() visual = (request.POST.get("visual") or "").strip() body = request.POST.get("body") or "" raw_slug = (request.POST.get("slug") or title or "page").strip() try: page_slug = pageops.normalize_slug(raw_slug, allow_index=False) if pageops.page_file(space.slug, page_slug).is_file(): raise pageops.PageOpsError("A page with that slug already exists") put_space_page( member, space, page_slug, title=title or page_slug, abstractor=abstractor, visual=visual, body=body, ) clear_content_cache_for_space(space) messages.success(request, "Page created.") target = reverse("spaces:detail", kwargs={"slug": space.slug}) return redirect(f"{target}?page={page_slug}") except pageops.PageOpsError as exc: messages.error(request, exc.message) return render( request, "spaces/page_form.html", { "principal": principal, "display_name": principal["display_name"], "space": space, "doc": pageops.PageDoc( slug=page_slug, title=title, abstractor=abstractor, visual=visual, body=body, ), "is_new": True, "form_title": "New page", }, ) @login_required @require_POST def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "page_delete") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") try: if get_space_for_member(member, space.slug) is None: raise pageops.PageOpsError("Space not found") pageops.delete_page(space.slug, page_slug) clear_content_cache_for_space(space) messages.success(request, f"Page “{page_slug}” deleted.") except pageops.PageOpsError as exc: messages.error(request, exc.message) return redirect("spaces:detail", slug=slug) @login_required @require_http_methods(["GET", "POST"]) def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "page_copy") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") all_spaces = list(spaces_for_member(member)) if request.method == "POST": dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip() dest_space_slug = (request.POST.get("dest_space") or space.slug).strip() dest_space = get_space_for_member(member, dest_space_slug) title = (request.POST.get("title") or "").strip() or None try: doc = copy_space_page( member, space, page_slug, dest_slug=dest_slug, dest_space=dest_space, title=title, ) messages.success(request, f"Copied to “{doc.slug}”.") target = reverse("spaces:detail", kwargs={"slug": dest_space.slug}) if doc.slug != "index": target = f"{target}?page={doc.slug}" return redirect(target) except pageops.PageOpsError as exc: messages.error(request, exc.message) return render( request, "spaces/page_copy.html", { "principal": principal, "display_name": principal["display_name"], "space": space, "page_slug": page_slug, "all_spaces": all_spaces, "default_dest": f"{page_slug}-copy", }, ) @login_required @require_http_methods(["GET", "POST"]) def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse: principal, forbid = _principal_or_forbid(request, "page_transfer") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") all_spaces = [s for s in spaces_for_member(member) if s.slug != space.slug] if request.method == "POST": dest_space_slug = (request.POST.get("dest_space") or "").strip() dest_slug = (request.POST.get("dest_slug") or page_slug).strip() dest_space = get_space_for_member(member, dest_space_slug) try: if dest_space is None: raise pageops.PageOpsError("Choose a destination space") doc = transfer_space_page( member, space, page_slug, dest_space, dest_slug=dest_slug, ) messages.success(request, f"Moved to {dest_space.title}.") target = reverse("spaces:detail", kwargs={"slug": dest_space.slug}) if doc.slug != "index": target = f"{target}?page={doc.slug}" return redirect(target) except pageops.PageOpsError as exc: messages.error(request, exc.message) return render( request, "spaces/page_transfer.html", { "principal": principal, "display_name": principal["display_name"], "space": space, "page_slug": page_slug, "all_spaces": all_spaces, }, ) @login_required @require_POST def space_refresh(request: HttpRequest, slug: str) -> HttpResponse: """Drop cache for this space and re-show the page.""" principal, forbid = _principal_or_forbid(request, "space_refresh") if forbid: return forbid member = principal.get("member") space = get_space_for_member(member, slug) if space is None: return HttpResponseNotFound("Space not found.") n = clear_content_cache_for_space(space) messages.success( request, f"Content cache cleared ({n} entries). Fresh fetch on this page load.", ) page = request.POST.get("page") or request.GET.get("page") or "index" target = reverse("spaces:detail", kwargs={"slug": slug}) if page and page != "index": target = f"{target}?page={page}" return redirect(target) @csrf_exempt @require_POST def forgejo_content_webhook(request: HttpRequest) -> JsonResponse: """Forgejo/Gitea push webhook → invalidate content cache for matching spaces.""" secret = (getattr(settings, "FORGEJO_WEBHOOK_SECRET", None) or "").strip() if not secret: return JsonResponse( {"ok": False, "error": "webhook not configured"}, status=503, ) if not _webhook_authorized(request, secret): return JsonResponse({"ok": False, "error": "unauthorized"}, status=401) try: payload = json.loads(request.body.decode("utf-8") or "{}") except json.JSONDecodeError: return JsonResponse({"ok": False, "error": "invalid json"}, status=400) owner, repo = _repo_from_push_payload(payload) if not owner or not repo: return JsonResponse({"ok": False, "error": "no repository in payload"}, status=400) cleared = clear_content_cache_for_repo(owner, repo) matched = Space.objects.filter( forgejo_owner__iexact=owner, forgejo_repo__iexact=repo, is_active=True, ).count() logger.info( "Forgejo webhook cache invalidate owner=%s repo=%s cleared=%s spaces=%s", owner, repo, cleared, matched, ) return JsonResponse( { "ok": True, "owner": owner, "repo": repo, "cache_entries_cleared": cleared, "spaces_matched": matched, } ) def _webhook_authorized(request: HttpRequest, secret: str) -> bool: header_secret = request.headers.get("X-Coulomb-Webhook-Secret", "") if header_secret and hmac.compare_digest(header_secret, secret): return True sig = request.headers.get("X-Gitea-Signature") or request.headers.get( "X-Hub-Signature-256", "" ) if sig.startswith("sha256="): sig = sig[len("sha256=") :] if sig: digest = hmac.new( secret.encode("utf-8"), request.body, hashlib.sha256 ).hexdigest() if hmac.compare_digest(digest, sig): return True return False def _repo_from_push_payload(payload: dict) -> tuple[str, str]: repo = payload.get("repository") or {} full = (repo.get("full_name") or "").strip() if "/" in full: owner, name = full.split("/", 1) return owner, name owner = repo.get("owner") or {} if isinstance(owner, dict): owner_name = owner.get("login") or owner.get("username") or "" else: owner_name = str(owner) name = repo.get("name") or "" return str(owner_name), str(name)