chore: update css, html, py files

This commit is contained in:
retoor 2026-01-31 16:54:47 +01:00
parent 2b3cc49d65
commit a99e81aae0
46 changed files with 3810 additions and 2 deletions

View File

@ -33,6 +33,14 @@
## Version 1.34.0 - 2026-01-31
update css, html, py files
**Changes:** 44 files, 3802 lines
**Languages:** CSS (712 lines), HTML (1300 lines), Python (1790 lines)
## Version 1.33.0 - 2026-01-24

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.33.0"
version = "1.34.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"

View File

@ -79,6 +79,29 @@ from snek.view.settings.profile_pages import (
)
from snek.view.profile_page import ProfilePageView
from snek.view.stats import StatsView
from snek.view.admin import (
AdminIndexView,
AdminUsersIndexView,
AdminUserEditView,
AdminUserBanView,
AdminChannelsIndexView,
AdminChannelMembersView,
AdminChannelEditView,
AdminChannelClearView,
AdminChannelDeleteView,
AdminMessagesIndexView,
AdminForumsIndexView,
AdminThreadsView,
AdminPostsView,
AdminFilesIndexView,
AdminDriveItemsView,
AdminRepositoriesView,
AdminNotificationsIndexView,
AdminNotificationCreateView,
AdminSystemIndexView,
AdminKVView,
AdminPushView,
)
from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView
from snek.view.upload import UploadView
@ -370,6 +393,27 @@ class Application(BaseApplication):
self.router.add_view(
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
)
self.router.add_view("/admin/index.html", AdminIndexView)
self.router.add_view("/admin/users/index.html", AdminUsersIndexView)
self.router.add_view("/admin/users/{user_uid}/edit.html", AdminUserEditView)
self.router.add_view("/admin/users/{user_uid}/ban.html", AdminUserBanView)
self.router.add_view("/admin/channels/index.html", AdminChannelsIndexView)
self.router.add_view("/admin/channels/{channel_uid}/members.html", AdminChannelMembersView)
self.router.add_view("/admin/channels/{channel_uid}/edit.html", AdminChannelEditView)
self.router.add_view("/admin/channels/{channel_uid}/clear.html", AdminChannelClearView)
self.router.add_view("/admin/channels/{channel_uid}/delete.html", AdminChannelDeleteView)
self.router.add_view("/admin/messages/index.html", AdminMessagesIndexView)
self.router.add_view("/admin/forums/index.html", AdminForumsIndexView)
self.router.add_view("/admin/forums/{forum_uid}/threads.html", AdminThreadsView)
self.router.add_view("/admin/forums/threads/{thread_uid}/posts.html", AdminPostsView)
self.router.add_view("/admin/files/index.html", AdminFilesIndexView)
self.router.add_view("/admin/files/{drive_uid}/items.html", AdminDriveItemsView)
self.router.add_view("/admin/files/repositories.html", AdminRepositoriesView)
self.router.add_view("/admin/notifications/index.html", AdminNotificationsIndexView)
self.router.add_view("/admin/notifications/create.html", AdminNotificationCreateView)
self.router.add_view("/admin/system/index.html", AdminSystemIndexView)
self.router.add_view("/admin/system/kv.html", AdminKVView)
self.router.add_view("/admin/system/push.html", AdminPushView)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)

View File

@ -2,6 +2,7 @@
import functools
from snek.service.admin import AdminService
from snek.service.channel import ChannelService
from snek.service.channel_attachment import ChannelAttachmentService
from snek.service.channel_member import ChannelMemberService
@ -68,4 +69,5 @@ register_service("post", PostService)
register_service("post_like", PostLikeService)
register_service("profile_page", ProfilePageService)
register_service("mention", MentionService)
register_service("admin", AdminService)

577
src/snek/service/admin.py Normal file
View File

