Compare commits

49 Commits

Author SHA1 Message Date
giles
bd25d442f5 Fix circular fragment fetching (shared submodule update)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:21:18 +00:00
giles
e9d4a34484 Sync shared: fragment failures now raise by default
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:04:41 +00:00
giles
e6a0f4f11f trigger rebuild
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-02-24 18:02:05 +00:00
giles
9973683ee4 Remove cross-domain cart mini copy (shared _oob.html now uses fragment)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:33:18 +00:00
giles
fd8505a6dd Add cross-domain template copy: cart mini for OOB header swaps
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:17:39 +00:00
giles
204c86f213 Sync shared submodule (bound DB connection pool)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:08:21 +00:00
giles
7744ec07e6 Sync shared submodule (Phase 6 template migration)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:55:50 +00:00
giles
07b8bb0a14 Sync shared submodule (Phase 5 widget cleanup)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:59:10 +00:00
giles
52317518d8 Sync shared submodule: Phase 4 container widget → fragment changes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:33:49 +00:00
giles
b2b853c052 Fix actors.profile → activitypub.actor_profile endpoint references
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
The AP blueprint was consolidated but 4 references to the old
'actors.profile' endpoint remained, causing BuildError on social pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:46:04 +00:00
giles
f17dd923a6 Restore menu_items fallback for nav, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Keep get_navigation_tree() as fallback when nav-tree fragment fetch
fails. Update shared submodule with fixed app slug URLs in nav.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:57:52 +00:00
giles
2016f0b727 Fetch nav-tree fragment from blog, drop local menu_items query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Navigation is now rendered by blog as an HTML fragment. This app
fetches it with its own app_name and path for correct highlighting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:39:39 +00:00
giles
6674eb827d Update shared submodule (product_slug rename in templates)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:30:16 +00:00
giles
8f4d3d76c1 Update shared submodule (fragment auth skip for internal paths)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:11:58 +00:00
giles
cd3cc4def0 Add fragment blueprint + sync shared: micro-frontend infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:52 +00:00
giles
0c56d8dded Sync shared: instant logout detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:40 +00:00
giles
f75cebcf85 Sync shared submodule: external delivery handler
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:41:21 +00:00
giles
96c2e44cf0 Sync shared: add artdag_url() helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:54 +00:00
giles
b649554642 Sync shared: per-domain delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-02-23 21:54:20 +00:00
giles
caa1fd3115 Update shared: backfill only current posts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:36:50 +00:00
giles
7a68fc6ed3 Update shared: rewrite object URLs for per-app AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:07 +00:00
giles
46df2fdb87 Update shared: fix activity ID domain mismatch in AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:13 +00:00
giles
0e9e6b9dc4 Update shared submodule: exempt AP paths from auth redirect
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:29:08 +00:00
giles
1cb9323167 Remove dead wellknown/actors BPs, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
These blueprints are fully replaced by the shared AP blueprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:31:05 +00:00
giles
bcfeec99e9 Use shared AP blueprint, drop custom wellknown/actors BPs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Webfinger, actor profile, inbox, outbox, and followers are now served
by the shared AP blueprint registered in create_base_app(). Federation
keeps identity + social blueprints for UI routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:02:53 +00:00
giles
baf78f9805 Update shared submodule (blog.home → blog.index template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
2026-02-23 16:55:43 +00:00
giles
6c42722dc6 Retrigger CI (Docker Hub image now cached)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-02-23 16:45:51 +00:00
giles
1546c6d7c9 Update shared submodule (at-least-once + delivery log)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1s
2026-02-23 16:21:19 +00:00
giles
baf8f011e4 Update shared submodule (NOTIFY/LISTEN event processor)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1s
2026-02-23 16:05:23 +00:00
giles
8b77ae61cd Update shared submodule (add device_id migration)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
2026-02-23 15:26:55 +00:00
giles
90c779aebb Update shared: blog_did = account_did, one device identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:12:30 +00:00
giles
1af5b189d7 Update shared: device-id SSO with account_did + Redis login signal
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:01:53 +00:00
giles
fc7b36688e Sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:41:37 +00:00
giles
2b76310dc1 Update shared: add aiohttp dependency
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:05:52 +00:00
giles
15550dd687 Update shared: device cookie auth state detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:57:21 +00:00
giles
ff4dfc7182 Update shared: grant-based session revocation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:30 +00:00
giles
195df18e60 Iframe-based SSO logout (tolerates dead apps)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:21:51 +00:00
giles
ecf183711d Update shared: remove sso_hint, add sso-clear logout chain
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:17:52 +00:00
giles
4e82193a5e Update shared: SSO revocation clears local session on logout
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:16:10 +00:00
giles
ffad7ffea9 Remove auth blueprint, federation is now an OAuth client
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Auth server responsibilities moved to account app.
Federation uses the shared OAuth client blueprint via factory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:00:22 +00:00
giles
f197dcffcb Add /auth/clear to reset stale cookies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:45:34 +00:00
giles
5bf710a5ce Add /auth/sso-logout/ endpoint for cross-app logout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:31:53 +00:00
giles
b81d679af8 Update shared: silent SSO via sso_hint cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:23:42 +00:00
giles
c7618b8a65 Set sso_hint cookie on login, clear on logout
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:23:31 +00:00
giles
f3737b2471 Fix logout redirect to blog home
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:15:35 +00:00
giles
084b1786f1 Fix logout to use local /auth/logout/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:46 +00:00
giles
a4902b2ff4 Sign-in → account, clear old shared cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:57:22 +00:00
giles
09d36b89c1 Trigger rebuild: per-app cookies + OAuth SSO
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:45:24 +00:00
giles
54c73d1740 Fix OAuth authorize URL prefix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:26:05 +00:00
32 changed files with 238 additions and 866 deletions

18
app.py
View File

@@ -2,18 +2,16 @@ from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path from pathlib import Path
from quart import g from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
from shared.services.registry import services from shared.services.registry import services
from bp import ( from bp import (
register_wellknown_bp,
register_actors_bp,
register_identity_bp, register_identity_bp,
register_auth_bp,
register_social_bp, register_social_bp,
register_fragments,
) )
@@ -22,9 +20,15 @@ async def federation_context() -> dict:
from shared.infrastructure.context import base_context from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context() ctx = await base_context()
ctx["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
params={"app_name": "federation", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s) ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data (consistent with all other apps) # Cart data (consistent with all other apps)
@@ -62,11 +66,11 @@ def create_app() -> "Quart":
]) ])
# --- blueprints --- # --- blueprints ---
app.register_blueprint(register_wellknown_bp()) # Well-known + actors (webfinger, inbox, outbox, etc.) are now handled
app.register_blueprint(register_actors_bp()) # by the shared AP blueprint registered in create_base_app().
app.register_blueprint(register_identity_bp()) app.register_blueprint(register_identity_bp())
app.register_blueprint(register_auth_bp())
app.register_blueprint(register_social_bp()) app.register_blueprint(register_social_bp())
app.register_blueprint(register_fragments())
# --- home page --- # --- home page ---
@app.get("/") @app.get("/")

