diff --git a/src/snek/app.py b/src/snek/app.py
index 4bae513..a46137b 100644
--- a/src/snek/app.py
+++ b/src/snek/app.py
@@ -76,6 +76,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView
from snek.view.web import WebView
from snek.webdav import WebdavApplication
+from snek.forum import setup_forum
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@@ -152,7 +153,7 @@ class Application(BaseApplication):
self.ssh_host = "0.0.0.0"
self.ssh_port = 2242
- self.setup_router()
+ self.forum = None
self.ssh_server = None
self.sync_service = None
self.executor = None
@@ -161,6 +162,8 @@ class Application(BaseApplication):
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
+
+ self.setup_router()
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
@@ -333,9 +336,10 @@ class Application(BaseApplication):
)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
+
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
-
+ setup_forum(self)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):
diff --git a/src/snek/forum.py b/src/snek/forum.py
new file mode 100644
index 0000000..a0b0d2d
--- /dev/null
+++ b/src/snek/forum.py
@@ -0,0 +1,115 @@
+# forum_app.py
+import aiohttp.web
+from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
+
+
+class ForumApplication(aiohttp.web.Application):
+ def __init__(self, parent, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.parent = parent
+ self.render_template = self.parent.render_template
+ # Set up routes
+ self.setup_routes()
+
+ # Set up notification listeners
+ self.setup_notifications()
+
+ @property
+ def db(self):
+ return self.parent.db
+
+ @property
+ def services(self):
+ return self.parent.services
+
+ def setup_routes(self):
+ """Set up all forum routes"""
+ # API routes
+ self.router.add_view("/index.html", ForumIndexView)
+ self.router.add_route("GET", "/api/forums", ForumView.get_forums)
+ self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
+ self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread)
+ self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
+ self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post)
+ self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
+ self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
+ self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like)
+ self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin)
+ self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock)
+
+ # WebSocket route
+ self.router.add_view("/ws", ForumWebSocketView)
+
+ # Static HTML route
+ self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
+
+ def setup_notifications(self):
+ """Set up notification listeners for WebSocket broadcasting"""
+ # Forum notifications
+ self.services.forum.add_notification_listener("forum_created", self.on_forum_event)
+
+ # Thread notifications
+ self.services.thread.add_notification_listener("thread_created", self.on_thread_event)
+
+ # Post notifications
+ self.services.post.add_notification_listener("post_created", self.on_post_event)
+ self.services.post.add_notification_listener("post_edited", self.on_post_event)
+ self.services.post.add_notification_listener("post_deleted", self.on_post_event)
+
+ # Like notifications
+ self.services.post_like.add_notification_listener("post_liked", self.on_like_event)
+ self.services.post_like.add_notification_listener("post_unliked", self.on_like_event)
+
+ async def on_forum_event(self, event_type, data):
+ """Handle forum events"""
+ await ForumWebSocketView.broadcast_update(self, event_type, data)
+
+ async def on_thread_event(self, event_type, data):
+ """Handle thread events"""
+ await ForumWebSocketView.broadcast_update(self, event_type, data)
+
+ async def on_post_event(self, event_type, data):
+ """Handle post events"""
+ await ForumWebSocketView.broadcast_update(self, event_type, data)
+
+ async def on_like_event(self, event_type, data):
+ """Handle like events"""
+ await ForumWebSocketView.broadcast_update(self, event_type, data)
+
+ async def serve_forum_html(self, request):
+ """Serve the forum HTML with the web component"""
+ html = """
+
+
+
+
+ Forum
+
+
+
+
+
+
+"""
+ return await self.parent.render_template("forum.html", request)
+
+
+ #return aiohttp.web.Response(text=html, content_type="text/html")
+
+
+# Integration with main app
+def setup_forum(app):
+ """Set up forum sub-application"""
+ forum_app = ForumApplication(app)
+ app.add_subapp("/forum", forum_app)
+ app.forum_app = forum_app
+ # Register models and services if needed
+ # This would typically be done in your main app initialization
+
+ return forum_app
diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py
index 062553a..be01022 100644
--- a/src/snek/mapper/__init__.py
+++ b/src/snek/mapper/__init__.py
@@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
+from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object
@@ -31,9 +32,14 @@ def get_mappers(app=None):
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
"push": PushMapper(app=app),
+ "forum": ForumMapper(app=app),
+ "thread": ThreadMapper(app=app),
+ "post": PostMapper(app=app),
+ "post_like": PostLikeMapper(app=app),
}
)
def get_mapper(name, app=None):
return get_mappers(app=app)[name]
+
diff --git a/src/snek/mapper/forum.py b/src/snek/mapper/forum.py
new file mode 100644
index 0000000..ec258a2
--- /dev/null
+++ b/src/snek/mapper/forum.py
@@ -0,0 +1,23 @@
+# mapper/forum.py
+from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
+from snek.system.mapper import BaseMapper
+
+
+class ForumMapper(BaseMapper):
+ table_name = "forum"
+ model_class = ForumModel
+
+
+class ThreadMapper(BaseMapper):
+ table_name = "thread"
+ model_class = ThreadModel
+
+
+class PostMapper(BaseMapper):
+ table_name = "post"
+ model_class = PostModel
+
+
+class PostLikeMapper(BaseMapper):
+ table_name = "post_like"
+ model_class = PostLikeModel
diff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py
index 187ea4b..13ba5b2 100644
--- a/src/snek/model/__init__.py
+++ b/src/snek/model/__init__.py
@@ -14,6 +14,7 @@ from snek.model.push_registration import PushRegistrationModel
from snek.model.repository import RepositoryModel
from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel
+from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.object import Object
@@ -33,9 +34,15 @@ def get_models():
"channel_attachment": ChannelAttachmentModel,
"container": Container,
"push_registration": PushRegistrationModel,
+ "forum": ForumModel,
+ "thread": ThreadModel,
+ "post": PostModel,
+ "post_like": PostLikeModel,
}
)
def get_model(name):
return get_models()[name]
+
+
diff --git a/src/snek/model/forum.py b/src/snek/model/forum.py
new file mode 100644
index 0000000..59ef23a
--- /dev/null
+++ b/src/snek/model/forum.py
@@ -0,0 +1,96 @@
+# models/forum.py
+from snek.system.model import BaseModel, ModelField
+
+
+class ForumModel(BaseModel):
+ """Forum categories"""
+ name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100)
+ description = ModelField(name="description", required=False, kind=str, max_length=500)
+ slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True)
+ icon = ModelField(name="icon", required=False, kind=str)
+ position = ModelField(name="position", required=True, kind=int, value=0)
+ is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
+ created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
+ thread_count = ModelField(name="thread_count", required=True, kind=int, value=0)
+ post_count = ModelField(name="post_count", required=True, kind=int, value=0)
+ last_post_at = ModelField(name="last_post_at", required=False, kind=str)
+ last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
+
+ async def get_threads(self, limit=50, offset=0):
+ return await self.app.services.thread.find(
+ forum_uid=self["uid"],
+ deleted_at=None,
+ _limit=limit,
+ _offset=offset,
+ _order_by="is_pinned DESC, last_post_at DESC"
+ )
+
+ async def increment_thread_count(self):
+ self["thread_count"] += 1
+ await self.save()
+
+ async def increment_post_count(self):
+ self["post_count"] += 1
+ await self.save()
+
+
+# models/thread.py
+class ThreadModel(BaseModel):
+ """Forum threads"""
+ forum_uid = ModelField(name="forum_uid", required=True, kind=str)
+ title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200)
+ slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
+ created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
+ is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
+ is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False)
+ view_count = ModelField(name="view_count", required=True, kind=int, value=0)
+ post_count = ModelField(name="post_count", required=True, kind=int, value=0)
+ last_post_at = ModelField(name="last_post_at", required=False, kind=str)
+ last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
+
+ async def get_posts(self, limit=50, offset=0):
+ return await self.app.services.post.find(
+ thread_uid=self["uid"],
+ deleted_at=None,
+ _limit=limit,
+ _offset=offset,
+ _order_by="created_at ASC"
+ )
+
+ async def increment_view_count(self):
+ self["view_count"] += 1
+ await self.save()
+
+ async def increment_post_count(self):
+ self["post_count"] += 1
+ self["last_post_at"] = self.app.services.get_timestamp()
+ await self.save()
+
+
+# models/post.py
+class PostModel(BaseModel):
+ """Forum posts"""
+ thread_uid = ModelField(name="thread_uid", required=True, kind=str)
+ forum_uid = ModelField(name="forum_uid", required=True, kind=str)
+ content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000)
+ created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
+ edited_at = ModelField(name="edited_at", required=False, kind=str)
+ edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
+ is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False)
+ like_count = ModelField(name="like_count", required=True, kind=int, value=0)
+
+ async def get_author(self):
+ return await self.app.services.user.get(uid=self["created_by_uid"])
+
+ async def is_liked_by(self, user_uid):
+ return await self.app.services.post_like.exists(
+ post_uid=self["uid"],
+ user_uid=user_uid
+ )
+
+
+# models/post_like.py
+class PostLikeModel(BaseModel):
+ """Post likes"""
+ post_uid = ModelField(name="post_uid", required=True, kind=str)
+ user_uid = ModelField(name="user_uid", required=True, kind=str)
diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py
index 2f6e85b..6e9e58f 100644
--- a/src/snek/service/__init__.py
+++ b/src/snek/service/__init__.py
@@ -17,32 +17,49 @@ from snek.service.user import UserService
from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService
from snek.system.object import Object
-from snek.service.statistics import StatisticsService
+from snek.service.statistics import StatisticsService
+from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
+_service_registry = {}
+
+def register_service(name, service_cls):
+ _service_registry[name] = service_cls
+
+register = register_service
@functools.cache
def get_services(app):
- return Object(
+ result = Object(
**{
- "user": UserService(app=app),
- "channel_member": ChannelMemberService(app=app),
- "channel": ChannelService(app=app),
- "channel_message": ChannelMessageService(app=app),
- "chat": ChatService(app=app),
- "socket": SocketService(app=app),
- "notification": NotificationService(app=app),
- "util": UtilService(app=app),
- "drive": DriveService(app=app),
- "drive_item": DriveItemService(app=app),
- "user_property": UserPropertyService(app=app),
- "repository": RepositoryService(app=app),
- "db": DBService(app=app),
- "channel_attachment": ChannelAttachmentService(app=app),
- "container": ContainerService(app=app),
- "push": PushService(app=app),
- "statistics": StatisticsService(app=app),
+ name: service_cls(app=app)
+ for name, service_cls in _service_registry.items()
}
)
-
+ result.register = register_service
+ return result
def get_service(name, app=None):
return get_services(app=app)[name]
+
+# Registering all services
+register_service("user", UserService)
+register_service("channel_member", ChannelMemberService)
+register_service("channel", ChannelService)
+register_service("channel_message", ChannelMessageService)
+register_service("chat", ChatService)
+register_service("socket", SocketService)
+register_service("notification", NotificationService)
+register_service("util", UtilService)
+register_service("drive", DriveService)
+register_service("drive_item", DriveItemService)
+register_service("user_property", UserPropertyService)
+register_service("repository", RepositoryService)
+register_service("db", DBService)
+register_service("channel_attachment", ChannelAttachmentService)
+register_service("container", ContainerService)
+register_service("push", PushService)
+register_service("statistics", StatisticsService)
+register_service("forum", ForumService)
+register_service("thread", ThreadService)
+register_service("post", PostService)
+register_service("post_like", PostLikeService)
+
diff --git a/src/snek/service/forum.py b/src/snek/service/forum.py
new file mode 100644
index 0000000..6e241c2
--- /dev/null
+++ b/src/snek/service/forum.py
@@ -0,0 +1,293 @@
+# services/forum.py
+from snek.system.service import BaseService
+import re
+
+from collections import defaultdict
+from typing import Any, Awaitable, Callable, Dict, List
+
+EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
+
+
+class BaseForumService(BaseService):
+ """
+ Base mix-in that gives a service `add_notification_listener`
+ and an internal `_dispatch_event` helper.
+ """
+
+ def __init__(self,*args, **kwargs) -> None:
+ # Map event name -> list of listener callables
+ self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
+ super().__init__(*args, **kwargs)
+ def add_notification_listener(
+ self, event_name: str, listener: EventListener
+ ) -> None:
+ """
+ Register a callback to be fired when `event_name` happens.
+
+ Parameters
+ ----------
+ event_name : str
+ The name of the domain event, e.g. "post_created".
+ listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
+ Your handler; can be async or sync.
+ """
+ if not callable(listener):
+ raise TypeError("listener must be callable")
+
+ self._listeners[event_name].append(listener)
+
+ # -----------------------------------------------------------------
+ # The piece below is optional but makes the service fully usable.
+ # Call `_dispatch_event` whenever the service actually performs an
+ # action that should notify listeners.
+ # -----------------------------------------------------------------
+ async def _dispatch_event(self, event_name: str, data: Any) -> None:
+ """Invoke every listener for the given event."""
+ for listener in self._listeners.get(event_name, []):
+ if hasattr(listener, "__await__"): # async function or coro
+ await listener(event_name, data)
+ else: # plain sync function
+ listener(event_name, data)
+
+
+
+
+class ForumService(BaseForumService):
+ mapper_name = "forum"
+
+ async def create_forum(self, name, description, created_by_uid, slug=None, icon=None):
+ if not slug:
+ slug = self.generate_slug(name)
+
+ # Check if slug exists
+ existing = await self.find_one(slug=slug)
+ if existing:
+ slug = f"{slug}-{self.generate_uid()[:8]}"
+
+ model = await self.new()
+ model["name"] = name
+ model["description"] = description
+ model["slug"] = slug
+ model["created_by_uid"] = created_by_uid
+ if icon:
+ model["icon"] = icon
+
+ if await self.save(model):
+ await self.notify("forum_created", model)
+ return model
+ raise Exception(f"Failed to create forum: {model.errors}")
+
+ def generate_slug(self, text):
+ # Convert to lowercase and replace spaces with hyphens
+ slug = text.lower().strip()
+ slug = re.sub(r'[^\w\s-]', '', slug)
+ slug = re.sub(r'[-\s]+', '-', slug)
+ return slug
+
+ async def get_active_forums(self):
+ async for forum in self.find(is_active=True, _order_by="position ASC, created_at ASC"):
+ yield forum
+
+ async def update_last_post(self, forum_uid, thread_uid):
+ forum = await self.get(uid=forum_uid)
+ if forum:
+ forum["last_post_at"] = self.get_timestamp()
+ forum["last_thread_uid"] = thread_uid
+ await self.save(forum)
+
+
+# services/thread.py
+class ThreadService(BaseForumService):
+ mapper_name = "thread"
+
+ async def create_thread(self, forum_uid, title, content, created_by_uid):
+ # Generate slug
+ slug = self.services.forum.generate_slug(title)
+
+ # Check if slug exists in this forum
+ existing = await self.find_one(forum_uid=forum_uid, slug=slug)
+ if existing:
+ slug = f"{slug}-{self.generate_uid()[:8]}"
+
+ # Create thread
+ thread = await self.new()
+ thread["forum_uid"] = forum_uid
+ thread["title"] = title
+ thread["slug"] = slug
+ thread["created_by_uid"] = created_by_uid
+ thread["last_post_at"] = self.get_timestamp()
+ thread["last_post_by_uid"] = created_by_uid
+
+ if await self.save(thread):
+ # Create first post
+ post = await self.services.post.create_post(
+ thread_uid=thread["uid"],
+ forum_uid=forum_uid,
+ content=content,
+ created_by_uid=created_by_uid,
+ is_first_post=True
+ )
+
+ # Update forum counters
+ forum = await self.services.forum.get(uid=forum_uid)
+ await forum.increment_thread_count()
+ await self.services.forum.update_last_post(forum_uid, thread["uid"])
+
+ await self.notify("thread_created", {
+ "thread": thread,
+ "forum_uid": forum_uid
+ })
+
+ return thread, post
+ raise Exception(f"Failed to create thread: {thread.errors}")
+
+ async def toggle_pin(self, thread_uid, user_uid):
+ thread = await self.get(uid=thread_uid)
+ if not thread:
+ return None
+
+ # Check if user is admin
+ user = await self.services.user.get(uid=user_uid)
+ if not user.get("is_admin"):
+ return None
+
+ thread["is_pinned"] = not thread["is_pinned"]
+ await self.save(thread)
+ return thread
+
+ async def toggle_lock(self, thread_uid, user_uid):
+ thread = await self.get(uid=thread_uid)
+ if not thread:
+ return None
+
+ # Check if user is admin or thread creator
+ user = await self.services.user.get(uid=user_uid)
+ if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
+ return None
+
+ thread["is_locked"] = not thread["is_locked"]
+ await self.save(thread)
+ return thread
+
+
+# services/post.py
+class PostService(BaseForumService):
+ mapper_name = "post"
+
+ async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False):
+ # Check if thread is locked
+ thread = await self.services.thread.get(uid=thread_uid)
+ if thread["is_locked"] and not is_first_post:
+ raise Exception("Thread is locked")
+
+ post = await self.new()
+ post["thread_uid"] = thread_uid
+ post["forum_uid"] = forum_uid
+ post["content"] = content
+ post["created_by_uid"] = created_by_uid
+ post["is_first_post"] = is_first_post
+
+ if await self.save(post):
+ # Update thread counters
+ if not is_first_post:
+ thread["post_count"] += 1
+ thread["last_post_at"] = self.get_timestamp()
+ thread["last_post_by_uid"] = created_by_uid
+ await self.services.thread.save(thread)
+
+ # Update forum counters
+ forum = await self.services.forum.get(uid=forum_uid)
+ await forum.increment_post_count()
+ await self.services.forum.update_last_post(forum_uid, thread_uid)
+
+ await self.notify("post_created", {
+ "post": post,
+ "thread_uid": thread_uid,
+ "forum_uid": forum_uid
+ })
+
+ return post
+ raise Exception(f"Failed to create post: {post.errors}")
+
+ async def edit_post(self, post_uid, content, user_uid):
+ post = await self.get(uid=post_uid)
+ if not post:
+ return None
+
+ # Check permissions
+ user = await self.services.user.get(uid=user_uid)
+ if post["created_by_uid"] != user_uid and not user.get("is_admin"):
+ return None
+
+ post["content"] = content
+ post["edited_at"] = self.get_timestamp()
+ post["edited_by_uid"] = user_uid
+
+ if await self.save(post):
+ await self.notify("post_edited", post)
+ return post
+ return None
+
+ async def delete_post(self, post_uid, user_uid):
+ post = await self.get(uid=post_uid)
+ if not post:
+ return False
+
+ # Check permissions
+ user = await self.services.user.get(uid=user_uid)
+ if post["created_by_uid"] != user_uid and not user.get("is_admin"):
+ return False
+
+ # Don't allow deleting first post
+ if post["is_first_post"]:
+ return False
+
+ post["deleted_at"] = self.get_timestamp()
+ if await self.save(post):
+ await self.notify("post_deleted", post)
+ return True
+ return False
+
+
+# services/post_like.py
+class PostLikeService(BaseForumService):
+ mapper_name = "post_like"
+
+ async def toggle_like(self, post_uid, user_uid):
+ # Check if already liked
+ existing = await self.find_one(post_uid=post_uid, user_uid=user_uid)
+
+ if existing:
+ # Unlike
+ await self.delete(uid=existing["uid"])
+
+ # Update post like count
+ post = await self.services.post.get(uid=post_uid)
+ post["like_count"] = max(0, post["like_count"] - 1)
+ await self.services.post.save(post)
+
+ await self.notify("post_unliked", {
+ "post_uid": post_uid,
+ "user_uid": user_uid
+ })
+
+ return False
+ else:
+ # Like
+ like = await self.new()
+ like["post_uid"] = post_uid
+ like["user_uid"] = user_uid
+
+ if await self.save(like):
+ # Update post like count
+ post = await self.services.post.get(uid=post_uid)
+ post["like_count"] += 1
+ await self.services.post.save(post)
+
+ await self.notify("post_liked", {
+ "post_uid": post_uid,
+ "user_uid": user_uid
+ })
+
+ return True
+ return None
diff --git a/src/snek/templates/forum.html b/src/snek/templates/forum.html
new file mode 100644
index 0000000..4930417
--- /dev/null
+++ b/src/snek/templates/forum.html
@@ -0,0 +1,829 @@
+
+
+
+
+
+ Forum Component
+
+
+
+
+
+
+
diff --git a/src/snek/view/forum.py b/src/snek/view/forum.py
new file mode 100644
index 0000000..fd0c462
--- /dev/null
+++ b/src/snek/view/forum.py
@@ -0,0 +1,533 @@
+# views/forum.py
+from snek.system.view import BaseView
+from aiohttp import web
+import json
+
+
+
+from aiohttp import web
+
+from snek.system.view import BaseView
+
+
+class ForumIndexView(BaseView):
+
+ login_required = True
+
+ async def get(self):
+ if self.login_required and not self.session.get("logged_in"):
+ return web.HTTPFound("/")
+ channel = await self.services.channel.get(
+ uid=self.request.match_info.get("channel")
+ )
+ if not channel:
+ user = await self.services.user.get(
+ uid=self.request.match_info.get("channel")
+ )
+ if user:
+ channel = await self.services.channel.get_dm(
+ self.session.get("uid"), user["uid"]
+ )
+ if channel:
+ return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
+ if not channel:
+ return web.HTTPNotFound()
+
+ channel_member = await self.app.services.channel_member.get(
+ user_uid=self.session.get("uid"), channel_uid=channel["uid"]
+ )
+ if not channel_member:
+ if not channel["is_private"]:
+ channel_member = await self.app.services.channel_member.create(
+ channel_uid=channel["uid"],
+ user_uid=self.session.get("uid"),
+ is_moderator=False,
+ is_read_only=False,
+ is_muted=False,
+ is_banned=False,
+ )
+
+ return web.HTTPNotFound()
+
+ channel_member["new_count"] = 0
+ await self.app.services.channel_member.save(channel_member)
+
+ user = await self.services.user.get(uid=self.session.get("uid"))
+
+ messages = [
+ await self.app.services.channel_message.to_extended_dict(message)
+ for message in await self.app.services.channel_message.offset(
+ channel["uid"]
+ )
+ ]
+ for message in messages:
+ await self.app.services.notification.mark_as_read(
+ self.session.get("uid"), message["uid"]
+ )
+ name = await channel_member.get_name()
+ return await self.render_template(
+ "forum.html",
+ {"name": name, "channel": channel, "user": user, "messages": messages},
+ )
+
+
+
+
+
+class ForumView(BaseView):
+ """REST API endpoints for forum"""
+
+ login_required = True
+
+ async def get_forums(self):
+ request = self
+ self = request.app
+ """GET /forum/api/forums - Get all active forums"""
+ forums = []
+ async for forum in self.services.forum.get_active_forums():
+ forums.append({
+ "uid": forum["uid"],
+ "name": forum["name"],
+ "description": forum["description"],
+ "slug": forum["slug"],
+ "icon": forum["icon"],
+ "thread_count": forum["thread_count"],
+ "post_count": forum["post_count"],
+ "last_post_at": forum["last_post_at"],
+ "last_thread_uid": forum["last_thread_uid"]
+ })
+ return web.json_response({"forums": forums})
+
+ async def get_forum(self):
+ request = self
+ self = request.app
+ """GET /forum/api/forums/:slug - Get forum by slug"""
+ slug = self.request.match_info["slug"]
+ forum = await self.services.forum.find_one(slug=slug, is_active=True)
+
+ if not forum:
+ return web.json_response({"error": "Forum not found"}, status=404)
+
+ # Get threads
+ threads = []
+ page = int(self.request.query.get("page", 1))
+ limit = 50
+ offset = (page - 1) * limit
+
+ async for thread in forum.get_threads(limit=limit, offset=offset):
+ # Get author info
+ author = await self.services.user.get(uid=thread["created_by_uid"])
+ last_post_author = None
+ if thread["last_post_by_uid"]:
+ last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"])
+
+ threads.append({
+ "uid": thread["uid"],
+ "title": thread["title"],
+ "slug": thread["slug"],
+ "is_pinned": thread["is_pinned"],
+ "is_locked": thread["is_locked"],
+ "view_count": thread["view_count"],
+ "post_count": thread["post_count"],
+ "created_at": thread["created_at"],
+ "last_post_at": thread["last_post_at"],
+ "author": {
+ "uid": author["uid"],
+ "username": author["username"],
+ "nick": author["nick"],
+ "color": author["color"]
+ },
+ "last_post_author": {
+ "uid": last_post_author["uid"],
+ "username": last_post_author["username"],
+ "nick": last_post_author["nick"],
+ "color": last_post_author["color"]
+ } if last_post_author else None
+ })
+
+ return web.json_response({
+ "forum": {
+ "uid": forum["uid"],
+ "name": forum["name"],
+ "description": forum["description"],
+ "slug": forum["slug"],
+ "icon": forum["icon"],
+ "thread_count": forum["thread_count"],
+ "post_count": forum["post_count"]
+ },
+ "threads": threads,
+ "page": page,
+ "hasMore": len(threads) == limit
+ })
+
+ async def create_thread(self):
+ request = self
+ self = request.app
+ """POST /forum/api/forums/:slug/threads - Create new thread"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ slug = self.request.match_info["slug"]
+ forum = await self.services.forum.find_one(slug=slug, is_active=True)
+
+ if not forum:
+ return web.json_response({"error": "Forum not found"}, status=404)
+
+ data = await self.request.json()
+ title = data.get("title", "").strip()
+ content = data.get("content", "").strip()
+
+ if not title or not content:
+ return web.json_response({"error": "Title and content required"}, status=400)
+
+ try:
+ thread, post = await self.services.thread.create_thread(
+ forum_uid=forum["uid"],
+ title=title,
+ content=content,
+ created_by_uid=self.request.session["uid"]
+ )
+
+ return web.json_response({
+ "thread": {
+ "uid": thread["uid"],
+ "slug": thread["slug"],
+ "forum_slug": forum["slug"]
+ }
+ })
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=400)
+
+ async def get_thread(self):
+ request = self
+ self = request.app
+ """GET /forum/api/threads/:thread_slug - Get thread with posts"""
+ thread_slug = self.request.match_info["thread_slug"]
+ thread = await self.services.thread.find_one(slug=thread_slug)
+
+ if not thread:
+ return web.json_response({"error": "Thread not found"}, status=404)
+
+ # Increment view count
+ await thread.increment_view_count()
+
+ # Get forum
+ forum = await self.services.forum.get(uid=thread["forum_uid"])
+
+ # Get posts
+ posts = []
+ page = int(self.request.query.get("page", 1))
+ limit = 50
+ offset = (page - 1) * limit
+
+ current_user_uid = self.request.session.get("uid")
+
+ async for post in thread.get_posts(limit=limit, offset=offset):
+ author = await post.get_author()
+ is_liked = False
+ if current_user_uid:
+ is_liked = await post.is_liked_by(current_user_uid)
+
+ posts.append({
+ "uid": post["uid"],
+ "content": post["content"],
+ "created_at": post["created_at"],
+ "edited_at": post["edited_at"],
+ "is_first_post": post["is_first_post"],
+ "like_count": post["like_count"],
+ "is_liked": is_liked,
+ "author": {
+ "uid": author["uid"],
+ "username": author["username"],
+ "nick": author["nick"],
+ "color": author["color"]
+ }
+ })
+
+ # Get thread author
+ thread_author = await self.services.user.get(uid=thread["created_by_uid"])
+
+ return web.json_response({
+ "thread": {
+ "uid": thread["uid"],
+ "title": thread["title"],
+ "slug": thread["slug"],
+ "is_pinned": thread["is_pinned"],
+ "is_locked": thread["is_locked"],
+ "view_count": thread["view_count"],
+ "post_count": thread["post_count"],
+ "created_at": thread["created_at"],
+ "author": {
+ "uid": thread_author["uid"],
+ "username": thread_author["username"],
+ "nick": thread_author["nick"],
+ "color": thread_author["color"]
+ }
+ },
+ "forum": {
+ "uid": forum["uid"],
+ "name": forum["name"],
+ "slug": forum["slug"]
+ },
+ "posts": posts,
+ "page": page,
+ "hasMore": len(posts) == limit
+ })
+
+ async def create_post(self):
+ request = self
+ self = request.app
+ """POST /forum/api/threads/:thread_uid/posts - Create new post"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ thread_uid = self.request.match_info["thread_uid"]
+ thread = await self.services.thread.get(uid=thread_uid)
+
+ if not thread:
+ return web.json_response({"error": "Thread not found"}, status=404)
+
+ data = await self.request.json()
+ content = data.get("content", "").strip()
+
+ if not content:
+ return web.json_response({"error": "Content required"}, status=400)
+
+ try:
+ post = await self.services.post.create_post(
+ thread_uid=thread["uid"],
+ forum_uid=thread["forum_uid"],
+ content=content,
+ created_by_uid=self.request.session["uid"]
+ )
+
+ author = await post.get_author()
+
+ return web.json_response({
+ "post": {
+ "uid": post["uid"],
+ "content": post["content"],
+ "created_at": post["created_at"],
+ "like_count": post["like_count"],
+ "is_liked": False,
+ "author": {
+ "uid": author["uid"],
+ "username": author["username"],
+ "nick": author["nick"],
+ "color": author["color"]
+ }
+ }
+ })
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=400)
+
+ async def edit_post(self):
+ request = self
+ self = request.app
+ """PUT /forum/api/posts/:post_uid - Edit post"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ post_uid = self.request.match_info["post_uid"]
+ data = await self.request.json()
+ content = data.get("content", "").strip()
+
+ if not content:
+ return web.json_response({"error": "Content required"}, status=400)
+
+ post = await self.services.post.edit_post(
+ post_uid=post_uid,
+ content=content,
+ user_uid=self.request.session["uid"]
+ )
+
+ if not post:
+ return web.json_response({"error": "Cannot edit post"}, status=403)
+
+ return web.json_response({
+ "post": {
+ "uid": post["uid"],
+ "content": post["content"],
+ "edited_at": post["edited_at"]
+ }
+ })
+
+ async def delete_post(self):
+ request = self
+ self = request.app
+ """DELETE /forum/api/posts/:post_uid - Delete post"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ post_uid = self.request.match_info["post_uid"]
+
+ success = await self.services.post.delete_post(
+ post_uid=post_uid,
+ user_uid=self.request.session["uid"]
+ )
+
+ if not success:
+ return web.json_response({"error": "Cannot delete post"}, status=403)
+
+ return web.json_response({"success": True})
+
+ async def toggle_like(self):
+ request = self
+ self = request.app
+ """POST /forum/api/posts/:post_uid/like - Toggle post like"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ post_uid = self.request.match_info["post_uid"]
+
+ is_liked = await self.services.post_like.toggle_like(
+ post_uid=post_uid,
+ user_uid=self.request.session["uid"]
+ )
+
+ if is_liked is None:
+ return web.json_response({"error": "Failed to toggle like"}, status=400)
+
+ # Get updated post
+ post = await self.services.post.get(uid=post_uid)
+
+ return web.json_response({
+ "is_liked": is_liked,
+ "like_count": post["like_count"]
+ })
+
+ async def toggle_pin(self):
+ request = self
+ self = request.app
+ """POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ thread_uid = self.request.match_info["thread_uid"]
+
+ thread = await self.services.thread.toggle_pin(
+ thread_uid=thread_uid,
+ user_uid=self.request.session["uid"]
+ )
+
+ if not thread:
+ return web.json_response({"error": "Cannot toggle pin"}, status=403)
+
+ return web.json_response({"is_pinned": thread["is_pinned"]})
+
+ async def toggle_lock(self):
+ request = self
+ self = request.app
+ """POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
+ if not self.request.session.get("logged_in"):
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ thread_uid = self.request.match_info["thread_uid"]
+
+ thread = await self.services.thread.toggle_lock(
+ thread_uid=thread_uid,
+ user_uid=self.request.session["uid"]
+ )
+
+ if not thread:
+ return web.json_response({"error": "Cannot toggle lock"}, status=403)
+
+ return web.json_response({"is_locked": thread["is_locked"]})
+
+
+# views/forum_websocket.py
+class ForumWebSocketView(BaseView):
+ """WebSocket view for real-time forum updates"""
+
+ async def websocket_handler(self):
+ ws = web.WebSocketResponse()
+ await ws.prepare(self.request)
+
+ # Store WebSocket connection
+ ws_id = self.services.generate_uid()
+ if not hasattr(self.app, 'forum_websockets'):
+ self.app.forum_websockets = {}
+
+ user_uid = self.request.session.get("uid")
+ self.app.forum_websockets[ws_id] = {
+ "ws": ws,
+ "user_uid": user_uid,
+ "subscriptions": set()
+ }
+
+ try:
+ async for msg in ws:
+ if msg.type == web.WSMsgType.TEXT:
+ data = json.loads(msg.data)
+ await self.handle_ws_message(ws_id, data)
+ elif msg.type == web.WSMsgType.ERROR:
+ break
+ finally:
+ # Clean up
+ if ws_id in self.app.forum_websockets:
+ del self.app.forum_websockets[ws_id]
+
+ return ws
+
+ async def handle_ws_message(self, ws_id, data):
+ """Handle incoming WebSocket messages"""
+ action = data.get("action")
+
+ if action == "subscribe":
+ # Subscribe to forum/thread updates
+ target_type = data.get("type") # "forum" or "thread"
+ target_id = data.get("id")
+
+ if target_type and target_id:
+ subscription = f"{target_type}:{target_id}"
+ self.app.forum_websockets[ws_id]["subscriptions"].add(subscription)
+
+ # Send confirmation
+ ws = self.app.forum_websockets[ws_id]["ws"]
+ await ws.send_str(json.dumps({
+ "type": "subscribed",
+ "subscription": subscription
+ }))
+
+ elif action == "unsubscribe":
+ target_type = data.get("type")
+ target_id = data.get("id")
+
+ if target_type and target_id:
+ subscription = f"{target_type}:{target_id}"
+ self.app.forum_websockets[ws_id]["subscriptions"].discard(subscription)
+
+ # Send confirmation
+ ws = self.app.forum_websockets[ws_id]["ws"]
+ await ws.send_str(json.dumps({
+ "type": "unsubscribed",
+ "subscription": subscription
+ }))
+
+ @staticmethod
+ async def broadcast_update(app, event_type, data):
+ """Broadcast updates to subscribed WebSocket clients"""
+ if not hasattr(app, 'forum_websockets'):
+ return
+
+ # Determine subscription targets based on event
+ targets = set()
+
+ if event_type in ["thread_created", "post_created", "post_edited", "post_deleted"]:
+ if "forum_uid" in data:
+ targets.add(f"forum:{data['forum_uid']}")
+
+ if event_type in ["post_created", "post_edited", "post_deleted", "post_liked", "post_unliked"]:
+ if "thread_uid" in data:
+ targets.add(f"thread:{data['thread_uid']}")
+
+ # Send to subscribed clients
+ for ws_id, ws_data in app.forum_websockets.items():
+ if ws_data["subscriptions"] & targets:
+ try:
+ await ws_data["ws"].send_str(json.dumps({
+ "type": event_type,
+ "data": data
+ }))
+ except Exception:
+ pass