@ -0,0 +1,577 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime
from typing import Any
from snek.system.service import BaseService
logger = logging.getLogger(__name__)
class AdminService(BaseService):
mapper_name = None
async def _require_admin(self, user_uid: str) -> bool:
user = await self.services.user.get(uid=user_uid)
if not user or not user["is_admin"]:
raise PermissionError("Admin access required")
return True
async def get_dashboard_stats(self) -> dict[str, Any]:
logger.info("Fetching dashboard statistics")
user_count = await self.services.user.count(deleted_at=None)
channel_count = await self.services.channel.count(deleted_at=None)
message_count = await self.services.channel_message.count(deleted_at=None)
forum_count = await self.services.forum.count(deleted_at=None)
thread_count = await self.services.thread.count(deleted_at=None)
post_count = await self.services.post.count(deleted_at=None)
connected_users = await self.services.socket.get_connected_users()
online_count = len(connected_users)
drive_count = await self.services.drive.count(deleted_at=None)
repository_count = await self.services.repository.count(deleted_at=None)
notification_count = await self.services.notification.count(deleted_at=None)
logger.debug(f"Dashboard stats: users={user_count}, channels={channel_count}, messages={message_count}")
return {
"users": user_count,
"channels": channel_count,
"messages": message_count,
"forums": forum_count,
"threads": thread_count,
"posts": post_count,
"online": online_count,
"drives": drive_count,
"repositories": repository_count,
"notifications": notification_count,
}
async def get_recent_activity(self, limit: int = 20) -> list[dict]:
logger.info(f"Fetching recent activity, limit={limit}")
messages = []
async for message in self.services.channel_message.find(_limit=limit, _order_by="-created_at", deleted_at=None):
user = await self.services.user.get(uid=message["user_uid"])
channel = await self.services.channel.get(uid=message["channel_uid"])
messages.append({
"uid": message["uid"],
"content": message["message"][:100] if message["message"] else "",
"created_at": str(message["created_at"]) if message["created_at"] else None,
"user_nick": user["nick"] if user else "Unknown",
"user_uid": message["user_uid"],
"channel_name": channel["name"] if channel else "Unknown",
"channel_uid": message["channel_uid"],
})
logger.debug(f"Fetched {len(messages)} recent messages")
return messages
async def list_users(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
logger.info(f"Listing users: page={page}, per_page={per_page}, search={search}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
users = []
async for user in self.services.user.find(**filters):
if search and search.lower() not in (user["username"] or "").lower() and search.lower() not in (user["nick"] or "").lower():
continue
users.append({
"uid": user["uid"],
"username": user["username"],
"nick": user["nick"],
"is_admin": user["is_admin"] or False,
"is_banned": user["is_banned"] or False,
"created_at": str(user["created_at"]) if user["created_at"] else None,
"last_online": str(user["last_online"]) if user["last_online"] else None,
"city": user["city"],
"country_short": user["country_short"],
})
total = await self.services.user.count(deleted_at=None)
logger.debug(f"Found {len(users)} users, total={total}")
return {"users": users, "total": total, "page": page, "per_page": per_page}
async def update_user(self, admin_uid: str, target_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating user {target_uid}")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
allowed_fields = ["nick", "is_admin", "is_banned", "color"]
for field in allowed_fields:
if field in updates:
if field == "is_admin" and target_uid == admin_uid:
raise ValueError("Cannot modify own admin status")
user[field] = updates[field]
await self.services.user.save(user)
logger.info(f"User {target_uid} updated by admin {admin_uid}")
return {"success": True}
async def ban_user(self, admin_uid: str, target_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} banning user {target_uid}")
if admin_uid == target_uid:
raise ValueError("Cannot ban yourself")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["is_banned"] = True
await self.services.user.save(user)
logger.info(f"User {target_uid} banned by admin {admin_uid}")
return {"success": True}
async def unban_user(self, admin_uid: str, target_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} unbanning user {target_uid}")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["is_banned"] = False
await self.services.user.save(user)
logger.info(f"User {target_uid} unbanned by admin {admin_uid}")
return {"success": True}
async def reset_password(self, admin_uid: str, target_uid: str, new_password: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} resetting password for user {target_uid}")
if len(new_password) != 6:
raise ValueError("Password must be exactly 6 characters")
user = await self.services.user.get(uid=target_uid)
if not user:
raise ValueError("User not found")
user["password"] = new_password
await self.services.user.save(user)
logger.info(f"Password reset for user {target_uid} by admin {admin_uid}")
return {"success": True}
async def list_channels(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
logger.info(f"Listing channels: page={page}, per_page={per_page}, search={search}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
channels = []
async for channel in self.services.channel.find(**filters):
if search and search.lower() not in (channel["name"] or "").lower():
continue
member_count = await self.services.channel_member.count(channel_uid=channel["uid"], deleted_at=None)
message_count = await self.services.channel_message.count(channel_uid=channel["uid"], deleted_at=None)
channels.append({
"uid": channel["uid"],
"name": channel["name"],
"description": channel["description"] or "",
"tag": channel["tag"],
"is_private": channel["is_private"] or False,
"created_at": str(channel["created_at"]) if channel["created_at"] else None,
"member_count": member_count,
"message_count": message_count,
})
total = await self.services.channel.count(deleted_at=None)
logger.debug(f"Found {len(channels)} channels, total={total}")
return {"channels": channels, "total": total, "page": page, "per_page": per_page}
async def get_channel_members(self, channel_uid: str) -> list[dict]:
logger.info(f"Getting members for channel {channel_uid}")
members = []
async for member in self.services.channel_member.find(channel_uid=channel_uid, deleted_at=None):
user = await self.services.user.get(uid=member["user_uid"])
members.append({
"uid": member["uid"],
"user_uid": member["user_uid"],
"username": user["username"] if user else "Unknown",
"nick": user["nick"] if user else "Unknown",
"is_owner": member["is_owner"] or False,
"is_banned": member["is_banned"] or False,
"joined_at": str(member["created_at"]) if member["created_at"] else None,
})
logger.debug(f"Found {len(members)} members for channel {channel_uid}")
return members
async def update_channel(self, admin_uid: str, channel_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
allowed_fields = ["name", "description", "is_private"]
for field in allowed_fields:
if field in updates:
channel[field] = updates[field]
await self.services.channel.save(channel)
logger.info(f"Channel {channel_uid} updated by admin {admin_uid}")
return {"success": True}
async def delete_channel(self, admin_uid: str, channel_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
if channel["name"] == "public":
raise ValueError("Cannot delete the public channel")
channel["deleted_at"] = datetime.now()
await self.services.channel.save(channel)
logger.info(f"Channel {channel_uid} deleted by admin {admin_uid}")
return {"success": True}
async def clear_channel_history(self, admin_uid: str, channel_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} clearing history for channel {channel_uid}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise ValueError("Channel not found")
deleted_count = 0
async for message in self.services.channel_message.find(channel_uid=channel_uid, deleted_at=None):
message["deleted_at"] = datetime.now()
await self.services.channel_message.save(message)
deleted_count += 1
logger.info(f"Cleared {deleted_count} messages from channel {channel_uid}")
return {"success": True, "deleted_count": deleted_count}
async def search_messages(self, query: str = None, channel_uid: str = None, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Searching messages: query={query}, channel={channel_uid}, user={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
if channel_uid:
filters["channel_uid"] = channel_uid
if user_uid:
filters["user_uid"] = user_uid
messages = []
async for message in self.services.channel_message.find(**filters):
if query and query.lower() not in (message["message"] or "").lower():
continue
user = await self.services.user.get(uid=message["user_uid"])
channel = await self.services.channel.get(uid=message["channel_uid"])
messages.append({
"uid": message["uid"],
"content": message["message"][:200] if message["message"] else "",
"created_at": str(message["created_at"]) if message["created_at"] else None,
"user_nick": user["nick"] if user else "Unknown",
"user_uid": message["user_uid"],
"channel_name": channel["name"] if channel else "Unknown",
"channel_uid": message["channel_uid"],
})
total = await self.services.channel_message.count(deleted_at=None)
logger.debug(f"Found {len(messages)} messages")
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
async def delete_message(self, admin_uid: str, message_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting message {message_uid}")
message = await self.services.channel_message.get(uid=message_uid)
if not message:
raise ValueError("Message not found")
message["deleted_at"] = datetime.now()
await self.services.channel_message.save(message)
logger.info(f"Message {message_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_forums(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing forums: page={page}, per_page={per_page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
forums = []
async for forum in self.services.forum.find(**filters):
thread_count = await self.services.thread.count(forum_uid=forum["uid"], deleted_at=None)
forums.append({
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"] or "",
"is_active": forum["is_active"] if forum["is_active"] is not None else True,
"created_at": str(forum["created_at"]) if forum["created_at"] else None,
"thread_count": thread_count,
})
total = await self.services.forum.count(deleted_at=None)
logger.debug(f"Found {len(forums)} forums, total={total}")
return {"forums": forums, "total": total, "page": page, "per_page": per_page}
async def update_forum(self, admin_uid: str, forum_uid: str, updates: dict) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} updating forum {forum_uid}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
raise ValueError("Forum not found")
allowed_fields = ["name", "description", "is_active"]
for field in allowed_fields:
if field in updates:
forum[field] = updates[field]
await self.services.forum.save(forum)
logger.info(f"Forum {forum_uid} updated by admin {admin_uid}")
return {"success": True}
async def delete_forum(self, admin_uid: str, forum_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting forum {forum_uid}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
raise ValueError("Forum not found")
forum["deleted_at"] = datetime.now()
await self.services.forum.save(forum)
logger.info(f"Forum {forum_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_threads(self, forum_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing threads for forum {forum_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"forum_uid": forum_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
threads = []
async for thread in self.services.thread.find(**filters):
user = await self.services.user.get(uid=thread["created_by_uid"])
post_count = await self.services.post.count(thread_uid=thread["uid"], deleted_at=None)
threads.append({
"uid": thread["uid"],
"title": thread["title"],
"is_pinned": thread["is_pinned"] or False,
"is_locked": thread["is_locked"] or False,
"created_at": str(thread["created_at"]) if thread["created_at"] else None,
"created_by_nick": user["nick"] if user else "Unknown",
"created_by_uid": thread["created_by_uid"],
"post_count": post_count,
})
total = await self.services.thread.count(forum_uid=forum_uid, deleted_at=None)
logger.debug(f"Found {len(threads)} threads, total={total}")
return {"threads": threads, "total": total, "page": page, "per_page": per_page}
async def toggle_thread_pin(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} toggling pin for thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["is_pinned"] = not (thread["is_pinned"] or False)
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} pin toggled to {thread['is_pinned']}")
return {"success": True, "is_pinned": thread["is_pinned"]}
async def toggle_thread_lock(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} toggling lock for thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["is_locked"] = not (thread["is_locked"] or False)
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} lock toggled to {thread['is_locked']}")
return {"success": True, "is_locked": thread["is_locked"]}
async def delete_thread(self, admin_uid: str, thread_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting thread {thread_uid}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
raise ValueError("Thread not found")
thread["deleted_at"] = datetime.now()
await self.services.thread.save(thread)
logger.info(f"Thread {thread_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_posts(self, thread_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing posts for thread {thread_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"thread_uid": thread_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
posts = []
async for post in self.services.post.find(**filters):
user = await self.services.user.get(uid=post["created_by_uid"])
posts.append({
"uid": post["uid"],
"content": post["content"][:200] if post["content"] else "",
"created_at": str(post["created_at"]) if post["created_at"] else None,
"created_by_nick": user["nick"] if user else "Unknown",
"created_by_uid": post["created_by_uid"],
})
total = await self.services.post.count(thread_uid=thread_uid, deleted_at=None)
logger.debug(f"Found {len(posts)} posts, total={total}")
return {"posts": posts, "total": total, "page": page, "per_page": per_page}
async def delete_post(self, admin_uid: str, post_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting post {post_uid}")
post = await self.services.post.get(uid=post_uid)
if not post:
raise ValueError("Post not found")
post["deleted_at"] = datetime.now()
await self.services.post.save(post)
logger.info(f"Post {post_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_drives(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing drives: user_uid={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
if user_uid:
filters["user_uid"] = user_uid
drives = []
async for drive in self.services.drive.find(**filters):
user = await self.services.user.get(uid=drive["user_uid"])
item_count = await self.services.drive_item.count(drive_uid=drive["uid"], deleted_at=None)
drives.append({
"uid": drive["uid"],
"name": drive["name"] or "Drive",
"user_uid": drive["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"created_at": str(drive["created_at"]) if drive["created_at"] else None,
"item_count": item_count,
})
total = await self.services.drive.count(deleted_at=None)
logger.debug(f"Found {len(drives)} drives, total={total}")
return {"drives": drives, "total": total, "page": page, "per_page": per_page}
async def list_drive_items(self, drive_uid: str, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing items for drive {drive_uid}: page={page}")
offset = (page - 1) * per_page
filters = {"drive_uid": drive_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
items = []
async for item in self.services.drive_item.find(**filters):
items.append({
"uid": item["uid"],
"name": item["name"] or "",
"path": item["path"] or "",
"size": item["size"] or 0,
"mime_type": item["mime_type"] or "",
"created_at": str(item["created_at"]) if item["created_at"] else None,
})
total = await self.services.drive_item.count(drive_uid=drive_uid, deleted_at=None)
logger.debug(f"Found {len(items)} items, total={total}")
return {"items": items, "total": total, "page": page, "per_page": per_page}
async def delete_drive_item(self, admin_uid: str, item_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting drive item {item_uid}")
item = await self.services.drive_item.get(uid=item_uid)
if not item:
raise ValueError("Drive item not found")
item["deleted_at"] = datetime.now()
await self.services.drive_item.save(item)
logger.info(f"Drive item {item_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_repositories(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing repositories: user_uid={user_uid}, page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
if user_uid:
filters["user_uid"] = user_uid
repositories = []
async for repo in self.services.repository.find(**filters):
user = await self.services.user.get(uid=repo["user_uid"])
repositories.append({
"uid": repo["uid"],
"name": repo["name"],
"user_uid": repo["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"is_private": repo["is_private"] or False,
"created_at": str(repo["created_at"]) if repo["created_at"] else None,
})
total = await self.services.repository.count(deleted_at=None)
logger.debug(f"Found {len(repositories)} repositories, total={total}")
return {"repositories": repositories, "total": total, "page": page, "per_page": per_page}
async def delete_repository(self, admin_uid: str, repo_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting repository {repo_uid}")
repo = await self.services.repository.get(uid=repo_uid)
if not repo:
raise ValueError("Repository not found")
repo["deleted_at"] = datetime.now()
await self.services.repository.save(repo)
logger.info(f"Repository {repo_uid} deleted by admin {admin_uid}")
return {"success": True}
async def list_notifications(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing notifications: page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
notifications = []
async for notif in self.services.notification.find(**filters):
user = await self.services.user.get(uid=notif["user_uid"])
notifications.append({
"uid": notif["uid"],
"message": (notif["message"] or "")[:100],
"type": notif["type"] or "",
"user_uid": notif["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"is_read": notif["is_read"] or False,
"created_at": str(notif["created_at"]) if notif["created_at"] else None,
})
total = await self.services.notification.count(deleted_at=None)
logger.debug(f"Found {len(notifications)} notifications, total={total}")
return {"notifications": notifications, "total": total, "page": page, "per_page": per_page}
async def mass_notify(self, admin_uid: str, message: str, user_uids: list[str] = None) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} sending mass notification")
if not message or len(message) > 500:
raise ValueError("Message must be between 1 and 500 characters")
sent_count = 0
if user_uids:
target_users = user_uids
else:
target_users = []
async for user in self.services.user.find(deleted_at=None, is_banned=False):
target_users.append(user["uid"])
for user_uid in target_users:
await self.services.notification.create(
user_uid=user_uid,
message=message,
notification_type="admin",
)
sent_count += 1
logger.info(f"Mass notification sent to {sent_count} users by admin {admin_uid}")
return {"success": True, "sent_count": sent_count}
async def get_kv_entries(self) -> list[dict]:
logger.info("Fetching KV store entries")
entries = []
for row in self.app.db.query("SELECT key, value FROM kv ORDER BY key"):
entries.append({"key": row["key"], "value": row["value"]})
logger.debug(f"Found {len(entries)} KV entries")
return entries
async def set_kv_entry(self, admin_uid: str, key: str, value: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} setting KV entry: key={key}")
if not key or len(key) > 255:
raise ValueError("Key must be between 1 and 255 characters")
if len(value) > 10000:
raise ValueError("Value must be less than 10000 characters")
self.app.db["kv"].upsert({"key": key, "value": value}, ["key"])
logger.info(f"KV entry set: key={key}")
return {"success": True}
async def delete_kv_entry(self, admin_uid: str, key: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting KV entry: key={key}")
self.app.db["kv"].delete(key=key)
logger.info(f"KV entry deleted: key={key}")
return {"success": True}
async def list_push_registrations(self, page: int = 1, per_page: int = 20) -> dict:
logger.info(f"Listing push registrations: page={page}")
offset = (page - 1) * per_page
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
registrations = []
async for reg in self.services.push.find(**filters):
user = await self.services.user.get(uid=reg["user_uid"])
registrations.append({
"uid": reg["uid"],
"user_uid": reg["user_uid"],
"user_nick": user["nick"] if user else "Unknown",
"endpoint": (reg["endpoint"] or "")[:50] + "..." if reg["endpoint"] else "",
"created_at": str(reg["created_at"]) if reg["created_at"] else None,
})
total = await self.services.push.count(deleted_at=None)
logger.debug(f"Found {len(registrations)} push registrations, total={total}")
return {"registrations": registrations, "total": total, "page": page, "per_page": per_page}
async def delete_push_registration(self, admin_uid: str, registration_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} deleting push registration {registration_uid}")
reg = await self.services.push.get(uid=registration_uid)
if not reg:
raise ValueError("Push registration not found")
reg["deleted_at"] = datetime.now()
await self.services.push.save(reg)
logger.info(f"Push registration {registration_uid} deleted by admin {admin_uid}")
return {"success": True}
async def run_maintenance(self, admin_uid: str) -> dict:
await self._require_admin(admin_uid)
logger.info(f"Admin {admin_uid} running maintenance")
self.app.db.query("VACUUM")
self.app.db.query("ANALYZE")
logger.info("Maintenance completed: VACUUM and ANALYZE")
return {"success": True, "message": "Database maintenance completed"}

696
src/snek/static/admin.css Normal file
View File

@ -0,0 +1,696 @@
/* retoor <retoor@molodetz.nl> */
html:has(body.admin-page) {
height: auto;
min-height: 100%;
}
body.admin-page {
height: auto;
min-height: 100%;
grid-template-rows: auto auto;
}
body.admin-page main {
overflow: visible;
flex: none;
}
:root {
--admin-bg: #000000;
--admin-bg-secondary: #0f0f0f;
--admin-bg-tertiary: #1a1a1a;
--admin-accent: #f05a28;
--admin-accent-hover: #e04924;
--admin-text: #e6e6e6;
--admin-text-muted: #888;
--admin-text-faded: #aaa;
--admin-border: #333;
--admin-border-light: #444;
--admin-danger: #8b0000;
--admin-danger-hover: #a00000;
--admin-warning: #c9a227;
--admin-success: #2e7d32;
}
.admin-content {
padding: 20px;
max-width: 1400px;
}
.admin-sidebar h2 {
color: var(--admin-accent);
font-size: 0.9em;
margin-bottom: 15px;
}
.admin-sidebar hr {
border: none;
border-top: 1px solid var(--admin-border);
margin: 20px 0;
}
.admin-stats {
margin-bottom: 30px;
}
.admin-stats h2 {
margin-bottom: 20px;
color: var(--admin-accent);
font-size: 1.3em;
}
.admin-stats h3 {
margin-top: 25px;
margin-bottom: 15px;
color: var(--admin-text-muted);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
}
.time-cell {
font-size: 0.85em;
color: var(--admin-text-muted);
white-space: nowrap;
}
.stat-card {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 20px;
text-align: center;
transition: border-color 0.2s ease;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100px;
}
.stat-card:hover {
border-color: var(--admin-accent);
}
.stat-card.stat-highlight {
border-color: var(--admin-accent);
background: linear-gradient(135deg, var(--admin-bg-secondary) 0%, rgba(240, 90, 40, 0.1) 100%);
}
.stat-label {
display: block;
font-size: 0.75em;
color: var(--admin-text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
font-weight: 600;
}
.stat-value {
display: block;
font-size: 2.5em;
font-weight: bold;
color: var(--admin-accent);
line-height: 1;
}
.stat-sub {
display: block;
font-size: 0.8em;
color: var(--admin-success);
margin-top: 8px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.info-item {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 15px;
}
.info-label {
display: block;
font-size: 0.8em;
color: var(--admin-text-muted);
text-transform: uppercase;
margin-bottom: 5px;
}
.info-value {
display: block;
font-size: 1.1em;
color: var(--admin-text);
}
.admin-activity {
margin-bottom: 30px;
}
.admin-activity h2 {
margin-bottom: 15px;
color: var(--admin-accent);
}
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--admin-bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.admin-table thead {
background: var(--admin-bg-tertiary);
}
.admin-table th {
text-align: left;
padding: 12px 15px;
color: var(--admin-text-muted);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--admin-border);
}
.admin-table td {
padding: 12px 15px;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text);
}
.admin-table tbody tr:hover {
background: var(--admin-bg-tertiary);
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.message-preview,
.description-preview,
.value-preview,
.path-preview,
.endpoint-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-banned {
background: rgba(139, 0, 0, 0.1);
}
.row-inactive {
opacity: 0.6;
}
.row-pinned {
background: rgba(240, 90, 40, 0.1);
}
.row-locked {
opacity: 0.7;
}
.row-read {
opacity: 0.6;
}
.actions {
white-space: nowrap;
}
.btn {
display: inline-block;
padding: 8px 16px;
font-family: 'Courier New', monospace;
font-size: 14px;
font-weight: 500;
border-radius: 4px;
border: 1px solid var(--admin-border);
background: var(--admin-bg-tertiary);
color: var(--admin-text);
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}
.btn:hover {
background: #2a2a2a;
border-color: var(--admin-border-light);
color: #fff;
}
.btn-primary {
background: var(--admin-accent);
border-color: var(--admin-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--admin-accent-hover);
border-color: var(--admin-accent-hover);
}
.btn-secondary {
background: var(--admin-bg-tertiary);
border-color: var(--admin-border);
}
.btn-danger {
background: var(--admin-danger);
border-color: var(--admin-danger);
color: #fff;
}
.btn-danger:hover {
background: var(--admin-danger-hover);
border-color: var(--admin-danger-hover);
}
.btn-warning {
background: var(--admin-warning);
border-color: var(--admin-warning);
color: #000;
}
.btn-warning:hover {
background: #d4ab2a;
border-color: #d4ab2a;
}
.btn-small {
padding: 4px 10px;
font-size: 12px;
}
.admin-search {
margin-bottom: 20px;
}
.admin-search form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.search-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
width: 100%;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg-secondary);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: var(--admin-accent);
}
.search-select {
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg-secondary);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-width: 150px;
}
.search-select:focus {
outline: none;
border-color: var(--admin-accent);
}
.admin-pagination {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
padding: 15px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
}
.pagination-info {
color: var(--admin-text-muted);
font-size: 0.9em;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 15px;
}
.pagination-pages {
color: var(--admin-text);
font-weight: 500;
}
.admin-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid var(--admin-border);
}
.admin-header h2 {
color: var(--admin-accent);
margin-bottom: 5px;
}
.admin-header p {
color: var(--admin-text-muted);
margin-bottom: 15px;
}
.admin-form {
max-width: 600px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
}
.admin-form h2 {
color: var(--admin-accent);
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: var(--admin-text-muted);
font-size: 0.9em;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group input[type="color"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 4px;
background: var(--admin-bg);
color: var(--admin-text);
font-family: 'Courier New', monospace;
font-size: 14px;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--admin-accent);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-group small {
display: block;
margin-top: 5px;
color: var(--admin-text-muted);
font-size: 0.85em;
}
.form-group.checkbox label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-transform: none;
}
.form-group.checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--admin-accent);
}
.form-row {
display: flex;
gap: 15px;
align-items: flex-end;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.form-actions-inline {
flex: 0 0 auto;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 25px;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-transform: none;
}
.radio-group input[type="radio"] {
accent-color: var(--admin-accent);
}
.user-checkboxes {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--admin-border);
border-radius: 4px;
padding: 10px;
background: var(--admin-bg);
}
.checkbox-label {
display: block;
padding: 5px 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
margin-right: 10px;
accent-color: var(--admin-accent);
}
.danger-zone {
border-color: var(--admin-danger);
background: rgba(139, 0, 0, 0.05);
}
.danger-zone h2 {
color: var(--admin-danger);
}
.danger-zone p {
color: var(--admin-text-muted);
margin-bottom: 20px;
}
.admin-info {
max-width: 600px;
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
}
.admin-info h3 {
color: var(--admin-accent);
margin-bottom: 15px;
font-size: 1em;
}
.admin-info dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 10px;
}
.admin-info dt {
color: var(--admin-text-muted);
font-size: 0.9em;
}
.admin-info dd {
color: var(--admin-text);
margin: 0;
}
.admin-list {
margin-bottom: 20px;
}
.admin-list h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-actions-section {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
margin-bottom: 20px;
max-width: 600px;
}
.admin-actions-section h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-actions-section p {
color: var(--admin-text-muted);
margin-bottom: 15px;
}
.admin-links {
background: var(--admin-bg-secondary);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 25px;
max-width: 600px;
}
.admin-links h2 {
color: var(--admin-accent);
margin-bottom: 15px;
}
.admin-links ul {
list-style: none;
}
.admin-links li {
margin-bottom: 10px;
}
.admin-links a {
color: var(--admin-accent);
text-decoration: none;
}
.admin-links a:hover {
text-decoration: underline;
}
.alert {
padding: 12px 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.alert-error {
background: rgba(139, 0, 0, 0.2);
border: 1px solid var(--admin-danger);
color: #ff6b6b;
}
.alert-success {
background: rgba(46, 125, 50, 0.2);
border: 1px solid var(--admin-success);
color: #81c784;
}
code {
background: var(--admin-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
@media (max-width: 768px) {
.admin-content {
padding: 15px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-table {
font-size: 0.9em;
}
.admin-table th,
.admin-table td {
padding: 8px 10px;
}
.btn-small {
padding: 3px 8px;
font-size: 11px;
}
.form-row {
flex-direction: column;
}
.search-row {
flex-direction: column;
}
.search-input,
.search-select {
width: 100%;
}
}

View File

@ -43,6 +43,22 @@ body {
height: 100%;
}
html:has(body.profile-page) {
height: auto;
min-height: 100%;
}
body.profile-page {
height: auto;
min-height: 100%;
grid-template-rows: auto auto;
}
body.profile-page main {
overflow: visible;
flex: none;
}
main {
display: flex;
flex: 1;

View File

@ -0,0 +1,18 @@
{% extends "app.html" %}
{% block body_class %}admin-page{% endblock %}
{% block sidebar %}
{% include "admin/sidebar.html" %}
{% endblock %}
{% block head %}
<link rel="stylesheet" href="/admin.css">
{% endblock %}
{% block logo %}
<h1>Admin Panel</h1>
{% endblock %}
{% block main %}
{% endblock main %}

View File

@ -0,0 +1,26 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Clear Channel History</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form danger-zone">
<h2>{{ channel.name }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<p>This action will permanently delete all messages in this channel. This cannot be undone.</p>
<form method="post" action="/admin/channels/{{ channel.uid }}/clear.html">
<div class="form-actions">
<button type="submit" class="btn btn-danger">Clear All Messages</button>
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,26 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Delete Channel</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form danger-zone">
<h2>{{ channel.name }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<p>This action will permanently delete this channel and all its messages. This cannot be undone.</p>
<form method="post" action="/admin/channels/{{ channel.uid }}/delete.html">
<div class="form-actions">
<button type="submit" class="btn btn-danger">Delete Channel</button>
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,53 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Edit Channel</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form">
<h2>{{ channel.name }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/channels/{{ channel.uid }}/edit.html">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ channel.name }}" maxlength="50" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="3" maxlength="500">{{ channel.description or '' }}</textarea>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_private" {% if channel.is_private %}checked{% endif %}>
Private Channel
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
<section class="admin-info">
<h3>Channel Information</h3>
<dl>
<dt>UID</dt>
<dd>{{ channel.uid }}</dd>
<dt>Type</dt>
<dd>{{ channel.tag or 'channel' }}</dd>
<dt>Created</dt>
<dd>{{ channel.created_at }}</dd>
</dl>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,70 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Channels</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/channels/index.html">
<input type="text" name="search" placeholder="Search channels..." value="{{ search }}" class="search-input">
<button type="submit" class="btn btn-primary">Search</button>
{% if search %}
<a href="/admin/channels/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Private</th>
<th>Members</th>
<th>Messages</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for channel in channels %}
<tr>
<td>{{ channel.name }}</td>
<td>{{ channel.tag or 'channel' }}</td>
<td>{% if channel.is_private %}Yes{% else %}No{% endif %}</td>
<td>{{ channel.member_count }}</td>
<td>{{ channel.message_count }}</td>
<td>{{ channel.created_at }}</td>
<td class="actions">
<a href="/admin/channels/{{ channel.uid }}/members.html" class="btn btn-small">Members</a>
<a href="/admin/channels/{{ channel.uid }}/edit.html" class="btn btn-small">Edit</a>
<a href="/admin/channels/{{ channel.uid }}/clear.html" class="btn btn-small btn-warning">Clear</a>
<a href="/admin/channels/{{ channel.uid }}/delete.html" class="btn btn-small btn-danger">Delete</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No channels found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} channels</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/channels/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/channels/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,48 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Channel Members</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ channel.name }}</h2>
<p>{{ channel.description }}</p>
<a href="/admin/channels/index.html" class="btn btn-secondary">Back to Channels</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Nick</th>
<th>Owner</th>
<th>Banned</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr class="{% if member.is_banned %}row-banned{% endif %}">
<td>{{ member.username }}</td>
<td>{{ member.nick }}</td>
<td>{% if member.is_owner %}Yes{% else %}No{% endif %}</td>
<td>{% if member.is_banned %}Yes{% else %}No{% endif %}</td>
<td>{{ member.joined_at }}</td>
<td class="actions">
<a href="/admin/users/{{ member.user_uid }}/edit.html" class="btn btn-small">Edit User</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No members found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,67 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Drives</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/files/index.html">
<select name="user_uid" class="search-select">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Filter</button>
{% if user_uid %}
<a href="/admin/files/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Items</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for drive in drives %}
<tr>
<td>{{ drive.name }}</td>
<td><a href="/admin/users/{{ drive.user_uid }}/edit.html">{{ drive.user_nick }}</a></td>
<td>{{ drive.item_count }}</td>
<td>{{ drive.created_at }}</td>
<td class="actions">
<a href="/admin/files/{{ drive.uid }}/items.html" class="btn btn-small">View Items</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No drives found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/files/index.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/files/index.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,63 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Drive Items</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ drive.name or 'Drive' }}</h2>
<p>Owner: {{ user.nick if user else 'Unknown' }}</p>
<a href="/admin/files/index.html" class="btn btn-secondary">Back to Drives</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Type</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.name }}</td>
<td class="path-preview">{{ item.path }}</td>
<td>{{ item.size }}</td>
<td>{{ item.mime_type }}</td>
<td>{{ item.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/files/{{ drive.uid }}/items.html" style="display:inline;">
<input type="hidden" name="item_uid" value="{{ item.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this item?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No items found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,70 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Repositories</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/files/repositories.html">
<select name="user_uid" class="search-select">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Filter</button>
{% if user_uid %}
<a href="/admin/files/repositories.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Owner</th>
<th>Private</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for repo in repositories %}
<tr>
<td>{{ repo.name }}</td>
<td><a href="/admin/users/{{ repo.user_uid }}/edit.html">{{ repo.user_nick }}</a></td>
<td>{% if repo.is_private %}Yes{% else %}No{% endif %}</td>
<td>{{ repo.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/files/repositories.html?{% if user_uid %}user_uid={{ user_uid }}{% endif %}" style="display:inline;">
<input type="hidden" name="repo_uid" value="{{ repo.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this repository?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No repositories found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/files/repositories.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/files/repositories.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,64 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Forums</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Active</th>
<th>Threads</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for forum in forums %}
<tr class="{% if not forum.is_active %}row-inactive{% endif %}">
<td>{{ forum.name }}</td>
<td class="description-preview">{{ forum.description }}</td>
<td>{% if forum.is_active %}Yes{% else %}No{% endif %}</td>
<td>{{ forum.thread_count }}</td>
<td>{{ forum.created_at }}</td>
<td class="actions">
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-small">Threads</a>
<form method="post" action="/admin/forums/index.html" style="display:inline;">
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
<input type="hidden" name="action" value="toggle_active">
<button type="submit" class="btn btn-small">{% if forum.is_active %}Deactivate{% else %}Activate{% endif %}</button>
</form>
<form method="post" action="/admin/forums/index.html" style="display:inline;">
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this forum?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No forums found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/forums/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/forums/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,60 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Posts</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ thread.title }}</h2>
<p>Forum: {{ forum.name }}</p>
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-secondary">Back to Threads</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Author</th>
<th>Content</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td><a href="/admin/users/{{ post.created_by_uid }}/edit.html">{{ post.created_by_nick }}</a></td>
<td class="message-preview">{{ post.content }}</td>
<td>{{ post.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/forums/threads/{{ thread.uid }}/posts.html" style="display:inline;">
<input type="hidden" name="post_uid" value="{{ post.uid }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this post?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">No posts found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,77 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Threads</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<h2>{{ forum.name }}</h2>
<p>{{ forum.description }}</p>
<a href="/admin/forums/index.html" class="btn btn-secondary">Back to Forums</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Pinned</th>
<th>Locked</th>
<th>Posts</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for thread in threads %}
<tr class="{% if thread.is_pinned %}row-pinned{% endif %} {% if thread.is_locked %}row-locked{% endif %}">
<td>{{ thread.title }}</td>
<td><a href="/admin/users/{{ thread.created_by_uid }}/edit.html">{{ thread.created_by_nick }}</a></td>
<td>{% if thread.is_pinned %}Yes{% else %}No{% endif %}</td>
<td>{% if thread.is_locked %}Yes{% else %}No{% endif %}</td>
<td>{{ thread.post_count }}</td>
<td>{{ thread.created_at }}</td>
<td class="actions">
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html" class="btn btn-small">Posts</a>
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
<input type="hidden" name="action" value="toggle_pin">
<button type="submit" class="btn btn-small">{% if thread.is_pinned %}Unpin{% else %}Pin{% endif %}</button>
</form>
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
<input type="hidden" name="action" value="toggle_lock">
<button type="submit" class="btn btn-small">{% if thread.is_locked %}Unlock{% else %}Lock{% endif %}</button>
</form>
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this thread?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No threads found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,88 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Dashboard</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-stats">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card stat-highlight">
<span class="stat-label">Total Users</span>
<span class="stat-value">{{ stats.users }}</span>
<span class="stat-sub">{{ stats.online }} currently online</span>
</div>
<div class="stat-card">
<span class="stat-label">Channels</span>
<span class="stat-value">{{ stats.channels }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Messages</span>
<span class="stat-value">{{ stats.messages }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Notifications</span>
<span class="stat-value">{{ stats.notifications }}</span>
</div>
</div>
<h3>Forums</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Forums</span>
<span class="stat-value">{{ stats.forums }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Threads</span>
<span class="stat-value">{{ stats.threads }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Posts</span>
<span class="stat-value">{{ stats.posts }}</span>
</div>
</div>
<h3>Storage</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">User Drives</span>
<span class="stat-value">{{ stats.drives }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Git Repositories</span>
<span class="stat-value">{{ stats.repositories }}</span>
</div>
</div>
</section>
<section class="admin-activity">
<h2>Recent Messages</h2>
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Channel</th>
<th>Message</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for item in activity %}
<tr>
<td><a href="/admin/users/{{ item.user_uid }}/edit.html">{{ item.user_nick }}</a></td>
<td><a href="/admin/channels/{{ item.channel_uid }}/edit.html">{{ item.channel_name }}</a></td>
<td class="message-preview">{{ item.content }}</td>
<td class="time-cell">{{ item.created_at }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">No recent activity</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,80 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Messages</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/messages/index.html">
<div class="search-row">
<input type="text" name="query" placeholder="Search messages..." value="{{ query }}" class="search-input">
<select name="channel_uid" class="search-select">
<option value="">All Channels</option>
{% for channel in channels %}
<option value="{{ channel.uid }}" {% if channel.uid == channel_uid %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<select name="user_uid" class="search-select">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Search</button>
{% if query or channel_uid or user_uid %}
<a href="/admin/messages/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</div>
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Channel</th>
<th>Message</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for message in messages %}
<tr>
<td><a href="/admin/users/{{ message.user_uid }}/edit.html">{{ message.user_nick }}</a></td>
<td><a href="/admin/channels/{{ message.channel_uid }}/edit.html">{{ message.channel_name }}</a></td>
<td class="message-preview">{{ message.content }}</td>
<td>{{ message.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/messages/index.html?{{ request.query_string }}" style="display:inline;">
<input type="hidden" name="message_uid" value="{{ message.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this message?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">No messages found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} messages</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/messages/index.html?page={{ page - 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/messages/index.html?page={{ page + 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,65 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Create Notification</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form">
<h2>Mass Notification</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/notifications/create.html">
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" maxlength="500" required placeholder="Enter notification message...">{{ message or '' }}</textarea>
<small>Maximum 500 characters</small>
</div>
<div class="form-group">
<label>Recipients</label>
<div class="radio-group">
<label>
<input type="radio" name="target" value="all" checked>
All Users
</label>
<label>
<input type="radio" name="target" value="selected">
Selected Users
</label>
</div>
</div>
<div class="form-group" id="user-select-group" style="display:none;">
<label>Select Users</label>
<div class="user-checkboxes">
{% for user in users %}
<label class="checkbox-label">
<input type="checkbox" name="user_uids" value="{{ user.uid }}">
{{ user.nick }} ({{ user.username }})
</label>
{% endfor %}
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Send Notification</button>
<a href="/admin/notifications/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
</div>
<script>
document.querySelectorAll('input[name="target"]').forEach(function(radio) {
radio.addEventListener('change', function() {
var userGroup = document.getElementById('user-select-group');
userGroup.style.display = this.value === 'selected' ? 'block' : 'none';
});
});
</script>
{% endblock main %}

View File

@ -0,0 +1,55 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Notifications</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-header">
<a href="/admin/notifications/create.html" class="btn btn-primary">Create Mass Notification</a>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Type</th>
<th>Message</th>
<th>Read</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for notif in notifications %}
<tr class="{% if notif.is_read %}row-read{% endif %}">
<td><a href="/admin/users/{{ notif.user_uid }}/edit.html">{{ notif.user_nick }}</a></td>
<td>{{ notif.type }}</td>
<td class="message-preview">{{ notif.message }}</td>
<td>{% if notif.is_read %}Yes{% else %}No{% endif %}</td>
<td>{{ notif.created_at }}</td>
</tr>
{% else %}
<tr>
<td colspan="5">No notifications found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/notifications/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/notifications/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,20 @@
<aside class="sidebar admin-sidebar" id="channelSidebar">
<h2>Admin</h2>
<ul>
<li><a class="no-select" href="/admin/index.html">Dashboard</a></li>
<li><a class="no-select" href="/admin/users/index.html">Users</a></li>
<li><a class="no-select" href="/admin/channels/index.html">Channels</a></li>
<li><a class="no-select" href="/admin/messages/index.html">Messages</a></li>
<li><a class="no-select" href="/admin/forums/index.html">Forums</a></li>
<li><a class="no-select" href="/admin/files/index.html">Files</a></li>
<li><a class="no-select" href="/admin/files/repositories.html">Repositories</a></li>
<li><a class="no-select" href="/admin/notifications/index.html">Notifications</a></li>
<li><a class="no-select" href="/admin/system/index.html">System</a></li>
<li><a class="no-select" href="/admin/system/kv.html">KV Store</a></li>
<li><a class="no-select" href="/admin/system/push.html">Push</a></li>
</ul>
<hr>
<ul>
<li><a class="no-select" href="/settings/index.html">Back to Settings</a></li>
</ul>
</aside>

View File

@ -0,0 +1,52 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>System</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
<section class="admin-stats">
<h2>System Information</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Uptime</span>
<span class="info-value">{{ uptime }}</span>
</div>
<div class="info-item">
<span class="info-label">Total Users</span>
<span class="info-value">{{ stats.users }}</span>
</div>
<div class="info-item">
<span class="info-label">Online Users</span>
<span class="info-value">{{ stats.online }}</span>
</div>
<div class="info-item">
<span class="info-label">Total Messages</span>
<span class="info-value">{{ stats.messages }}</span>
</div>
</div>
</section>
<section class="admin-actions-section">
<h2>Maintenance</h2>
<form method="post" action="/admin/system/index.html">
<input type="hidden" name="action" value="maintenance">
<p>Run database maintenance (VACUUM and ANALYZE) to optimize performance.</p>
<button type="submit" class="btn btn-primary">Run Maintenance</button>
</form>
</section>
<section class="admin-links">
<h2>Quick Links</h2>
<ul>
<li><a href="/admin/system/kv.html">KV Store Editor</a></li>
<li><a href="/admin/system/push.html">Push Registrations</a></li>
</ul>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,65 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>KV Store</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<section class="admin-form">
<h2>Add / Update Entry</h2>
<form method="post" action="/admin/system/kv.html">
<input type="hidden" name="action" value="set">
<div class="form-row">
<div class="form-group">
<label for="key">Key</label>
<input type="text" id="key" name="key" maxlength="255" required placeholder="key_name">
</div>
<div class="form-group">
<label for="value">Value</label>
<input type="text" id="value" name="value" maxlength="10000" required placeholder="value">
</div>
<div class="form-group form-actions-inline">
<button type="submit" class="btn btn-primary">Set</button>
</div>
</div>
</form>
</section>
<section class="admin-list">
<h2>Current Entries</h2>
<table class="admin-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td><code>{{ entry.key }}</code></td>
<td class="value-preview">{{ entry.value }}</td>
<td class="actions">
<form method="post" action="/admin/system/kv.html" style="display:inline;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="key" value="{{ entry.key }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this entry?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3">No entries found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,53 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Push Registrations</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>User</th>
<th>Endpoint</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for reg in registrations %}
<tr>
<td><a href="/admin/users/{{ reg.user_uid }}/edit.html">{{ reg.user_nick }}</a></td>
<td class="endpoint-preview">{{ reg.endpoint }}</td>
<td>{{ reg.created_at }}</td>
<td class="actions">
<form method="post" action="/admin/system/push.html" style="display:inline;">
<input type="hidden" name="registration_uid" value="{{ reg.uid }}">
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this registration?')">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">No push registrations found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% if total > per_page %}
<section class="admin-pagination">
{% if page > 1 %}
<a href="/admin/system/push.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
{% endif %}
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/system/push.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
{% endif %}
</section>
{% endif %}
</div>
{% endblock main %}

View File

@ -0,0 +1,37 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>{% if target_user.is_banned %}Unban{% else %}Ban{% endif %} User</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form danger-zone">
<h2>{{ target_user.username }} ({{ target_user.nick }})</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if target_user.is_banned %}
<p>This user is currently banned. Unbanning will restore their access to the system.</p>
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
<input type="hidden" name="action" value="unban">
<div class="form-actions">
<button type="submit" class="btn btn-primary">Unban User</button>
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
{% else %}
<p>Banning this user will prevent them from logging in and accessing the system.</p>
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
<input type="hidden" name="action" value="ban">
<div class="form-actions">
<button type="submit" class="btn btn-danger">Ban User</button>
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
{% endif %}
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,64 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Edit User</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-form">
<h2>{{ target_user.username }}</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/users/{{ target_user.uid }}/edit.html">
<div class="form-group">
<label for="nick">Nickname</label>
<input type="text" id="nick" name="nick" value="{{ target_user.nick }}" maxlength="20" required>
</div>
<div class="form-group">
<label for="color">Color</label>
<input type="color" id="color" name="color" value="{{ target_user.color or '#ffffff' }}">
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_admin" {% if target_user.is_admin %}checked{% endif %}>
Administrator
</label>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="is_banned" {% if target_user.is_banned %}checked{% endif %}>
Banned
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
</div>
</form>
</section>
<section class="admin-info">
<h3>User Information</h3>
<dl>
<dt>UID</dt>
<dd>{{ target_user.uid }}</dd>
<dt>Created</dt>
<dd>{{ target_user.created_at }}</dd>
<dt>Last Online</dt>
<dd>{{ target_user.last_online or 'Never' }}</dd>
<dt>Location</dt>
<dd>{{ target_user.city }}{% if target_user.country_long %}, {{ target_user.country_long }}{% endif %}</dd>
<dt>IP</dt>
<dd>{{ target_user.ip or 'Unknown' }}</dd>
</dl>
</section>
</div>
{% endblock main %}

View File

@ -0,0 +1,68 @@
{% extends "admin/base.html" %}
{% block logo %}
<h1>Users</h1>
{% endblock %}
{% block main %}
<div class="admin-content">
<section class="admin-search">
<form method="get" action="/admin/users/index.html">
<input type="text" name="search" placeholder="Search users..." value="{{ search }}" class="search-input">
<button type="submit" class="btn btn-primary">Search</button>
{% if search %}
<a href="/admin/users/index.html" class="btn btn-secondary">Clear</a>
{% endif %}
</form>
</section>
<section class="admin-list">
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Nick</th>
<th>Admin</th>
<th>Banned</th>
<th>Location</th>
<th>Last Online</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{% if user.is_banned %}row-banned{% endif %}">
<td>{{ user.username }}</td>
<td>{{ user.nick }}</td>
<td>{% if user.is_admin %}Yes{% else %}No{% endif %}</td>
<td>{% if user.is_banned %}Yes{% else %}No{% endif %}</td>
<td>{{ user.city }}{% if user.country_short %}, {{ user.country_short }}{% endif %}</td>
<td>{{ user.last_online or 'Never' }}</td>
<td class="actions">
<a href="/admin/users/{{ user.uid }}/edit.html" class="btn btn-small">Edit</a>
<a href="/admin/users/{{ user.uid }}/ban.html" class="btn btn-small btn-danger">{% if user.is_banned %}Unban{% else %}Ban{% endif %}</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">No users found</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="admin-pagination">
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} users</span>
<div class="pagination-controls">
{% if page > 1 %}
<a href="/admin/users/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
{% endif %}
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
{% if page * per_page < total %}
<a href="/admin/users/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
{% endif %}
</div>
</section>
</div>
{% endblock main %}

View File

@ -40,8 +40,9 @@
<link rel="stylesheet" href="/mention-nav.css">
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
{% block head %}{% endblock %}
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
<header>
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
<channel-menu></channel-menu>

View File

@ -1,5 +1,7 @@
{% extends "app.html" %}
{% block body_class %}profile-page{% endblock %}
{% block sidebar %}
<aside class="sidebar" id="channelSidebar">
<h2>Navigation</h2>

View File

@ -5,4 +5,10 @@
<li><a class="no-select" href="/settings/profile_pages/index.html">Profile Pages</a></li>
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
</ul>
{% if user and user.is_admin %}
<h2>Administration</h2>
<ul>
<li><a class="no-select" href="/admin/index.html">Admin Panel</a></li>
</ul>
{% endif %}
</aside>

View File

@ -0,0 +1,34 @@
# retoor <retoor@molodetz.nl>
from snek.view.admin.index import AdminIndexView
from snek.view.admin.users import AdminUsersIndexView, AdminUserEditView, AdminUserBanView
from snek.view.admin.channels import AdminChannelsIndexView, AdminChannelMembersView, AdminChannelEditView, AdminChannelClearView, AdminChannelDeleteView
from snek.view.admin.messages import AdminMessagesIndexView
from snek.view.admin.forums import AdminForumsIndexView, AdminThreadsView, AdminPostsView
from snek.view.admin.files import AdminFilesIndexView, AdminDriveItemsView, AdminRepositoriesView
from snek.view.admin.notifications import AdminNotificationsIndexView, AdminNotificationCreateView
from snek.view.admin.system import AdminSystemIndexView, AdminKVView, AdminPushView
__all__ = [
"AdminIndexView",
"AdminUsersIndexView",
"AdminUserEditView",
"AdminUserBanView",
"AdminChannelsIndexView",
"AdminChannelMembersView",
"AdminChannelEditView",
"AdminChannelClearView",
"AdminChannelDeleteView",
"AdminMessagesIndexView",
"AdminForumsIndexView",
"AdminThreadsView",
"AdminPostsView",
"AdminFilesIndexView",
"AdminDriveItemsView",
"AdminRepositoriesView",
"AdminNotificationsIndexView",
"AdminNotificationCreateView",
"AdminSystemIndexView",
"AdminKVView",
"AdminPushView",
]

View File

@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.system.view import BaseView
logger = logging.getLogger(__name__)
class AdminBaseView(BaseView):
login_required = True
async def _iter(self):
if not self.session.get("logged_in") or not self.session.get("uid"):
logger.warning("Unauthorized admin access attempt: not logged in")
return web.HTTPFound("/login.html")
user = await self.services.user.get(uid=self.session.get("uid"))
if not user or not user["is_admin"]:
logger.warning(f"Forbidden admin access attempt by user {self.session.get('uid')}")
return web.HTTPForbidden(text="Admin access required")
return await super()._iter()

View File

@ -0,0 +1,148 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminChannelsIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin channels list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
search = self.request.query.get("search", "")
result = await self.services.admin.list_channels(page=page, search=search if search else None)
return await self.render_template(
"admin/channels/index.html",
{
"channels": result["channels"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"search": search,
}
)
class AdminChannelMembersView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel members accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
members = await self.services.admin.get_channel_members(channel_uid)
return await self.render_template(
"admin/channels/members.html",
{
"channel": channel,
"members": members,
}
)
class AdminChannelEditView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel edit accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
return await self.render_template(
"admin/channels/edit.html",
{"channel": channel}
)
async def post(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel edit POST for {channel_uid} by {self.session.get('uid')}")
try:
data = await self.request.post()
updates = {}
if "name" in data:
updates["name"] = data["name"]
if "description" in data:
updates["description"] = data["description"]
if "is_private" in data:
updates["is_private"] = data["is_private"] == "on"
await self.services.admin.update_channel(
admin_uid=self.session.get("uid"),
channel_uid=channel_uid,
updates=updates
)
return web.HTTPFound("/admin/channels/index.html")
except ValueError as ex:
logger.warning(f"Channel update error: {ex}")
channel = await self.services.channel.get(uid=channel_uid)
return await self.render_template(
"admin/channels/edit.html",
{"channel": channel, "error": str(ex)}
)
class AdminChannelClearView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel clear accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
return await self.render_template(
"admin/channels/clear.html",
{"channel": channel}
)
async def post(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel clear POST for {channel_uid} by {self.session.get('uid')}")
try:
result = await self.services.admin.clear_channel_history(
admin_uid=self.session.get("uid"),
channel_uid=channel_uid
)
return web.HTTPFound(f"/admin/channels/index.html?cleared={result['deleted_count']}")
except ValueError as ex:
logger.warning(f"Channel clear error: {ex}")
channel = await self.services.channel.get(uid=channel_uid)
return await self.render_template(
"admin/channels/clear.html",
{"channel": channel, "error": str(ex)}
)
class AdminChannelDeleteView(AdminBaseView):
async def get(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel delete accessed for {channel_uid} by {self.session.get('uid')}")
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return web.HTTPNotFound(text="Channel not found")
return await self.render_template(
"admin/channels/delete.html",
{"channel": channel}
)
async def post(self):
channel_uid = self.request.match_info.get("channel_uid")
logger.info(f"Admin channel delete POST for {channel_uid} by {self.session.get('uid')}")
try:
await self.services.admin.delete_channel(
admin_uid=self.session.get("uid"),
channel_uid=channel_uid
)
return web.HTTPFound("/admin/channels/index.html")
except ValueError as ex:
logger.warning(f"Channel delete error: {ex}")
channel = await self.services.channel.get(uid=channel_uid)
return await self.render_template(
"admin/channels/delete.html",
{"channel": channel, "error": str(ex)}
)

View File

@ -0,0 +1,110 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminFilesIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin files list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
user_uid = self.request.query.get("user_uid", "")
result = await self.services.admin.list_drives(
user_uid=user_uid if user_uid else None,
page=page
)
users_result = await self.services.admin.list_users(per_page=100)
return await self.render_template(
"admin/files/index.html",
{
"drives": result["drives"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"user_uid": user_uid,
"users": users_result["users"],
}
)
class AdminDriveItemsView(AdminBaseView):
async def get(self):
drive_uid = self.request.match_info.get("drive_uid")
logger.info(f"Admin drive items accessed for {drive_uid} by {self.session.get('uid')}")
drive = await self.services.drive.get(uid=drive_uid)
if not drive:
return web.HTTPNotFound(text="Drive not found")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_drive_items(drive_uid=drive_uid, page=page)
user = await self.services.user.get(uid=drive["user_uid"])
return await self.render_template(
"admin/files/items.html",
{
"drive": drive,
"user": user,
"items": result["items"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
drive_uid = self.request.match_info.get("drive_uid")
logger.info(f"Admin drive item delete POST for {drive_uid} by {self.session.get('uid')}")
data = await self.request.post()
item_uid = data.get("item_uid")
if item_uid:
try:
await self.services.admin.delete_drive_item(
admin_uid=self.session.get("uid"),
item_uid=item_uid
)
except ValueError as ex:
logger.warning(f"Drive item delete error: {ex}")
return web.HTTPFound(self.request.path_qs)
class AdminRepositoriesView(AdminBaseView):
async def get(self):
logger.info(f"Admin repositories list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
user_uid = self.request.query.get("user_uid", "")
result = await self.services.admin.list_repositories(
user_uid=user_uid if user_uid else None,
page=page
)
users_result = await self.services.admin.list_users(per_page=100)
return await self.render_template(
"admin/files/repositories.html",
{
"repositories": result["repositories"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"user_uid": user_uid,
"users": users_result["users"],
}
)
async def post(self):
logger.info(f"Admin repository delete POST by {self.session.get('uid')}")
data = await self.request.post()
repo_uid = data.get("repo_uid")
if repo_uid:
try:
await self.services.admin.delete_repository(
admin_uid=self.session.get("uid"),
repo_uid=repo_uid
)
except ValueError as ex:
logger.warning(f"Repository delete error: {ex}")
return web.HTTPFound(self.request.path_qs)

View File

@ -0,0 +1,139 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminForumsIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin forums list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_forums(page=page)
return await self.render_template(
"admin/forums/index.html",
{
"forums": result["forums"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
logger.info(f"Admin forum action POST by {self.session.get('uid')}")
data = await self.request.post()
forum_uid = data.get("forum_uid")
action = data.get("action")
if forum_uid and action:
try:
if action == "delete":
await self.services.admin.delete_forum(
admin_uid=self.session.get("uid"),
forum_uid=forum_uid
)
elif action == "toggle_active":
forum = await self.services.forum.get(uid=forum_uid)
if forum:
await self.services.admin.update_forum(
admin_uid=self.session.get("uid"),
forum_uid=forum_uid,
updates={"is_active": not forum.get("is_active", True)}
)
except ValueError as ex:
logger.warning(f"Forum action error: {ex}")
return web.HTTPFound(self.request.path_qs)
class AdminThreadsView(AdminBaseView):
async def get(self):
forum_uid = self.request.match_info.get("forum_uid")
logger.info(f"Admin threads list accessed for forum {forum_uid} by {self.session.get('uid')}")
forum = await self.services.forum.get(uid=forum_uid)
if not forum:
return web.HTTPNotFound(text="Forum not found")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
return await self.render_template(
"admin/forums/threads.html",
{
"forum": forum,
"threads": result["threads"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
forum_uid = self.request.match_info.get("forum_uid")
logger.info(f"Admin thread action POST for forum {forum_uid} by {self.session.get('uid')}")
data = await self.request.post()
thread_uid = data.get("thread_uid")
action = data.get("action")
if thread_uid and action:
try:
if action == "delete":
await self.services.admin.delete_thread(
admin_uid=self.session.get("uid"),
thread_uid=thread_uid
)
elif action == "toggle_pin":
await self.services.admin.toggle_thread_pin(
admin_uid=self.session.get("uid"),
thread_uid=thread_uid
)
elif action == "toggle_lock":
await self.services.admin.toggle_thread_lock(
admin_uid=self.session.get("uid"),
thread_uid=thread_uid
)
except ValueError as ex:
logger.warning(f"Thread action error: {ex}")
return web.HTTPFound(self.request.path_qs)
class AdminPostsView(AdminBaseView):
async def get(self):
thread_uid = self.request.match_info.get("thread_uid")
logger.info(f"Admin posts list accessed for thread {thread_uid} by {self.session.get('uid')}")
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
return web.HTTPNotFound(text="Thread not found")
forum = await self.services.forum.get(uid=thread["forum_uid"])
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_posts(thread_uid=thread_uid, page=page)
return await self.render_template(
"admin/forums/posts.html",
{
"forum": forum,
"thread": thread,
"posts": result["posts"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
thread_uid = self.request.match_info.get("thread_uid")
logger.info(f"Admin post action POST for thread {thread_uid} by {self.session.get('uid')}")
data = await self.request.post()
post_uid = data.get("post_uid")
action = data.get("action")
if post_uid and action == "delete":
try:
await self.services.admin.delete_post(
admin_uid=self.session.get("uid"),
post_uid=post_uid
)
except ValueError as ex:
logger.warning(f"Post delete error: {ex}")
return web.HTTPFound(self.request.path_qs)

View File

@ -0,0 +1,22 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin dashboard accessed by user {self.session.get('uid')}")
stats = await self.services.admin.get_dashboard_stats()
activity = await self.services.admin.get_recent_activity(limit=10)
return await self.render_template(
"admin/index.html",
{
"stats": stats,
"activity": activity,
}
)

View File

@ -0,0 +1,55 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminMessagesIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin messages list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
query = self.request.query.get("query", "")
channel_uid = self.request.query.get("channel_uid", "")
user_uid = self.request.query.get("user_uid", "")
result = await self.services.admin.search_messages(
query=query if query else None,
channel_uid=channel_uid if channel_uid else None,
user_uid=user_uid if user_uid else None,
page=page
)
channels_result = await self.services.admin.list_channels(per_page=100)
users_result = await self.services.admin.list_users(per_page=100)
return await self.render_template(
"admin/messages/index.html",
{
"messages": result["messages"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"query": query,
"channel_uid": channel_uid,
"user_uid": user_uid,
"channels": channels_result["channels"],
"users": users_result["users"],
}
)
async def post(self):
logger.info(f"Admin message delete POST by {self.session.get('uid')}")
data = await self.request.post()
message_uid = data.get("message_uid")
if message_uid:
try:
await self.services.admin.delete_message(
admin_uid=self.session.get("uid"),
message_uid=message_uid
)
except ValueError as ex:
logger.warning(f"Message delete error: {ex}")
return web.HTTPFound(self.request.path_qs)

View File

@ -0,0 +1,58 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminNotificationsIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin notifications list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_notifications(page=page)
return await self.render_template(
"admin/notifications/index.html",
{
"notifications": result["notifications"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
class AdminNotificationCreateView(AdminBaseView):
async def get(self):
logger.info(f"Admin notification create accessed by user {self.session.get('uid')}")
users_result = await self.services.admin.list_users(per_page=200)
return await self.render_template(
"admin/notifications/create.html",
{"users": users_result["users"]}
)
async def post(self):
logger.info(f"Admin notification create POST by {self.session.get('uid')}")
data = await self.request.post()
message = data.get("message", "").strip()
target = data.get("target", "all")
user_uids = data.getall("user_uids", []) if target == "selected" else None
try:
result = await self.services.admin.mass_notify(
admin_uid=self.session.get("uid"),
message=message,
user_uids=user_uids
)
return web.HTTPFound(f"/admin/notifications/index.html?sent={result['sent_count']}")
except ValueError as ex:
logger.warning(f"Mass notify error: {ex}")
users_result = await self.services.admin.list_users(per_page=200)
return await self.render_template(
"admin/notifications/create.html",
{"users": users_result["users"], "error": str(ex), "message": message}
)

View File

@ -0,0 +1,117 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminSystemIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin system index accessed by user {self.session.get('uid')}")
stats = await self.services.admin.get_dashboard_stats()
return await self.render_template(
"admin/system/index.html",
{
"stats": stats,
"uptime": self.app.uptime,
}
)
async def post(self):
logger.info(f"Admin system action POST by {self.session.get('uid')}")
data = await self.request.post()
action = data.get("action")
message = None
if action == "maintenance":
try:
result = await self.services.admin.run_maintenance(
admin_uid=self.session.get("uid")
)
message = result.get("message", "Maintenance completed")
except Exception as ex:
logger.warning(f"Maintenance error: {ex}")
message = f"Error: {ex}"
stats = await self.services.admin.get_dashboard_stats()
return await self.render_template(
"admin/system/index.html",
{
"stats": stats,
"uptime": self.app.uptime,
"message": message,
}
)
class AdminKVView(AdminBaseView):
async def get(self):
logger.info(f"Admin KV store accessed by user {self.session.get('uid')}")
entries = await self.services.admin.get_kv_entries()
return await self.render_template(
"admin/system/kv.html",
{"entries": entries}
)
async def post(self):
logger.info(f"Admin KV store POST by {self.session.get('uid')}")
data = await self.request.post()
action = data.get("action")
try:
if action == "set":
key = data.get("key", "").strip()
value = data.get("value", "").strip()
await self.services.admin.set_kv_entry(
admin_uid=self.session.get("uid"),
key=key,
value=value
)
elif action == "delete":
key = data.get("key", "").strip()
await self.services.admin.delete_kv_entry(
admin_uid=self.session.get("uid"),
key=key
)
except ValueError as ex:
logger.warning(f"KV store error: {ex}")
entries = await self.services.admin.get_kv_entries()
return await self.render_template(
"admin/system/kv.html",
{"entries": entries, "error": str(ex)}
)
return web.HTTPFound("/admin/system/kv.html")
class AdminPushView(AdminBaseView):
async def get(self):
logger.info(f"Admin push registrations accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
result = await self.services.admin.list_push_registrations(page=page)
return await self.render_template(
"admin/system/push.html",
{
"registrations": result["registrations"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
}
)
async def post(self):
logger.info(f"Admin push registration delete POST by {self.session.get('uid')}")
data = await self.request.post()
registration_uid = data.get("registration_uid")
if registration_uid:
try:
await self.services.admin.delete_push_registration(
admin_uid=self.session.get("uid"),
registration_uid=registration_uid
)
except ValueError as ex:
logger.warning(f"Push registration delete error: {ex}")
return web.HTTPFound(self.request.path_qs)

View File

@ -0,0 +1,109 @@
# retoor <retoor@molodetz.nl>
import logging
from aiohttp import web
from snek.view.admin.base import AdminBaseView
logger = logging.getLogger(__name__)
class AdminUsersIndexView(AdminBaseView):
async def get(self):
logger.info(f"Admin users list accessed by user {self.session.get('uid')}")
page = int(self.request.query.get("page", 1))
search = self.request.query.get("search", "")
result = await self.services.admin.list_users(page=page, search=search if search else None)
return await self.render_template(
"admin/users/index.html",
{
"users": result["users"],
"total": result["total"],
"page": result["page"],
"per_page": result["per_page"],
"search": search,
}
)
class AdminUserEditView(AdminBaseView):
async def get(self):
user_uid = self.request.match_info.get("user_uid")
logger.info(f"Admin user edit accessed for {user_uid} by {self.session.get('uid')}")
target_user = await self.services.user.get(uid=user_uid)
if not target_user:
return web.HTTPNotFound(text="User not found")
return await self.render_template(
"admin/users/edit.html",
{"target_user": target_user}
)
async def post(self):
user_uid = self.request.match_info.get("user_uid")
logger.info(f"Admin user edit POST for {user_uid} by {self.session.get('uid')}")
try:
data = await self.request.post()
updates = {}
if "nick" in data:
updates["nick"] = data["nick"]
if "is_admin" in data:
updates["is_admin"] = data["is_admin"] == "on"
if "is_banned" in data:
updates["is_banned"] = data["is_banned"] == "on"
if "color" in data:
updates["color"] = data["color"]
await self.services.admin.update_user(
admin_uid=self.session.get("uid"),
target_uid=user_uid,
updates=updates
)
return web.HTTPFound("/admin/users/index.html")
except ValueError as ex:
logger.warning(f"User update error: {ex}")
target_user = await self.services.user.get(uid=user_uid)
return await self.render_template(
"admin/users/edit.html",
{"target_user": target_user, "error": str(ex)}
)
class AdminUserBanView(AdminBaseView):
async def get(self):
user_uid = self.request.match_info.get("user_uid")
logger.info(f"Admin user ban accessed for {user_uid} by {self.session.get('uid')}")
target_user = await self.services.user.get(uid=user_uid)
if not target_user:
return web.HTTPNotFound(text="User not found")
return await self.render_template(
"admin/users/ban.html",
{"target_user": target_user}
)
async def post(self):
user_uid = self.request.match_info.get("user_uid")
data = await self.request.post()
action = data.get("action", "ban")
logger.info(f"Admin user {action} POST for {user_uid} by {self.session.get('uid')}")
try:
if action == "unban":
await self.services.admin.unban_user(
admin_uid=self.session.get("uid"),
target_uid=user_uid
)
else:
await self.services.admin.ban_user(
admin_uid=self.session.get("uid"),
target_uid=user_uid
)
return web.HTTPFound("/admin/users/index.html")
except ValueError as ex:
logger.warning(f"User ban error: {ex}")
target_user = await self.services.user.get(uid=user_uid)
return await self.render_template(
"admin/users/ban.html",
{"target_user": target_user, "error": str(ex)}
)

View File

@ -12,6 +12,7 @@ from snek.system.view import BaseView
from snek.view.rpc.base import BaseAction
from snek.view.rpc.utils import safe_get, safe_str
from snek.view.rpc.actions import (
AdminActions,
AuthActions,
UserActions,
ChannelActions,
@ -27,6 +28,7 @@ logger = logging.getLogger(__name__)
class RPCApi(
BaseAction,
AdminActions,
AuthActions,
UserActions,
ChannelActions,

View File

@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl>
from snek.view.rpc.actions.admin import AdminActions
from snek.view.rpc.actions.auth import AuthActions
from snek.view.rpc.actions.user import UserActions
from snek.view.rpc.actions.channel import ChannelActions
@ -10,6 +11,7 @@ from snek.view.rpc.actions.database import DatabaseActions
from snek.view.rpc.actions.misc import MiscActions
__all__ = [
"AdminActions",
"AuthActions",
"UserActions",
"ChannelActions",

View File

@ -0,0 +1,347 @@
# retoor <retoor@molodetz.nl>
import logging
from snek.view.rpc.utils import safe_str
logger = logging.getLogger(__name__)
class AdminActions:
async def _require_admin(self):
self._require_login()
self._require_services()
user = await self.services.user.get(uid=self.user_uid)
if not user or not user["is_admin"]:
raise PermissionError("Admin access required")
return True
async def admin_get_stats(self):
try:
await self._require_admin()
stats = await self.services.admin.get_dashboard_stats()
return {"success": True, "data": stats}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_stats failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get stats"}
async def admin_get_recent_activity(self, limit=20):
try:
await self._require_admin()
activity = await self.services.admin.get_recent_activity(limit=limit)
return {"success": True, "data": activity}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_recent_activity failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get activity"}
async def admin_list_users(self, page=1, per_page=20, search=None):
try:
await self._require_admin()
result = await self.services.admin.list_users(page=page, per_page=per_page, search=search)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_users failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list users"}
async def admin_update_user(self, target_uid, updates):
try:
await self._require_admin()
result = await self.services.admin.update_user(
admin_uid=self.user_uid,
target_uid=target_uid,
updates=updates
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_update_user failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to update user"}
async def admin_ban_user(self, target_uid):
try:
await self._require_admin()
result = await self.services.admin.ban_user(
admin_uid=self.user_uid,
target_uid=target_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_ban_user failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to ban user"}
async def admin_unban_user(self, target_uid):
try:
await self._require_admin()
result = await self.services.admin.unban_user(
admin_uid=self.user_uid,
target_uid=target_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_unban_user failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to unban user"}
async def admin_list_channels(self, page=1, per_page=20, search=None):
try:
await self._require_admin()
result = await self.services.admin.list_channels(page=page, per_page=per_page, search=search)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_channels failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list channels"}
async def admin_get_channel_members(self, channel_uid):
try:
await self._require_admin()
result = await self.services.admin.get_channel_members(channel_uid)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_channel_members failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get channel members"}
async def admin_update_channel(self, channel_uid, updates):
try:
await self._require_admin()
result = await self.services.admin.update_channel(
admin_uid=self.user_uid,
channel_uid=channel_uid,
updates=updates
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_update_channel failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to update channel"}
async def admin_delete_channel(self, channel_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_channel(
admin_uid=self.user_uid,
channel_uid=channel_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_channel failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete channel"}
async def admin_clear_channel(self, channel_uid):
try:
await self._require_admin()
result = await self.services.admin.clear_channel_history(
admin_uid=self.user_uid,
channel_uid=channel_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_clear_channel failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to clear channel"}
async def admin_search_messages(self, query=None, channel_uid=None, user_uid=None, page=1):
try:
await self._require_admin()
result = await self.services.admin.search_messages(
query=query,
channel_uid=channel_uid,
user_uid=user_uid,
page=page
)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_search_messages failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to search messages"}
async def admin_delete_message(self, message_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_message(
admin_uid=self.user_uid,
message_uid=message_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_message failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete message"}
async def admin_list_forums(self, page=1):
try:
await self._require_admin()
result = await self.services.admin.list_forums(page=page)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_forums failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list forums"}
async def admin_delete_forum(self, forum_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_forum(
admin_uid=self.user_uid,
forum_uid=forum_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_forum failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete forum"}
async def admin_list_threads(self, forum_uid, page=1):
try:
await self._require_admin()
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_list_threads failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to list threads"}
async def admin_toggle_thread_pin(self, thread_uid):
try:
await self._require_admin()
result = await self.services.admin.toggle_thread_pin(
admin_uid=self.user_uid,
thread_uid=thread_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_toggle_thread_pin failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to toggle pin"}
async def admin_toggle_thread_lock(self, thread_uid):
try:
await self._require_admin()
result = await self.services.admin.toggle_thread_lock(
admin_uid=self.user_uid,
thread_uid=thread_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_toggle_thread_lock failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to toggle lock"}
async def admin_delete_thread(self, thread_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_thread(
admin_uid=self.user_uid,
thread_uid=thread_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_thread failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete thread"}
async def admin_delete_post(self, post_uid):
try:
await self._require_admin()
result = await self.services.admin.delete_post(
admin_uid=self.user_uid,
post_uid=post_uid
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_post failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete post"}
async def admin_mass_notify(self, message, user_uids=None):
try:
await self._require_admin()
result = await self.services.admin.mass_notify(
admin_uid=self.user_uid,
message=message,
user_uids=user_uids
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_mass_notify failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to send notifications"}
async def admin_get_kv_entries(self):
try:
await self._require_admin()
result = await self.services.admin.get_kv_entries()
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_get_kv_entries failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to get KV entries"}
async def admin_set_kv_entry(self, key, value):
try:
await self._require_admin()
result = await self.services.admin.set_kv_entry(
admin_uid=self.user_uid,
key=key,
value=value
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_set_kv_entry failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to set KV entry"}
async def admin_delete_kv_entry(self, key):
try:
await self._require_admin()
result = await self.services.admin.delete_kv_entry(
admin_uid=self.user_uid,
key=key
)
return {"success": True, "data": result}
except (PermissionError, ValueError) as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_delete_kv_entry failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to delete KV entry"}
async def admin_run_maintenance(self):
try:
await self._require_admin()
result = await self.services.admin.run_maintenance(admin_uid=self.user_uid)
return {"success": True, "data": result}
except PermissionError as ex:
return {"success": False, "error": safe_str(ex)}
except Exception as ex:
logger.warning(f"admin_run_maintenance failed: {safe_str(ex)}")
return {"success": False, "error": "Failed to run maintenance"}