14
blog/models/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag
# Shared models — canonical definitions live in shared/models/
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from shared.models.menu_item import MenuItem
from shared.models.kv import KV
from shared.models.magic_link import MagicLink
from shared.models.user import User

View File

@@ -0,0 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostLike,
)

View File

@@ -0,0 +1,12 @@
# Re-export from canonical shared location
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
__all__ = [
"GhostLabel", "UserLabel",
"GhostNewsletter", "UserNewsletter",
"GhostTier", "GhostSubscription",
]

4
blog/models/kv.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.kv import KV
__all__ = ["KV"]

View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.magic_link import MagicLink
__all__ = ["MagicLink"]

4
blog/models/menu_item.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.menu_item import MenuItem
__all__ = ["MenuItem"]

32
blog/models/snippet.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Snippet(Base):
__tablename__ = "snippets"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
Index("ix_snippets_visibility", "visibility"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="private", server_default="private",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)

52
blog/models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from shared.db.base import Base
class TagGroup(Base):
__tablename__ = "tag_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
colour: Mapped[Optional[str]] = mapped_column(String(32))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
tag_links: Mapped[List["TagGroupTag"]] = relationship(
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
)
class TagGroupTag(Base):
__tablename__ = "tag_group_tags"
__table_args__ = (
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_group_id: Mapped[int] = mapped_column(
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")

4
blog/models/user.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.user import User
__all__ = ["User"]

View File

@@ -1,5 +1,3 @@
from .wellknown.routes import register as register_wellknown_bp
from .actors.routes import register as register_actors_bp
from .identity.routes import register as register_identity_bp from .identity.routes import register as register_identity_bp
from .auth.routes import register as register_auth_bp
from .social.routes import register as register_social_bp from .social.routes import register as register_social_bp
from .fragments import register_fragments

View File

@@ -1,734 +0,0 @@
"""ActivityPub actor endpoints: profiles, outbox, inbox.
Ported from ~/art-dag/activity-pub/app/routers/users.py.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
import httpx
from quart import Blueprint, request, abort, Response, g, render_template
from sqlalchemy import select
from shared.services.registry import services
from shared.models.federation import ActorProfile, APInboxItem
from shared.browser.app.csrf import csrf_exempt
log = logging.getLogger(__name__)
AP_CONTENT_TYPE = "application/activity+json"
def _domain() -> str:
return os.getenv("AP_DOMAIN", "rose-ash.com")
async def _fetch_remote_actor(actor_url: str) -> dict | None:
"""Fetch a remote actor's JSON-LD profile."""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
actor_url,
headers={"Accept": AP_CONTENT_TYPE},
)
if resp.status_code == 200:
return resp.json()
except Exception:
log.exception("Failed to fetch remote actor: %s", actor_url)
return None
async def _send_accept(
actor: ActorProfile,
follow_activity: dict,
follower_inbox: str,
) -> None:
"""Send an Accept activity back to the follower."""
from shared.utils.http_signatures import sign_request
domain = _domain()
username = actor.preferred_username
actor_url = f"https://{domain}/users/{username}"
accept_id = f"{actor_url}/activities/{uuid.uuid4()}"
accept = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": accept_id,
"type": "Accept",
"actor": actor_url,
"object": follow_activity,
}
body_bytes = json.dumps(accept).encode()
key_id = f"{actor_url}#main-key"
from urllib.parse import urlparse
parsed = urlparse(follower_inbox)
headers = sign_request(
private_key_pem=actor.private_key_pem,
key_id=key_id,
method="POST",
path=parsed.path,
host=parsed.netloc,
body=body_bytes,
)
headers["Content-Type"] = AP_CONTENT_TYPE
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
follower_inbox,
content=body_bytes,
headers=headers,
)
log.info("Accept → %s: %d", follower_inbox, resp.status_code)
except Exception:
log.exception("Failed to send Accept to %s", follower_inbox)
async def _backfill_follower(
actor: ActorProfile,
follower_inbox: str,
) -> None:
"""Deliver recent Create activities to a new follower's inbox."""
from shared.events.handlers.ap_delivery_handler import (
_build_activity_json, _deliver_to_inbox,
)
from shared.models.federation import APActivity
domain = _domain()
# Fetch recent Create activities (most recent first, limit 20)
activities = (
await g.s.execute(
select(APActivity).where(
APActivity.actor_profile_id == actor.id,
APActivity.is_local == True, # noqa: E712
APActivity.activity_type == "Create",
)
.order_by(APActivity.published.desc())
.limit(20)
)
).scalars().all()
if not activities:
return
log.info(
"Backfilling %d posts to %s for @%s",
len(activities), follower_inbox, actor.preferred_username,
)
async with httpx.AsyncClient() as client:
for activity in reversed(activities): # oldest first
activity_json = _build_activity_json(activity, actor, domain)
await _deliver_to_inbox(client, follower_inbox, activity_json, actor, domain)
def register(url_prefix="/users"):
bp = Blueprint("actors", __name__, url_prefix=url_prefix)
@bp.get("/<username>")
async def profile(username: str):
actor = await services.federation.get_actor_by_username(g.s, username)
if not actor:
abort(404)
domain = _domain()
accept = request.headers.get("accept", "")
# AP JSON-LD response
if "application/activity+json" in accept or "application/ld+json" in accept:
actor_json = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"type": "Person",
"id": f"https://{domain}/users/{username}",
"name": actor.display_name or username,
"preferredUsername": username,
"summary": actor.summary or "",
"manuallyApprovesFollowers": False,
"inbox": f"https://{domain}/users/{username}/inbox",
"outbox": f"https://{domain}/users/{username}/outbox",
"followers": f"https://{domain}/users/{username}/followers",
"following": f"https://{domain}/users/{username}/following",
"publicKey": {
"id": f"https://{domain}/users/{username}#main-key",
"owner": f"https://{domain}/users/{username}",
"publicKeyPem": actor.public_key_pem,
},
"url": f"https://{domain}/users/{username}",
}
return Response(
response=json.dumps(actor_json),
content_type=AP_CONTENT_TYPE,
)
# HTML profile page
activities, total = await services.federation.get_outbox(
g.s, username, page=1, per_page=20,
)
return await render_template(
"federation/profile.html",
actor=actor,
activities=activities,
total=total,
)
@bp.get("/<username>/outbox")
async def outbox(username: str):
actor = await services.federation.get_actor_by_username(g.s, username)
if not actor:
abort(404)
domain = _domain()
actor_id = f"https://{domain}/users/{username}"
page_param = request.args.get("page")
if not page_param:
_, total = await services.federation.get_outbox(g.s, username, page=1, per_page=1)
return Response(
response=json.dumps({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": f"{actor_id}/outbox",
"totalItems": total,
"first": f"{actor_id}/outbox?page=1",
}),
content_type=AP_CONTENT_TYPE,
)
page_num = int(page_param)
activities, total = await services.federation.get_outbox(
g.s, username, page=page_num, per_page=20,
)
items = []
for a in activities:
items.append({
"@context": "https://www.w3.org/ns/activitystreams",
"type": a.activity_type,
"id": a.activity_id,
"actor": actor_id,
"published": a.published.isoformat() if a.published else None,
"object": {
"type": a.object_type,
**(a.object_data or {}),
},
})
return Response(
response=json.dumps({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": f"{actor_id}/outbox?page={page_num}",
"partOf": f"{actor_id}/outbox",
"totalItems": total,
"orderedItems": items,
}),
content_type=AP_CONTENT_TYPE,
)
@csrf_exempt
@bp.post("/<username>/inbox")
async def inbox(username: str):
actor = await services.federation.get_actor_by_username(g.s, username)
if not actor:
abort(404)
body = await request.get_json()
if not body:
abort(400, "Invalid JSON")
activity_type = body.get("type", "")
from_actor_url = body.get("actor", "")
# Verify HTTP signature (best-effort — log but don't block yet
# while we validate our implementation against real servers)
sig_valid = False
try:
from shared.utils.http_signatures import verify_request_signature
req_headers = dict(request.headers)
sig_header = req_headers.get("Signature", "")
# Fetch remote actor to get their public key
remote_actor = await _fetch_remote_actor(from_actor_url)
if remote_actor and sig_header:
pub_key_pem = (remote_actor.get("publicKey") or {}).get("publicKeyPem")
if pub_key_pem:
sig_valid = verify_request_signature(
public_key_pem=pub_key_pem,
signature_header=sig_header,
method="POST",
path=f"/users/{username}/inbox",
headers=req_headers,
)
except Exception:
log.debug("Signature verification failed for %s", from_actor_url, exc_info=True)
if not sig_valid:
log.warning(
"Unverified inbox POST from %s (%s) — accepting anyway for now",
from_actor_url, activity_type,
)
# Load actor row for DB operations
actor_row = (
await g.s.execute(
select(ActorProfile).where(
ActorProfile.preferred_username == username
)
)
).scalar_one()
# Store raw inbox item
item = APInboxItem(
actor_profile_id=actor_row.id,
raw_json=body,
activity_type=activity_type,
from_actor=from_actor_url,
)
g.s.add(item)
await g.s.flush()
# Handle activity types inline
if activity_type == "Follow":
await _handle_follow(actor_row, body, from_actor_url)
elif activity_type == "Undo":
await _handle_undo(actor_row, body, from_actor_url)
elif activity_type == "Accept":
await _handle_accept(actor_row, body, from_actor_url)
elif activity_type == "Create":
await _handle_create(actor_row, body, from_actor_url)
elif activity_type == "Update":
await _handle_update(actor_row, body, from_actor_url)
elif activity_type == "Delete":
await _handle_delete(actor_row, body, from_actor_url)
elif activity_type == "Like":
await _handle_like(actor_row, body, from_actor_url)
elif activity_type == "Announce":
await _handle_announce(actor_row, body, from_actor_url)
# Mark as processed
item.state = "processed"
from datetime import datetime, timezone
item.processed_at = datetime.now(timezone.utc)
await g.s.flush()
return Response(status=202)
async def _handle_follow(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process a Follow activity: add follower, send Accept."""
remote_actor = await _fetch_remote_actor(from_actor_url)
if not remote_actor:
log.warning("Could not fetch remote actor for Follow: %s", from_actor_url)
return
follower_inbox = remote_actor.get("inbox")
if not follower_inbox:
log.warning("Remote actor has no inbox: %s", from_actor_url)
return
# Derive acct from preferredUsername@domain
remote_username = remote_actor.get("preferredUsername", "")
from urllib.parse import urlparse
remote_domain = urlparse(from_actor_url).netloc
follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url
pub_key = (remote_actor.get("publicKey") or {}).get("publicKeyPem")
# Add follower via service
await services.federation.add_follower(
g.s,
actor_row.preferred_username,
follower_acct=follower_acct,
follower_inbox=follower_inbox,
follower_actor_url=from_actor_url,
follower_public_key=pub_key,
)
log.info(
"New follower: %s → @%s",
follower_acct, actor_row.preferred_username,
)
# Notification
from shared.models.federation import APNotification, RemoteActor
ra = (
await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)
).scalar_one_or_none()
if not ra:
# Store this remote actor
ra_dto = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if ra_dto:
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
if ra:
notif = APNotification(
actor_profile_id=actor_row.id,
notification_type="follow",
from_remote_actor_id=ra.id,
)
g.s.add(notif)
# Send Accept
await _send_accept(actor_row, body, follower_inbox)
# Backfill: deliver recent posts to new follower's inbox
await _backfill_follower(actor_row, follower_inbox)
async def _handle_undo(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process an Undo activity (typically Undo Follow)."""
inner = body.get("object")
if not inner:
return
inner_type = inner.get("type") if isinstance(inner, dict) else None
if inner_type == "Follow":
# Derive acct
from urllib.parse import urlparse
remote_domain = urlparse(from_actor_url).netloc
remote_actor = await _fetch_remote_actor(from_actor_url)
remote_username = ""
if remote_actor:
remote_username = remote_actor.get("preferredUsername", "")
follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url
removed = await services.federation.remove_follower(
g.s, actor_row.preferred_username, follower_acct,
)
if removed:
log.info("Unfollowed: %s → @%s", follower_acct, actor_row.preferred_username)
else:
log.debug("Undo Follow: follower not found: %s", follower_acct)
else:
log.debug("Undo for %s — not handled", inner_type)
async def _handle_accept(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Accept activity — update outbound follow state."""
inner = body.get("object")
if not inner:
return
inner_type = inner.get("type") if isinstance(inner, dict) else None
if inner_type == "Follow":
await services.federation.accept_follow_response(
g.s, actor_row.preferred_username, from_actor_url,
)
log.info("Follow accepted by %s for @%s", from_actor_url, actor_row.preferred_username)
async def _handle_create(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Create(Note/Article) — ingest remote post."""
obj = body.get("object")
if not obj or not isinstance(obj, dict):
return
obj_type = obj.get("type", "")
if obj_type not in ("Note", "Article"):
log.debug("Create with type %s — skipping", obj_type)
return
# Get or create remote actor
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if not remote:
log.warning("Could not resolve remote actor for Create: %s", from_actor_url)
return
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
log.info("Ingested %s from %s", obj_type, from_actor_url)
# Create notification if mentions a local actor
from shared.models.federation import APNotification, APRemotePost, RemoteActor
tags = obj.get("tag", [])
if isinstance(tags, list):
domain = _domain()
for tag in tags:
if not isinstance(tag, dict):
continue
if tag.get("type") != "Mention":
continue
href = tag.get("href", "")
if f"https://{domain}/users/" in href:
mentioned_username = href.rsplit("/", 1)[-1]
mentioned = await services.federation.get_actor_by_username(
g.s, mentioned_username,
)
if mentioned:
# Find the remote post we just created
rp = (await g.s.execute(
select(APRemotePost).where(
APRemotePost.object_id == obj.get("id")
)
)).scalar_one_or_none()
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
notif = APNotification(
actor_profile_id=mentioned.id,
notification_type="mention",
from_remote_actor_id=ra.id if ra else None,
target_remote_post_id=rp.id if rp else None,
)
g.s.add(notif)
# Also check if it's a reply to one of our posts
in_reply_to = obj.get("inReplyTo")
if in_reply_to and f"https://{domain}/users/" in str(in_reply_to):
# It's a reply to one of our local posts
from shared.models.federation import APActivity
local_activity = (await g.s.execute(
select(APActivity).where(
APActivity.activity_id == in_reply_to,
)
)).scalar_one_or_none()
if local_activity:
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
rp = (await g.s.execute(
select(APRemotePost).where(
APRemotePost.object_id == obj.get("id")
)
)).scalar_one_or_none()
notif = APNotification(
actor_profile_id=local_activity.actor_profile_id,
notification_type="reply",
from_remote_actor_id=ra.id if ra else None,
target_remote_post_id=rp.id if rp else None,
)
g.s.add(notif)
async def _handle_update(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Update — re-ingest remote post."""
obj = body.get("object")
if not obj or not isinstance(obj, dict):
return
obj_type = obj.get("type", "")
if obj_type in ("Note", "Article"):
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if remote:
await services.federation.ingest_remote_post(g.s, remote.id, body, obj)
log.info("Updated %s from %s", obj_type, from_actor_url)
async def _handle_delete(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process Delete — remove remote post."""
obj = body.get("object")
if isinstance(obj, str):
object_id = obj
elif isinstance(obj, dict):
object_id = obj.get("id", "")
else:
return
if object_id:
await services.federation.delete_remote_post(g.s, object_id)
log.info("Deleted remote post %s from %s", object_id, from_actor_url)
async def _handle_like(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process incoming Like — record interaction + notify."""
from shared.models.federation import APInteraction, APNotification, RemoteActor
object_id = body.get("object", "")
if isinstance(object_id, dict):
object_id = object_id.get("id", "")
if not object_id:
return
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if not remote:
return
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
# Find the local activity this Like targets
from shared.models.federation import APActivity
target = (await g.s.execute(
select(APActivity).where(APActivity.activity_id == object_id)
)).scalar_one_or_none()
if not target:
# Try matching object_data.id
log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id)
return
# Record interaction
interaction = APInteraction(
remote_actor_id=ra.id if ra else None,
post_type="local",
post_id=target.id,
interaction_type="like",
activity_id=body.get("id"),
)
g.s.add(interaction)
# Notification
notif = APNotification(
actor_profile_id=target.actor_profile_id,
notification_type="like",
from_remote_actor_id=ra.id if ra else None,
target_activity_id=target.id,
)
g.s.add(notif)
log.info("Like from %s on activity %s", from_actor_url, object_id)
async def _handle_announce(
actor_row: ActorProfile,
body: dict,
from_actor_url: str,
) -> None:
"""Process incoming Announce (boost) — record interaction + notify."""
from shared.models.federation import APInteraction, APNotification, RemoteActor
object_id = body.get("object", "")
if isinstance(object_id, dict):
object_id = object_id.get("id", "")
if not object_id:
return
remote = await services.federation.get_or_fetch_remote_actor(g.s, from_actor_url)
if not remote:
return
ra = (await g.s.execute(
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
)).scalar_one_or_none()
from shared.models.federation import APActivity
target = (await g.s.execute(
select(APActivity).where(APActivity.activity_id == object_id)
)).scalar_one_or_none()
if not target:
log.info("Announce from %s for %s (target not found locally)", from_actor_url, object_id)
return
interaction = APInteraction(
remote_actor_id=ra.id if ra else None,
post_type="local",
post_id=target.id,
interaction_type="boost",
activity_id=body.get("id"),
)
g.s.add(interaction)
notif = APNotification(
actor_profile_id=target.actor_profile_id,
notification_type="boost",
from_remote_actor_id=ra.id if ra else None,
target_activity_id=target.id,
)
g.s.add(notif)
log.info("Announce from %s on activity %s", from_actor_url, object_id)
@bp.get("/<username>/followers")
async def followers(username: str):
actor = await services.federation.get_actor_by_username(g.s, username)
if not actor:
abort(404)
domain = _domain()
collection_id = f"https://{domain}/users/{username}/followers"
follower_list = await services.federation.get_followers(g.s, username)
page_param = request.args.get("page")
if not page_param:
return Response(
response=json.dumps({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": len(follower_list),
"first": f"{collection_id}?page=1",
}),
content_type=AP_CONTENT_TYPE,
)
return Response(
response=json.dumps({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": f"{collection_id}?page=1",
"partOf": collection_id,
"totalItems": len(follower_list),
"orderedItems": [f.follower_actor_url for f in follower_list],
}),
content_type=AP_CONTENT_TYPE,
)
@bp.get("/<username>/following")
async def following(username: str):
actor = await services.federation.get_actor_by_username(g.s, username)
if not actor:
abort(404)
domain = _domain()
collection_id = f"https://{domain}/users/{username}/following"
following_list, total = await services.federation.get_following(g.s, username)
page_param = request.args.get("page")
if not page_param:
return Response(
response=json.dumps({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": f"{collection_id}?page=1",
}),
content_type=AP_CONTENT_TYPE,
)
return Response(
response=json.dumps({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": f"{collection_id}?page=1",
"partOf": collection_id,
"totalItems": total,
"orderedItems": [f.actor_url for f in following_list],
}),
content_type=AP_CONTENT_TYPE,
)
return bp

View File

@@ -196,11 +196,37 @@ def register(url_prefix="/auth"):
qsession[SESSION_USER_KEY] = user_id qsession[SESSION_USER_KEY] = user_id
redirect_url = pop_login_redirect_target() redirect_url = pop_login_redirect_target()
return redirect(redirect_url, 303) resp = redirect(redirect_url, 303)
resp.set_cookie(
"sso_hint", "1",
domain=".rose-ash.com", max_age=30 * 24 * 3600,
secure=True, samesite="Lax", httponly=True,
)
return resp
@auth_bp.post("/logout/") @auth_bp.post("/logout/")
async def logout(): async def logout():
qsession.pop(SESSION_USER_KEY, None) qsession.pop(SESSION_USER_KEY, None)
return redirect(federation_url("/")) resp = redirect(federation_url("/"))
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
@auth_bp.get("/clear/")
async def clear():
"""One-time migration helper: clear all session cookies."""
qsession.clear()
resp = redirect(federation_url("/"))
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
@auth_bp.get("/sso-logout/")
async def sso_logout():
"""SSO logout: clear federation session + sso_hint, redirect to blog."""
qsession.pop(SESSION_USER_KEY, None)
from shared.infrastructure.urls import blog_url
resp = redirect(blog_url("/"))
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
return auth_bp return auth_bp

1
bp/fragments/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

34
bp/fragments/routes.py Normal file
View File

@@ -0,0 +1,34 @@
"""Federation app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
bp._fragment_handlers = _handlers
return bp

View File

@@ -37,7 +37,7 @@ def register(url_prefix="/identity"):
# Already has a username? # Already has a username?
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
if actor: if actor:
return redirect(url_for("actors.profile", username=actor.preferred_username)) return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
return await render_template("federation/choose_username.html") return await render_template("federation/choose_username.html")
@@ -49,7 +49,7 @@ def register(url_prefix="/identity"):
# Already has a username? # Already has a username?
existing = await services.federation.get_actor_by_user_id(g.s, g.user.id) existing = await services.federation.get_actor_by_user_id(g.s, g.user.id)
if existing: if existing:
return redirect(url_for("actors.profile", username=existing.preferred_username)) return redirect(url_for("activitypub.actor_profile", username=existing.preferred_username))
form = await request.form form = await request.form
username = (form.get("username") or "").strip().lower() username = (form.get("username") or "").strip().lower()
@@ -84,7 +84,7 @@ def register(url_prefix="/identity"):
next_url = request.args.get("next") next_url = request.args.get("next")
if next_url: if next_url:
return redirect(next_url) return redirect(next_url)
return redirect(url_for("actors.profile", username=actor.preferred_username)) return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
@bp.get("/check-username") @bp.get("/check-username")
async def check_username(): async def check_username():

View File

@@ -1,114 +0,0 @@
"""Well-known federation endpoints: WebFinger, NodeInfo, host-meta.
Ported from ~/art-dag/activity-pub/app/routers/federation.py.
"""
from __future__ import annotations
import os
from quart import Blueprint, request, abort, Response, g
from shared.services.registry import services
def _domain() -> str:
return os.getenv("AP_DOMAIN", "rose-ash.com")
def register(url_prefix=""):
bp = Blueprint("wellknown", __name__, url_prefix=url_prefix)
@bp.get("/.well-known/webfinger")
async def webfinger():
resource = request.args.get("resource", "")
if not resource.startswith("acct:"):
abort(400, "Invalid resource format")
parts = resource[5:].split("@")
if len(parts) != 2:
abort(400, "Invalid resource format")
username, domain = parts
if domain != _domain():
abort(404, "User not on this server")
actor = await services.federation.get_actor_by_username(g.s, username)
if not actor:
abort(404, "User not found")
domain = _domain()
return Response(
response=__import__("json").dumps({
"subject": resource,
"aliases": [f"https://{domain}/users/{username}"],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{domain}/users/{username}",
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": f"https://{domain}/users/{username}",
},
],
}),
content_type="application/jrd+json",
)
@bp.get("/.well-known/nodeinfo")
async def nodeinfo_index():
domain = _domain()
return Response(
response=__import__("json").dumps({
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": f"https://{domain}/nodeinfo/2.0",
}
]
}),
content_type="application/json",
)
@bp.get("/nodeinfo/2.0")
async def nodeinfo():
stats = await services.federation.get_stats(g.s)
return Response(
response=__import__("json").dumps({
"version": "2.0",
"software": {
"name": "rose-ash",
"version": "1.0.0",
},
"protocols": ["activitypub"],
"usage": {
"users": {
"total": stats.get("actors", 0),
"activeMonth": stats.get("actors", 0),
},
"localPosts": stats.get("activities", 0),
},
"openRegistrations": False,
"metadata": {
"nodeName": "Rose Ash",
"nodeDescription": "Cooperative platform with ActivityPub federation",
},
}),
content_type="application/json",
)
@bp.get("/.well-known/host-meta")
async def host_meta():
domain = _domain()
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n'
f' <Link rel="lrdd" type="application/xrd+xml" '
f'template="https://{domain}/.well-known/webfinger?resource={{uri}}"/>\n'
'</XRD>'
)
return Response(response=xml, content_type="application/xrd+xml")
return bp

2
cart/models/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .order import Order, OrderItem
from .page_config import PageConfig

1
cart/models/order.py Normal file
View File

@@ -0,0 +1 @@
from shared.models.order import Order, OrderItem # noqa: F401

View File

@@ -0,0 +1 @@
from shared.models.page_config import PageConfig # noqa: F401

0
events/__init__.py Normal file
View File

View File

@@ -0,0 +1,4 @@
from .calendars import (
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

View File

@@ -0,0 +1,4 @@
from shared.models.calendars import ( # noqa: F401
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

0
market/__init__.py Normal file
View File

View File

@@ -0,0 +1,8 @@
from .market import (
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)
from .market_place import MarketPlace

7
market/models/market.py Normal file
View File

@@ -0,0 +1,7 @@
from shared.models.market import ( # noqa: F401
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)

View File

@@ -0,0 +1 @@
from shared.models.market_place import MarketPlace # noqa: F401

2
shared

Submodule shared updated: 46f44f6171...9ab4b7b3fe

View File

@@ -34,7 +34,7 @@
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML" <span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML"
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span> class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>
</a> </a>
<a href="{{ url_for('actors.profile', username=actor.preferred_username) }}" <a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="px-2 py-1 rounded hover:bg-stone-200"> class="px-2 py-1 rounded hover:bg-stone-200">
@{{ actor.preferred_username }} @{{ actor.preferred_username }}
</a> </a>

View File

@@ -9,7 +9,7 @@
{% if actor %} {% if actor %}
<p class="mt-2"><strong>Username:</strong> @{{ actor.preferred_username }}</p> <p class="mt-2"><strong>Username:</strong> @{{ actor.preferred_username }}</p>
<p class="mt-1"> <p class="mt-1">
<a href="{{ url_for('actors.profile', username=actor.preferred_username) }}" <a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="text-blue-600 hover:underline"> class="text-blue-600 hover:underline">
View profile View profile
</a> </a>