diff --git a/pyproject.toml b/pyproject.toml
index a41d4bd..f515f83 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,7 +40,8 @@ dependencies = [
"pillow-heif",
"IP2Location",
"bleach",
- "sentry-sdk"
+ "sentry-sdk",
+ "bcrypt"
]
[tool.setuptools.packages.find]
@@ -51,3 +52,10 @@ where = ["src"] # <-- this changed
[project.scripts]
snek = "snek.__main__:main"
+
+[project.optional-dependencies]
+test = [
+ "pytest",
+ "pytest-asyncio",
+ "pytest-aiohttp"
+]
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..dc3bca0
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,17 @@
+[tool:pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+addopts =
+ --strict-markers
+ --strict-config
+ --disable-warnings
+ --tb=short
+ -v
+markers =
+ unit: Unit tests
+ integration: Integration tests
+ slow: Slow running tests
+asyncio_mode = auto
+asyncio_default_fixture_loop_scope = function
\ No newline at end of file
diff --git a/src/snek/app.py b/src/snek/app.py
index 9a95fad..3d033c4 100644
--- a/src/snek/app.py
+++ b/src/snek/app.py
@@ -73,6 +73,13 @@ from snek.view.settings.repositories import (
RepositoriesIndexView,
RepositoriesUpdateView,
)
+from snek.view.settings.profile_pages import (
+ ProfilePagesView,
+ ProfilePageCreateView,
+ ProfilePageEditView,
+ ProfilePageDeleteView,
+)
+from snek.view.profile_page import ProfilePageView
from snek.view.stats import StatsView
from snek.view.status import StatusView
from snek.view.terminal import TerminalSocketView, TerminalView
@@ -129,6 +136,20 @@ async def trailing_slash_middleware(request, handler):
class Application(BaseApplication):
+ async def create_default_forum(self, app):
+ # Check if any forums exist
+ forums = [f async for f in self.services.forum.find(is_active=True)]
+ if not forums:
+ # Find admin user to be the creator
+ admin_user = await self.services.user.get(is_admin=True)
+ if admin_user:
+ await self.services.forum.create_forum(
+ name="General Discussion",
+ description="A place for general discussion.",
+ created_by_uid=admin_user["uid"],
+ )
+ print("Default forum 'General Discussion' created.")
+
def __init__(self, *args, **kwargs):
middlewares = [
cors_middleware,
@@ -174,6 +195,7 @@ class Application(BaseApplication):
self.on_startup.append(self.start_user_availability_service)
self.on_startup.append(self.start_ssh_server)
self.on_startup.append(self.prepare_database)
+ self.on_startup.append(self.create_default_forum)
@property
@@ -310,11 +332,12 @@ class Application(BaseApplication):
self.router.add_view("/threads.html", ThreadsView)
self.router.add_view("/terminal.ws", TerminalSocketView)
self.router.add_view("/terminal.html", TerminalView)
- #self.router.add_view("/drive.json", DriveApiView)
- #self.router.add_view("/drive.html", DriveView)
- #self.router.add_view("/drive/{drive}.json", DriveView)
+ self.router.add_view("/drive.json", DriveApiView)
+ self.router.add_view("/drive.html", DriveView)
+ self.router.add_view("/drive/{rel_path:.*}", DriveView)
self.router.add_view("/stats.json", StatsView)
self.router.add_view("/user/{user}.html", UserView)
+ self.router.add_view("/user/{user_uid}/{slug}.html", ProfilePageView)
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
self.router.add_view(
"/repository/{username}/{repository}/{path:.*}", RepositoryView
@@ -331,6 +354,14 @@ class Application(BaseApplication):
"/settings/repositories/repository/{name}/delete.html",
RepositoriesDeleteView,
)
+ self.router.add_view("/settings/profile_pages/index.html", ProfilePagesView)
+ self.router.add_view("/settings/profile_pages/create.html", ProfilePageCreateView)
+ self.router.add_view(
+ "/settings/profile_pages/{page_uid}/edit.html", ProfilePageEditView
+ )
+ self.router.add_view(
+ "/settings/profile_pages/{page_uid}/delete.html", ProfilePageDeleteView
+ )
self.router.add_view("/settings/containers/index.html", ContainersIndexView)
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
self.router.add_view(
diff --git a/src/snek/forum.py b/src/snek/forum.py
index a0b0d2d..c05a6ae 100644
--- a/src/snek/forum.py
+++ b/src/snek/forum.py
@@ -78,29 +78,7 @@ class ForumApplication(aiohttp.web.Application):
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
- html = """
-
-
-
-
- Forum
-
-
-
-
-
-
-"""
return await self.parent.render_template("forum.html", request)
-
-
- #return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app
diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py
index be01022..c63921e 100644
--- a/src/snek/mapper/__init__.py
+++ b/src/snek/mapper/__init__.py
@@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
+from snek.mapper.profile_page import ProfilePageMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object
@@ -36,6 +37,7 @@ def get_mappers(app=None):
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
+ "profile_page": ProfilePageMapper(app=app),
}
)
diff --git a/src/snek/mapper/profile_page.py b/src/snek/mapper/profile_page.py
new file mode 100644
index 0000000..ec73b34
--- /dev/null
+++ b/src/snek/mapper/profile_page.py
@@ -0,0 +1,9 @@
+import logging
+from snek.model.profile_page import ProfilePageModel
+from snek.system.mapper import BaseMapper
+
+logger = logging.getLogger(__name__)
+
+class ProfilePageMapper(BaseMapper):
+ table_name = "profile_page"
+ model_class = ProfilePageModel
diff --git a/src/snek/model/profile_page.py b/src/snek/model/profile_page.py
new file mode 100644
index 0000000..653615f
--- /dev/null
+++ b/src/snek/model/profile_page.py
@@ -0,0 +1,12 @@
+import logging
+from snek.system.model import BaseModel, ModelField
+
+logger = logging.getLogger(__name__)
+
+class ProfilePageModel(BaseModel):
+ user_uid = ModelField(name="user_uid", required=True, kind=str)
+ title = ModelField(name="title", required=True, kind=str)
+ slug = ModelField(name="slug", required=True, kind=str)
+ content = ModelField(name="content", required=False, kind=str, value="")
+ order_index = ModelField(name="order_index", required=True, kind=int, value=0)
+ is_published = ModelField(name="is_published", required=True, kind=bool, value=True)
diff --git a/src/snek/model/repository.py b/src/snek/model/repository.py
index 521e3ff..6fb0206 100644
--- a/src/snek/model/repository.py
+++ b/src/snek/model/repository.py
@@ -7,4 +7,6 @@ class RepositoryModel(BaseModel):
name = ModelField(name="name", required=True, kind=str)
+ description = ModelField(name="description", required=False, kind=str)
+
is_private = ModelField(name="is_private", required=False, kind=bool)
diff --git a/src/snek/schema.sql b/src/snek/schema.sql
index f89158e..63f7346 100644
--- a/src/snek/schema.sql
+++ b/src/snek/schema.sql
@@ -1,32 +1,33 @@
-CREATE TABLE IF NOT EXISTS http_access (
- id INTEGER NOT NULL,
- created TEXT,
- path TEXT,
- duration FLOAT,
- PRIMARY KEY (id)
-);
-CREATE TABLE IF NOT EXISTS user (
+CREATE TABLE user (
id INTEGER NOT NULL,
+ city TEXT,
color TEXT,
+ country_long TEXT,
+ country_short TEXT,
created_at TEXT,
deleted_at TEXT,
email TEXT,
+ ip TEXT,
is_admin TEXT,
last_ping TEXT,
+ latitude TEXT,
+ longitude TEXT,
nick TEXT,
password TEXT,
+ region TEXT,
uid TEXT,
updated_at TEXT,
username TEXT,
PRIMARY KEY (id)
);
-CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
-CREATE TABLE IF NOT EXISTS channel (
+CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
+CREATE TABLE channel (
id INTEGER NOT NULL,
created_at TEXT,
created_by_uid TEXT,
deleted_at TEXT,
description TEXT,
+ history_start TEXT,
"index" BIGINT,
is_listed BOOLEAN,
is_private BOOLEAN,
@@ -37,46 +38,48 @@ CREATE TABLE IF NOT EXISTS channel (
updated_at TEXT,
PRIMARY KEY (id)
);
-CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
-CREATE TABLE IF NOT EXISTS channel_member (
- id INTEGER NOT NULL,
- channel_uid TEXT,
- created_at TEXT,
- deleted_at TEXT,
- is_banned BOOLEAN,
- is_moderator BOOLEAN,
- is_muted BOOLEAN,
- is_read_only BOOLEAN,
- label TEXT,
- new_count BIGINT,
- last_read_at TEXT,
- uid TEXT,
- updated_at TEXT,
- user_uid TEXT,
- PRIMARY KEY (id)
-);
-CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
-CREATE TABLE IF NOT EXISTS broadcast (
+CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
+CREATE TABLE channel_member (
id INTEGER NOT NULL,
channel_uid TEXT,
- message TEXT,
created_at TEXT,
+ deleted_at TEXT,
+ is_banned BOOLEAN,
+ is_moderator BOOLEAN,
+ is_muted BOOLEAN,
+ is_read_only BOOLEAN,
+ label TEXT,
+ new_count BIGINT,
+ uid TEXT,
+ updated_at TEXT,
+ user_uid TEXT,
PRIMARY KEY (id)
);
-CREATE TABLE IF NOT EXISTS channel_message (
+CREATE INDEX ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);
+CREATE TABLE channel_message (
id INTEGER NOT NULL,
channel_uid TEXT,
created_at TEXT,
deleted_at TEXT,
html TEXT,
+ is_final BOOLEAN,
message TEXT,
uid TEXT,
updated_at TEXT,
user_uid TEXT,
PRIMARY KEY (id)
);
-CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
-CREATE TABLE IF NOT EXISTS notification (
+CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
+CREATE INDEX ix_channel_message_acb69f257bb37684 ON channel_message (is_final, user_uid, channel_uid);
+CREATE INDEX ix_channel_message_c6c4cddf281df93d ON channel_message (deleted_at);
+CREATE TABLE kv (
+ id INTEGER NOT NULL,
+ "key" TEXT,
+ value TEXT,
+ PRIMARY KEY (id)
+);
+CREATE INDEX ix_kv_a62f2225bf70bfac ON kv ("key");
+CREATE TABLE notification (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
@@ -89,11 +92,38 @@ CREATE TABLE IF NOT EXISTS notification (
user_uid TEXT,
PRIMARY KEY (id)
);
-CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
-CREATE TABLE IF NOT EXISTS repository (
+CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
+CREATE UNIQUE INDEX ix_user_249ba36000029bbe ON user (username);
+CREATE INDEX ix_channel_member_9a1d4fb1836d9613 ON channel_member (channel_uid, user_uid);
+CREATE TABLE drive (
id INTEGER NOT NULL,
created_at TEXT,
deleted_at TEXT,
+ name TEXT,
+ uid TEXT,
+ updated_at TEXT,
+ user_uid TEXT,
+ PRIMARY KEY (id)
+);
+CREATE INDEX ix_drive_e2577dd78b54fe28 ON drive (uid);
+CREATE TABLE push_registration (
+ id INTEGER NOT NULL,
+ created_at TEXT,
+ deleted_at TEXT,
+ endpoint TEXT,
+ key_auth TEXT,
+ key_p256dh TEXT,
+ uid TEXT,
+ updated_at TEXT,
+ user_uid TEXT,
+ PRIMARY KEY (id)
+);
+CREATE INDEX ix_push_registration_e2577dd78b54fe28 ON push_registration (uid);
+CREATE TABLE repository (
+ id INTEGER NOT NULL,
+ created_at TEXT,
+ deleted_at TEXT,
+ description TEXT,
is_private BIGINT,
name TEXT,
uid TEXT,
@@ -101,4 +131,19 @@ CREATE TABLE IF NOT EXISTS repository (
user_uid TEXT,
PRIMARY KEY (id)
);
-CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);
+CREATE INDEX ix_repository_e2577dd78b54fe28 ON repository (uid);
+CREATE TABLE profile_page (
+ id INTEGER NOT NULL,
+ content TEXT,
+ created_at TEXT,
+ deleted_at TEXT,
+ is_published BOOLEAN,
+ order_index BIGINT,
+ slug TEXT,
+ title TEXT,
+ uid TEXT,
+ updated_at TEXT,
+ user_uid TEXT,
+ PRIMARY KEY (id)
+);
+CREATE INDEX ix_profile_page_e2577dd78b54fe28 ON profile_page (uid);
diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py
index 6e9e58f..c9f75c6 100644
--- a/src/snek/service/__init__.py
+++ b/src/snek/service/__init__.py
@@ -19,6 +19,7 @@ from snek.service.util import UtilService
from snek.system.object import Object
from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
+from snek.service.profile_page import ProfilePageService
_service_registry = {}
def register_service(name, service_cls):
@@ -62,4 +63,5 @@ register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)
+register_service("profile_page", ProfilePageService)
diff --git a/src/snek/service/forum.py b/src/snek/service/forum.py
index 3a6d552..8cd7fe0 100644
--- a/src/snek/service/forum.py
+++ b/src/snek/service/forum.py
@@ -4,6 +4,8 @@ import re
import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
+import asyncio
+import inspect
from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
@@ -42,10 +44,12 @@ class BaseForumService(BaseService):
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
- if hasattr(listener, "__await__"): # async function or coro
+ if inspect.iscoroutinefunction(listener):
await listener(event_name, data)
- else: # plain sync function
- listener(event_name, data)
+ else:
+ result = listener(event_name, data)
+ if inspect.isawaitable(result):
+ await result
async def notify(self, event_name: str, data: Any) -> None:
"""
diff --git a/src/snek/service/profile_page.py b/src/snek/service/profile_page.py
new file mode 100644
index 0000000..7d0807b
--- /dev/null
+++ b/src/snek/service/profile_page.py
@@ -0,0 +1,85 @@
+import logging
+import re
+from typing import Optional, List
+from snek.system.service import BaseService
+from snek.system.exceptions import ValidationError, DuplicateResourceError
+
+logger = logging.getLogger(__name__)
+
+class ProfilePageService(BaseService):
+ mapper_name = "profile_page"
+
+ def slugify(self, title: str) -> str:
+ slug = title.lower().strip()
+ slug = re.sub(r'[^\w\s-]', '', slug)
+ slug = re.sub(r'[-\s]+', '-', slug)
+ return slug[:100]
+
+ async def create_page(self, user_uid: str, title: str, content: str = "", is_published: bool = True) -> dict:
+ slug = self.slugify(title)
+
+ existing = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
+ if existing:
+ raise DuplicateResourceError(f"A page with slug '{slug}' already exists")
+
+ pages = [p async for p in self.find(user_uid=user_uid, deleted_at=None)]
+ max_order = max([p["order_index"] for p in pages], default=-1)
+
+ model = await self.new()
+ model["user_uid"] = user_uid
+ model["title"] = title
+ model["slug"] = slug
+ model["content"] = content
+ model["order_index"] = max_order + 1
+ model["is_published"] = is_published
+
+ await self.save(model)
+ return model
+
+ async def update_page(self, page_uid: str, title: Optional[str] = None,
+ content: Optional[str] = None, is_published: Optional[bool] = None) -> dict:
+ page = await self.get(uid=page_uid, deleted_at=None)
+ if not page:
+ raise ValidationError("Page not found")
+
+ if title is not None:
+ page["title"] = title
+ new_slug = self.slugify(title)
+ existing = await self.get(user_uid=page["user_uid"], slug=new_slug, deleted_at=None)
+ if existing and existing["uid"] != page_uid:
+ raise DuplicateResourceError(f"A page with slug '{new_slug}' already exists")
+ page["slug"] = new_slug
+
+ if content is not None:
+ page["content"] = content
+
+ if is_published is not None:
+ page["is_published"] = is_published
+
+ return await self.save(page)
+
+ async def get_user_pages(self, user_uid: str, include_unpublished: bool = False) -> List[dict]:
+ if include_unpublished:
+ pages = [p.record async for p in self.find(user_uid=user_uid, deleted_at=None)]
+ else:
+ pages = [p.record async for p in self.find(user_uid=user_uid, is_published=True, deleted_at=None)]
+
+ return sorted(pages, key=lambda p: p["order_index"])
+
+ async def get_page_by_slug(self, user_uid: str, slug: str, include_unpublished: bool = False) -> Optional[dict]:
+ page = await self.get(user_uid=user_uid, slug=slug, deleted_at=None)
+ if page and (include_unpublished or page["is_published"]):
+ return page
+ return None
+
+ async def reorder_pages(self, user_uid: str, page_uids: List[str]) -> None:
+ for index, page_uid in enumerate(page_uids):
+ page = await self.get(uid=page_uid, user_uid=user_uid, deleted_at=None)
+ if page:
+ page["order_index"] = index
+ await self.save(page)
+
+ async def delete_page(self, page_uid: str) -> None:
+ page = await self.get(uid=page_uid, deleted_at=None)
+ if page:
+ await self.delete(page)
diff --git a/src/snek/service/repository.py b/src/snek/service/repository.py
index def3257..0cd1ec5 100644
--- a/src/snek/service/repository.py
+++ b/src/snek/service/repository.py
@@ -11,7 +11,7 @@ class RepositoryService(BaseService):
loop = asyncio.get_event_loop()
repository_path = (
await self.services.user.get_repository_path(user_uid)
- ).joinpath(name)
+ ).joinpath(name + ".git")
try:
await loop.run_in_executor(None, shutil.rmtree, repository_path)
except Exception as ex:
@@ -39,7 +39,7 @@ class RepositoryService(BaseService):
stdout, stderr = await process.communicate()
return process.returncode == 0
- async def create(self, user_uid, name, is_private=False):
+ async def create(self, user_uid, name, is_private=False, description=None):
if await self.exists(user_uid=user_uid, name=name):
return False
@@ -50,4 +50,14 @@ class RepositoryService(BaseService):
model["user_uid"] = user_uid
model["name"] = name
model["is_private"] = is_private
+ model["description"] = description or ""
return await self.save(model)
+
+ async def list_by_user(self, user_uid):
+ repositories = []
+ async for repo in self.find(user_uid=user_uid):
+ repositories.append(repo)
+ return repositories
+
+ async def get_by_name(self, user_uid, name):
+ return await self.get(user_uid=user_uid, name=name)
diff --git a/src/snek/sgit.py b/src/snek/sgit.py
index 4c2b6ac..7e1adeb 100644
--- a/src/snek/sgit.py
+++ b/src/snek/sgit.py
@@ -16,61 +16,72 @@ logger = logging.getLogger("git_server")
class GitApplication(web.Application):
def __init__(self, parent=None):
- # import git
- # globals()['git'] = git
+ import git
+ globals()['git'] = git
self.parent = parent
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
self.add_routes(
[
- web.post("/create/{repo_name}", self.create_repository),
- web.delete("/delete/{repo_name}", self.delete_repository),
- web.get("/clone/{repo_name}", self.clone_repository),
- web.post("/push/{repo_name}", self.push_repository),
- web.post("/pull/{repo_name}", self.pull_repository),
- web.get("/status/{repo_name}", self.status_repository),
- # web.get('/list', self.list_repositories),
- web.get("/branches/{repo_name}", self.list_branches),
- web.post("/branches/{repo_name}", self.create_branch),
- web.get("/log/{repo_name}", self.commit_log),
- web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
- web.get("/{path:.+}/info/refs", self.git_smart_http),
- web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
- web.post("/{path:.+}/git-receive-pack", self.git_smart_http),
- web.get("/{repo_name}.git/info/refs", self.git_smart_http),
- web.post("/{repo_name}.git/git-upload-pack", self.git_smart_http),
- web.post("/{repo_name}.git/git-receive-pack", self.git_smart_http),
+ web.post("/{username}/{repo_name}/create", self.create_repository),
+ web.delete("/{username}/{repo_name}/delete", self.delete_repository),
+ web.get("/{username}/{repo_name}/clone", self.clone_repository),
+ web.post("/{username}/{repo_name}/push", self.push_repository),
+ web.post("/{username}/{repo_name}/pull", self.pull_repository),
+ web.get("/{username}/{repo_name}/status", self.status_repository),
+ web.get("/{username}/{repo_name}/branches", self.list_branches),
+ web.post("/{username}/{repo_name}/branches", self.create_branch),
+ web.get("/{username}/{repo_name}/log", self.commit_log),
+ web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
+ web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
+ web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
+ web.post("/{username}/{repo_name}.git/git-receive-pack", self.git_smart_http),
]
)
async def check_basic_auth(self, request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
- return None, None
+ return None, None, None
encoded_creds = auth_header.split("Basic ")[1]
decoded_creds = base64.b64decode(encoded_creds).decode()
username, password = decoded_creds.split(":", 1)
- request["user"] = await self.parent.services.user.authenticate(
+ request["auth_user"] = await self.parent.services.user.authenticate(
username=username, password=password
)
- if not request["user"]:
- return None, None
+ if not request["auth_user"]:
+ return None, None, None
+
+ path_username = request.match_info.get("username")
+ if not path_username:
+ return None, None, None
+
+ if path_username.count("-") == 4:
+ target_user = await self.parent.services.user.get(uid=path_username)
+ else:
+ target_user = await self.parent.services.user.get(username=path_username)
+
+ if not target_user:
+ return None, None, None
+
+ request["target_user"] = target_user
request["repository_path"] = (
- await self.parent.services.user.get_repository_path(request["user"]["uid"])
+ await self.parent.services.user.get_repository_path(target_user["uid"])
)
- return request["user"]["username"], request["repository_path"]
+ return request["auth_user"]["username"], target_user, request["repository_path"]
@staticmethod
def require_auth(handler):
async def wrapped(self, request, *args, **kwargs):
- username, repository_path = await self.check_basic_auth(request)
- if not username or not repository_path:
+ username, target_user, repository_path = await self.check_basic_auth(request)
+ if not username or not target_user or not repository_path:
return web.Response(
status=401,
headers={"WWW-Authenticate": "Basic"},
text="Authentication required",
)
request["username"] = username
+ request["target_user"] = target_user
request["repository_path"] = repository_path
return await handler(self, request, *args, **kwargs)
@@ -87,9 +98,17 @@ class GitApplication(web.Application):
@require_auth
async def create_repository(self, request):
- username = request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ if auth_user["uid"] != target_user["uid"]:
+ return web.Response(
+ text="Forbidden: can only create repositories in your own namespace",
+ status=403,
+ )
+
if not repo_name or "/" in repo_name or ".." in repo_name:
return web.Response(text="Invalid repository name", status=400)
repo_dir = self.repo_path(repository_path, repo_name)
@@ -97,7 +116,7 @@ class GitApplication(web.Application):
return web.Response(text="Repository already exists", status=400)
try:
git.Repo.init(repo_dir, bare=True)
- logger.info(f"Created repository: {repo_name} for user {username}")
+ logger.info(f"Created repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Created repository {repo_name}")
except Exception as e:
logger.error(f"Error creating repository {repo_name}: {str(e)}")
@@ -105,16 +124,22 @@ class GitApplication(web.Application):
@require_auth
async def delete_repository(self, request):
- username = request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ if auth_user["uid"] != target_user["uid"]:
+ return web.Response(
+ text="Forbidden: can only delete your own repositories", status=403
+ )
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
- #'''
try:
shutil.rmtree(self.repo_path(repository_path, repo_name))
- logger.info(f"Deleted repository: {repo_name} for user {username}")
+ logger.info(f"Deleted repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Deleted repository {repo_name}")
except Exception as e:
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
@@ -122,9 +147,20 @@ class GitApplication(web.Application):
@require_auth
async def clone_repository(self, request):
- request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ repo = await self.parent.services.repository.get(
+ user_uid=target_user["uid"], name=repo_name
+ )
+ if not repo:
+ return web.Response(text="Repository not found", status=404)
+
+ if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
+ return web.Response(text="Repository not found", status=404)
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -139,9 +175,16 @@ class GitApplication(web.Application):
@require_auth
async def push_repository(self, request):
- username = request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ if auth_user["uid"] != target_user["uid"]:
+ return web.Response(
+ text="Forbidden: can only push to your own repositories", status=403
+ )
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -175,14 +218,21 @@ class GitApplication(web.Application):
temp_repo.index.commit(commit_message)
origin = temp_repo.remote("origin")
origin.push(refspec=f"{branch}:{branch}")
- logger.info(f"Pushed to repository: {repo_name} for user {username}")
+ logger.info(f"Pushed to repository: {repo_name} for user {auth_user['username']}")
return web.Response(text=f"Successfully pushed changes to {repo_name}")
@require_auth
async def pull_repository(self, request):
- username = request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ if auth_user["uid"] != target_user["uid"]:
+ return web.Response(
+ text="Forbidden: can only pull to your own repositories", status=403
+ )
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -210,7 +260,7 @@ class GitApplication(web.Application):
origin = local_repo.remote("origin")
origin.push()
logger.info(
- f"Pulled to repository {repo_name} from {remote_url} for user {username}"
+ f"Pulled to repository {repo_name} from {remote_url} for user {auth_user['username']}"
)
return web.Response(
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
@@ -221,9 +271,20 @@ class GitApplication(web.Application):
@require_auth
async def status_repository(self, request):
- request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ repo = await self.parent.services.repository.get(
+ user_uid=target_user["uid"], name=repo_name
+ )
+ if not repo:
+ return web.Response(text="Repository not found", status=404)
+
+ if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
+ return web.Response(text="Repository not found", status=404)
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -291,9 +352,20 @@ class GitApplication(web.Application):
@require_auth
async def list_branches(self, request):
- request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ repo = await self.parent.services.repository.get(
+ user_uid=target_user["uid"], name=repo_name
+ )
+ if not repo:
+ return web.Response(text="Repository not found", status=404)
+
+ if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
+ return web.Response(text="Repository not found", status=404)
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -306,9 +378,17 @@ class GitApplication(web.Application):
@require_auth
async def create_branch(self, request):
- username = request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ if auth_user["uid"] != target_user["uid"]:
+ return web.Response(
+ text="Forbidden: can only create branches in your own repositories",
+ status=403,
+ )
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -328,7 +408,7 @@ class GitApplication(web.Application):
temp_repo.git.branch(branch_name, start_point)
temp_repo.git.push("origin", branch_name)
logger.info(
- f"Created branch {branch_name} in repository {repo_name} for user {username}"
+ f"Created branch {branch_name} in repository {repo_name} for user {auth_user['username']}"
)
return web.Response(text=f"Created branch {branch_name}")
except Exception as e:
@@ -339,9 +419,20 @@ class GitApplication(web.Application):
@require_auth
async def commit_log(self, request):
- request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
repository_path = request["repository_path"]
+
+ repo = await self.parent.services.repository.get(
+ user_uid=target_user["uid"], name=repo_name
+ )
+ if not repo:
+ return web.Response(text="Repository not found", status=404)
+
+ if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
+ return web.Response(text="Repository not found", status=404)
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -383,11 +474,22 @@ class GitApplication(web.Application):
@require_auth
async def file_content(self, request):
- request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repo_name = request.match_info["repo_name"]
file_path = request.match_info.get("file_path", "")
branch = request.query.get("branch", "main")
repository_path = request["repository_path"]
+
+ repo = await self.parent.services.repository.get(
+ user_uid=target_user["uid"], name=repo_name
+ )
+ if not repo:
+ return web.Response(text="Repository not found", status=404)
+
+ if repo["is_private"] and auth_user["uid"] != target_user["uid"]:
+ return web.Response(text="Repository not found", status=404)
+
error_response = self.check_repo_exists(repository_path, repo_name)
if error_response:
return error_response
@@ -433,25 +535,42 @@ class GitApplication(web.Application):
@require_auth
async def git_smart_http(self, request):
- request["username"]
+ auth_user = request["auth_user"]
+ target_user = request["target_user"]
repository_path = request["repository_path"]
+ repo_name = request.match_info.get("repo_name")
+ path_username = request.match_info.get("username")
path = request.path
+ repo = await self.parent.services.repository.get(
+ user_uid=target_user["uid"], name=repo_name
+ )
+ if not repo:
+ return web.Response(text="Repository not found", status=404)
+
+ is_owner = auth_user["uid"] == target_user["uid"]
+ is_write_operation = "/git-receive-pack" in path
+
+ if is_write_operation and not is_owner:
+ logger.warning(
+ f"Push denied: {auth_user['username']} attempted to push to {path_username}/{repo_name}"
+ )
+ return web.Response(
+ text="Push denied: only repository owner can push", status=403
+ )
+
+ if not is_owner and repo["is_private"]:
+ logger.warning(
+ f"Access denied: {auth_user['username']} attempted to access private repo {path_username}/{repo_name}"
+ )
+ return web.Response(text="Repository not found", status=404)
+
async def get_repository_path():
- req_path = path.lstrip("/")
- if req_path.endswith("/info/refs"):
- repo_name = req_path[: -len("/info/refs")]
- elif req_path.endswith("/git-upload-pack"):
- repo_name = req_path[: -len("/git-upload-pack")]
- elif req_path.endswith("/git-receive-pack"):
- repo_name = req_path[: -len("/git-receive-pack")]
- else:
- repo_name = req_path
- if repo_name.endswith(".git"):
- repo_name = repo_name[:-4]
- repo_name = repo_name[4:]
repo_dir = repository_path.joinpath(repo_name + ".git")
- logger.info(f"Resolved repo path: {repo_dir}")
+ logger.info(
+ f"Resolved repo path: {repo_dir} for user: {path_username}, repo: {repo_name}, "
+ f"auth: {auth_user['username']}, owner: {is_owner}, write: {is_write_operation}"
+ )
return repo_dir
async def handle_info_refs(service):
diff --git a/src/snek/system/debug.py b/src/snek/system/debug.py
new file mode 100644
index 0000000..194512c
--- /dev/null
+++ b/src/snek/system/debug.py
@@ -0,0 +1,108 @@
+import asyncio
+import functools
+import inspect
+import logging
+import traceback
+from typing import Any, Callable
+
+logger = logging.getLogger(__name__)
+
+def debug_trace(func: Callable) -> Callable:
+ func_logger = logging.getLogger(func.__module__)
+
+ if asyncio.iscoroutinefunction(func):
+ @functools.wraps(func)
+ async def async_wrapper(*args, **kwargs):
+ func_name = func.__qualname__
+ args_repr = _format_args(args, kwargs)
+
+ func_logger.debug(f"→ ENTER {func_name}({args_repr})")
+
+ try:
+ result = await func(*args, **kwargs)
+ result_repr = _format_result(result)
+ func_logger.debug(f"← EXIT {func_name} = {result_repr}")
+ return result
+ except Exception as e:
+ func_logger.error(
+ f"✗ EXCEPTION in {func_name}({args_repr}): {type(e).__name__}: {e}\n"
+ f"{''.join(traceback.format_exception(type(e), e, e.__traceback__))}"
+ )
+ raise
+ return async_wrapper
+ else:
+ @functools.wraps(func)
+ def sync_wrapper(*args, **kwargs):
+ func_name = func.__qualname__
+ args_repr = _format_args(args, kwargs)
+
+ func_logger.debug(f"→ ENTER {func_name}({args_repr})")
+
+ try:
+ result = func(*args, **kwargs)
+ result_repr = _format_result(result)
+ func_logger.debug(f"← EXIT {func_name} = {result_repr}")
+ return result
+ except Exception as e:
+ func_logger.error(
+ f"✗ EXCEPTION in {func_name}({args_repr}): {type(e).__name__}: {e}\n"
+ f"{''.join(traceback.format_exception(type(e), e, e.__traceback__))}"
+ )
+ raise
+ return sync_wrapper
+
+def _format_args(args: tuple, kwargs: dict) -> str:
+ args_parts = []
+
+ for i, arg in enumerate(args):
+ if i == 0 and hasattr(arg, '__class__') and arg.__class__.__name__ in ['Application', 'BaseService', 'BaseView', 'BaseFormView']:
+ args_parts.append(f"self={arg.__class__.__name__}")
+ else:
+ args_parts.append(_repr_value(arg))
+
+ for key, value in kwargs.items():
+ args_parts.append(f"{key}={_repr_value(value)}")
+
+ return ", ".join(args_parts)
+
+def _format_result(result: Any) -> str:
+ return _repr_value(result)
+
+def _repr_value(value: Any, max_len: int = 200) -> str:
+ if value is None:
+ return "None"
+ elif isinstance(value, (str, int, float, bool)):
+ repr_str = repr(value)
+ elif isinstance(value, dict):
+ if len(value) == 0:
+ return "{}"
+ keys = list(value.keys())[:3]
+ items = [f"{k!r}: {_repr_value(value[k], 50)}" for k in keys]
+ suffix = "..." if len(value) > 3 else ""
+ repr_str = "{" + ", ".join(items) + suffix + "}"
+ elif isinstance(value, (list, tuple)):
+ type_name = type(value).__name__
+ if len(value) == 0:
+ return f"{type_name}()"
+ items = [_repr_value(v, 50) for v in value[:3]]
+ suffix = "..." if len(value) > 3 else ""
+ repr_str = f"{type_name}([{', '.join(items)}{suffix}])"
+ else:
+ repr_str = f"<{type(value).__name__}>"
+
+ if len(repr_str) > max_len:
+ return repr_str[:max_len] + "..."
+ return repr_str
+
+def apply_debug_decorators(cls, debug_enabled: bool = False):
+ if not debug_enabled:
+ return cls
+
+ for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
+ if name.startswith('_') and name != '__init__':
+ continue
+ if name in ['__repr__', '__str__', '__eq__', '__hash__']:
+ continue
+ setattr(cls, name, debug_trace(method))
+
+ return cls
diff --git a/src/snek/system/exception_middleware.py b/src/snek/system/exception_middleware.py
new file mode 100644
index 0000000..324fb69
--- /dev/null
+++ b/src/snek/system/exception_middleware.py
@@ -0,0 +1,55 @@
+import logging
+import traceback
+from aiohttp import web
+
+logger = logging.getLogger(__name__)
+
+@web.middleware
+async def exception_handler_middleware(request, handler):
+ try:
+ response = await handler(request)
+ return response
+ except web.HTTPException as ex:
+ raise
+ except Exception as e:
+ debug_mode = hasattr(request.app, 'debug') and request.app.debug
+
+ error_id = id(e)
+ error_msg = f"Internal Server Error (ID: {error_id})"
+
+ if debug_mode:
+ stack_trace = ''.join(traceback.format_exception(type(e), e, e.__traceback__))
+ logger.error(
+ f"✗ UNHANDLED EXCEPTION in {request.method} {request.path}\n"
+ f"Error ID: {error_id}\n"
+ f"Exception: {type(e).__name__}: {e}\n"
+ f"Request: {request.method} {request.url}\n"
+ f"Headers: {dict(request.headers)}\n"
+ f"Stack trace:\n{stack_trace}"
+ )
+
+ return web.json_response(
+ {
+ "error": error_msg,
+ "exception": f"{type(e).__name__}: {str(e)}",
+ "path": str(request.path),
+ "method": request.method,
+ "error_id": error_id,
+ "stack_trace": stack_trace.split('\n')
+ },
+ status=500
+ )
+ else:
+ logger.error(
+ f"✗ UNHANDLED EXCEPTION in {request.method} {request.path}\n"
+ f"Error ID: {error_id}\n"
+ f"Exception: {type(e).__name__}: {e}"
+ )
+
+ return web.json_response(
+ {
+ "error": error_msg,
+ "error_id": error_id
+ },
+ status=500
+ )
diff --git a/src/snek/system/exceptions.py b/src/snek/system/exceptions.py
new file mode 100644
index 0000000..bf7a1f6
--- /dev/null
+++ b/src/snek/system/exceptions.py
@@ -0,0 +1,20 @@
+class SnekException(Exception):
+ def __init__(self, message: str, details: dict = None):
+ super().__init__(message)
+ self.message = message
+ self.details = details or {}
+
+class ValidationError(SnekException):
+ pass
+
+class NotFoundError(SnekException):
+ pass
+
+class PermissionDeniedError(SnekException):
+ pass
+
+class DuplicateResourceError(SnekException):
+ pass
+
+class AuthenticationError(SnekException):
+ pass
diff --git a/src/snek/templates/GIT_INTEGRATION.md b/src/snek/templates/GIT_INTEGRATION.md
new file mode 100644
index 0000000..6c2c56f
--- /dev/null
+++ b/src/snek/templates/GIT_INTEGRATION.md
@@ -0,0 +1,244 @@
+# Snek Git Integration Guide
+
+## Overview
+
+Snek provides full Git repository hosting with HTTP/HTTPS support. You can clone, push, pull, and manage repositories directly through the web interface or using standard Git clients.
+
+## Quick Start
+
+### Creating a Repository
+
+1. Navigate to **Settings** → **Repositories**
+2. Click **New Repository**
+3. Enter a repository name (e.g., `myproject`)
+4. Choose visibility (Public or Private)
+5. Click **Create**
+
+### Cloning Your Repository
+
+After creating a repository, you'll see a clone URL on the repositories list page. Use this with your Git client:
+
+```bash
+git clone http://username:password@your-snek-server.com/git/YOUR_UID/reponame.git
+```
+
+Replace:
+- `username` - Your Snek username
+- `password` - Your Snek password
+- `your-snek-server.com` - Your Snek server domain/IP
+- `YOUR_UID` - Your user ID (shown in the clone URL)
+- `reponame` - Your repository name
+
+### Example Workflow
+
+```bash
+# Clone the repository
+git clone http://john:mypass@localhost:8081/git/9c1cd9e5-19ce-4038-8637-d378400a4f33/myproject.git
+
+# Make changes
+cd myproject
+echo "# My Project" > README.md
+git add README.md
+git commit -m "Initial commit"
+
+# Push to Snek
+git push origin main
+```
+
+## Features
+
+### Web Interface
+
+- **Browse Code**: View repository files and directories through the web browser
+- **Commit History**: See recent commits with author and date information
+- **Branch Management**: View and switch between branches
+- **File Viewing**: Read file contents directly in the browser
+
+### Git Client Support
+
+Snek supports standard Git HTTP protocol. Any Git client works:
+- Command-line `git`
+- GitHub Desktop
+- GitKraken
+- VS Code Git integration
+- IntelliJ IDEA Git plugin
+
+## Repository URLs
+
+Snek provides multiple URL formats:
+
+### Clone URL (for Git clients)
+```
+http://username:password@server/git/USER_UID/repository.git
+```
+
+### Browse URL (web interface)
+```
+http://server/repository/USERNAME/repository
+```
+
+## Authentication
+
+Git operations require HTTP Basic Authentication:
+- **Username**: Your Snek username
+- **Password**: Your Snek password
+
+To avoid entering credentials every time, configure Git credential helper:
+
+```bash
+# Store credentials (Linux/Mac)
+git config --global credential.helper store
+
+# After first push/pull, credentials are saved
+```
+
+## Permissions
+
+- **Private repositories**: Only you can access
+- **Public repositories**: Anyone can read, only you can write
+
+## Troubleshooting
+
+### Authentication Failed
+
+**Problem**: `fatal: Authentication failed`
+
+**Solution**:
+- Verify your username and password are correct
+- Ensure you're using your Snek credentials, not Git credentials
+- Check if Basic Auth is properly formatted in URL
+
+### Repository Not Found
+
+**Problem**: `fatal: repository not found`
+
+**Solution**:
+- Verify the repository exists in your Snek account
+- Check the UID in the clone URL matches your user ID
+- Ensure repository name is spelled correctly
+
+### Push Rejected
+
+**Problem**: `error: failed to push some refs`
+
+**Solution**:
+- Pull latest changes first: `git pull origin main`
+- Resolve any merge conflicts
+- Try push again
+
+### SSL/TLS Errors
+
+**Problem**: Certificate verification failed
+
+**Solution**:
+- If using self-signed certificate, disable SSL verification (development only):
+ ```bash
+ git config --global http.sslVerify false
+ ```
+- For production, use proper SSL certificates
+
+## Advanced Usage
+
+### Working with Branches
+
+```bash
+# Create a new branch
+git checkout -b feature-branch
+
+# Push new branch to Snek
+git push origin feature-branch
+
+# Switch branches
+git checkout main
+```
+
+### Viewing Repository Info
+
+Visit your repository settings page to see:
+- Clone URL
+- Repository size
+- Creation date
+- Public/Private status
+
+### Deleting Repositories
+
+1. Go to **Settings** → **Repositories**
+2. Find the repository
+3. Click **Delete**
+4. Confirm deletion
+
+**Warning**: This action cannot be undone. All commits and history will be lost.
+
+## Technical Details
+
+### Repository Storage
+
+Repositories are stored as bare Git repositories in:
+```
+drive/repositories/USER_UID/repository.git
+```
+
+### Supported Git Operations
+
+- Clone
+- Pull
+- Push
+- Fetch
+- Branch creation
+- Tag creation
+- All standard Git commands
+
+### Protocol Support
+
+- HTTP (git-upload-pack, git-receive-pack)
+- Smart HTTP protocol
+- Compression supported
+- Large file support (up to 5GB per request)
+
+## Security Best Practices
+
+1. **Use strong passwords** for your Snek account
+2. **Enable 2FA** if available
+3. **Use HTTPS** in production (not HTTP)
+4. **Keep repositories private** unless intended for public access
+5. **Rotate credentials** periodically
+6. **Don't commit secrets** (API keys, passwords, etc.)
+
+## API Usage
+
+For programmatic access, use the Git HTTP API:
+
+### Create Repository
+```bash
+curl -u username:password -X POST \
+ http://server/git/create/myrepo
+```
+
+### Delete Repository
+```bash
+curl -u username:password -X DELETE \
+ http://server/git/delete/myrepo
+```
+
+### Get Repository Status
+```bash
+curl -u username:password \
+ http://server/git/status/myrepo
+```
+
+## Support
+
+For issues or questions:
+1. Check this guide first
+2. Review error messages carefully
+3. Check Snek application logs
+4. Contact your Snek administrator
+
+## Changelog
+
+### Version 1.0
+- Initial Git integration
+- HTTP protocol support
+- Web-based repository browser
+- Basic authentication
+- Public/Private repositories
diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html
index d8375a3..6cf392e 100644
--- a/src/snek/templates/app.html
+++ b/src/snek/templates/app.html
@@ -42,7 +42,7 @@
📂
🔍
📥
- 👥
+ 💬
⚙️
✉️
🔒
diff --git a/src/snek/templates/forum.html b/src/snek/templates/forum.html
index ddbc68a..40b7f87 100644
--- a/src/snek/templates/forum.html
+++ b/src/snek/templates/forum.html
@@ -117,6 +117,13 @@ class SnekForum extends HTMLElement {
}
async loadForums() {
+ if (window.preloadedForums) {
+ this.currentView = 'forums';
+ this.renderForums(window.preloadedForums);
+ this.updateBreadcrumb();
+ window.preloadedForums = null; // Clear it after use
+ return;
+ }
try {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
@@ -695,9 +702,11 @@ class SnekForum extends HTMLElement {
}
updateBreadcrumb() {
- return;
+ const breadcrumbContainer = document.getElementById('breadcrumb');
+ if (!breadcrumbContainer) return;
+
const crumb = [];
- crumb.push(`FORUMS`);
+ crumb.push(`FORUMS`);
if (this.currentView === "forum" && this.currentForum) {
crumb.push(`›`);
crumb.push(`${this.currentForum.name.toUpperCase()}`);
@@ -708,7 +717,7 @@ class SnekForum extends HTMLElement {
crumb.push(`›`);
crumb.push(`${this.currentThread.title.toUpperCase()}`);
}
- this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
+ breadcrumbContainer.innerHTML = crumb.join(' ');
}
renderForums(forums) {
@@ -905,6 +914,9 @@ class SnekForum extends HTMLElement {
}
customElements.define('snek-forum', SnekForum);
+
{% endblock %}
diff --git a/src/snek/templates/profile_page.html b/src/snek/templates/profile_page.html
new file mode 100644
index 0000000..f1e23d6
--- /dev/null
+++ b/src/snek/templates/profile_page.html
@@ -0,0 +1,35 @@
+{% extends "app.html" %}
+
+{% block sidebar %}
+
+{% endblock %}
+
+{% block header_text %}{{ page.title }}
{% endblock %}
+
+{% block main %}
+
+{% autoescape false %}
+{% markdown %}
+{{ page.content }}
+{% endmarkdown %}
+{% endautoescape %}
+
+{% endblock main %}
diff --git a/src/snek/templates/repository_empty.html b/src/snek/templates/repository_empty.html
new file mode 100644
index 0000000..4f85b56
--- /dev/null
+++ b/src/snek/templates/repository_empty.html
@@ -0,0 +1,26 @@
+{% extends "app.html" %}
+
+{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
+
+{% block sidebar %}{% endblock %}
+
+{% block main %}
+
+
Repository is Empty
+
This repository has been created but contains no branches or commits yet.
+
+
Quick Start
+
Create a new repository on the command line:
+
git init
+git add README.md
+git commit -m "Initial commit"
+git remote add origin {{ clone_url }}
+git push -u origin main
+
+
Push an existing repository:
+
git remote add origin {{ clone_url }}
+git push -u origin main
+
+
← Back to Repositories
+
+{% endblock %}
diff --git a/src/snek/templates/repository_file.html b/src/snek/templates/repository_file.html
new file mode 100644
index 0000000..32c6a13
--- /dev/null
+++ b/src/snek/templates/repository_file.html
@@ -0,0 +1,46 @@
+{% extends "app.html" %}
+
+{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
+
+{% block sidebar %}{% endblock %}
+
+{% block main %}
+
+
+ Branch:
+
+
+
+
+ {{ repo_name }}
+ {% if file_path %}
+ {% set parts = file_path.split('/') %}
+ {% set cumulative = '' %}
+ {% for part in parts %}
+ {% if cumulative %}
+ {% set cumulative = cumulative + '/' + part %}
+ {% else %}
+ {% set cumulative = part %}
+ {% endif %}
+ / {% if loop.last %}{{ part }}{% else %}{{ part }}{% endif %}
+ {% endfor %}
+ {% endif %}
+
+
+
{{ file_name }} ({{ file_size }})
+
+ {% if is_binary %}
+ {% if is_image %}
+

+ {% else %}
+
Binary file - cannot display content
+ {% endif %}
+ {% else %}
+
{{ content }}
+ {% endif %}
+
+{% endblock %}
diff --git a/src/snek/templates/repository_overview.html b/src/snek/templates/repository_overview.html
new file mode 100644
index 0000000..bccbe03
--- /dev/null
+++ b/src/snek/templates/repository_overview.html
@@ -0,0 +1,59 @@
+{% extends "app.html" %}
+
+{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
+
+{% block sidebar %}{% endblock %}
+
+{% block main %}
+
+ {% if repo.description %}
+
{{ repo.description }}
+ {% endif %}
+
+
+ Branch:
+
+
+
+
Clone URL:
+
{{ clone_url }}
+
+
Recent Commits
+ {% if commits %}
+
+ {% for commit in commits %}
+ -
+
{{ commit.short_hash }} {{ commit.message }} - {{ commit.author }} ({{ commit.date }})
+
+ {% endfor %}
+
+ {% else %}
+
No commits found.
+ {% endif %}
+
+
Files
+
+
+ {% if readme_content %}
+
README
+
{{ readme_content|safe }}
+ {% endif %}
+
+{% endblock %}
diff --git a/src/snek/templates/repository_tree.html b/src/snek/templates/repository_tree.html
new file mode 100644
index 0000000..975d3fc
--- /dev/null
+++ b/src/snek/templates/repository_tree.html
@@ -0,0 +1,56 @@
+{% extends "app.html" %}
+
+{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
+
+{% block sidebar %}{% endblock %}
+
+{% block main %}
+
+
+ Branch:
+
+
+
+
+ {{ repo_name }}
+ {% if rel_path %}
+ {% set parts = rel_path.split('/') %}
+ {% set cumulative = '' %}
+ {% for part in parts %}
+ {% if cumulative %}
+ {% set cumulative = cumulative + '/' + part %}
+ {% else %}
+ {% set cumulative = part %}
+ {% endif %}
+ / {{ part }}
+ {% endfor %}
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/src/snek/templates/settings/profile_pages/create.html b/src/snek/templates/settings/profile_pages/create.html
new file mode 100644
index 0000000..76609a0
--- /dev/null
+++ b/src/snek/templates/settings/profile_pages/create.html
@@ -0,0 +1,124 @@
+{% extends "settings/index.html" %}
+
+{% block header_text %}Create Profile Page
{% endblock %}
+
+{% block main %}
+
+
+
+ {% if error %}
+
+ {{ error }}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/snek/templates/settings/profile_pages/edit.html b/src/snek/templates/settings/profile_pages/edit.html
new file mode 100644
index 0000000..39674bd
--- /dev/null
+++ b/src/snek/templates/settings/profile_pages/edit.html
@@ -0,0 +1,130 @@
+{% extends "settings/index.html" %}
+
+{% block header_text %}Edit Profile Page
{% endblock %}
+
+{% block main %}
+
+
+
+ {% if error %}
+
+ {{ error }}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/snek/templates/settings/profile_pages/index.html b/src/snek/templates/settings/profile_pages/index.html
new file mode 100644
index 0000000..4b55bde
--- /dev/null
+++ b/src/snek/templates/settings/profile_pages/index.html
@@ -0,0 +1,93 @@
+{% extends "settings/index.html" %}
+
+{% block header_text %}Profile Pages
{% endblock %}
+
+{% block main %}
+
+
+
+ {% if pages %}
+
+ {% for page in pages %}
+
+
+
+
+ {{ page.title }}
+ {% if not page.is_published %}
+ (Draft)
+ {% endif %}
+
+
+ Slug: {{ page.slug }}
+
+
+ Order: {{ page.order_index }}
+
+
+
+
+
+ {% endfor %}
+
+
+
+
Page Order
+
Drag and drop pages to reorder them (coming soon), or use the order index field when editing.
+
+ {% else %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/src/snek/templates/settings/repositories/create.html b/src/snek/templates/settings/repositories/create.html
index ce3eacb..fd240ac 100644
--- a/src/snek/templates/settings/repositories/create.html
+++ b/src/snek/templates/settings/repositories/create.html
@@ -10,14 +10,18 @@
+
+
+
+
-
-
+
+
diff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html
index 115259e..1805868 100644
--- a/src/snek/templates/settings/repositories/index.html
+++ b/src/snek/templates/settings/repositories/index.html
@@ -75,8 +75,13 @@
{% for repo in repositories %}
-
{{ repo.name }}
-
+
+
{{ repo.name }}
+ {% if repo.description %}
+
{{ repo.description }}
+ {% endif %}
+
+
{% if repo.is_private %}Private{% else %}Public{% endif %}
@@ -86,9 +91,9 @@
Browse
-
- Clone
-
+
Edit
diff --git a/src/snek/templates/settings/repositories/update.html b/src/snek/templates/settings/repositories/update.html
index 93c9f72..9fafeb2 100644
--- a/src/snek/templates/settings/repositories/update.html
+++ b/src/snek/templates/settings/repositories/update.html
@@ -6,12 +6,15 @@
{% include "settings/repositories/form.html" %}
{% endblock %}
diff --git a/src/snek/templates/settings/sidebar.html b/src/snek/templates/settings/sidebar.html
index 9673b6e..d387a88 100644
--- a/src/snek/templates/settings/sidebar.html
+++ b/src/snek/templates/settings/sidebar.html
@@ -3,6 +3,7 @@
You
diff --git a/src/snek/templates/user.html b/src/snek/templates/user.html
index 6883981..9590e27 100644
--- a/src/snek/templates/user.html
+++ b/src/snek/templates/user.html
@@ -12,7 +12,15 @@
Profile
DM
- Gists
+ {% if profile_pages %}
+ Pages
+
+ {% endif %}
+ Gists
diff --git a/src/snek/view/forum.py b/src/snek/view/forum.py
index c72a248..9df6c9b 100644
--- a/src/snek/view/forum.py
+++ b/src/snek/view/forum.py
@@ -17,57 +17,27 @@ class ForumIndexView(BaseView):
async def get(self):
if self.login_required and not self.session.get("logged_in"):
return web.HTTPFound("/")
- channel = await self.services.channel.get(
- uid=self.request.match_info.get("channel")
- )
- if not channel:
- user = await self.services.user.get(
- uid=self.request.match_info.get("channel")
- )
- if user:
- channel = await self.services.channel.get_dm(
- self.session.get("uid"), user["uid"]
- )
- if channel:
- return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
- if not channel:
- return web.HTTPNotFound()
-
- channel_member = await self.app.services.channel_member.get(
- user_uid=self.session.get("uid"), channel_uid=channel["uid"]
- )
- if not channel_member:
- if not channel["is_private"]:
- channel_member = await self.app.services.channel_member.create(
- channel_uid=channel["uid"],
- user_uid=self.session.get("uid"),
- is_moderator=False,
- is_read_only=False,
- is_muted=False,
- is_banned=False,
- )
-
- return web.HTTPNotFound()
-
- channel_member["new_count"] = 0
- await self.app.services.channel_member.save(channel_member)
-
- user = await self.services.user.get(uid=self.session.get("uid"))
-
- messages = [
- await self.app.services.channel_message.to_extended_dict(message)
- for message in await self.app.services.channel_message.offset(
- channel["uid"]
- )
- ]
- for message in messages:
- await self.app.services.notification.mark_as_read(
- self.session.get("uid"), message["uid"]
- )
- name = await channel_member.get_name()
+
+ forums = []
+ async for forum in self.services.forum.get_active_forums():
+ forums.append({
+ "uid": forum["uid"],
+ "name": forum["name"],
+ "description": forum["description"],
+ "slug": forum["slug"],
+ "icon": forum["icon"],
+ "thread_count": forum["thread_count"],
+ "post_count": forum["post_count"],
+ "last_post_at": forum["last_post_at"],
+ "last_thread_uid": forum["last_thread_uid"]
+ })
+
return await self.render_template(
"forum.html",
- {"name": name, "channel": channel, "user": user, "messages": messages},
+ {
+ "forums_json": json.dumps(forums),
+ "user": await self.services.user.get(self.session.get("uid"))
+ },
)
@@ -80,11 +50,9 @@ class ForumView(BaseView):
login_required = True
async def get_forums(self):
- request = self
- self = request.app
"""GET /forum/api/forums - Get all active forums"""
forums = []
- async for forum in self.services.forum.get_active_forums():
+ async for forum in self.app.services.forum.get_active_forums():
forums.append({
"uid": forum["uid"],
"name": forum["name"],
@@ -99,28 +67,25 @@ class ForumView(BaseView):
return web.json_response({"forums": forums})
async def get_forum(self):
- request = self
- self = request.app
- setattr(self, "request", request)
"""GET /forum/api/forums/:slug - Get forum by slug"""
- slug = self.request.match_info["slug"]
- forum = await self.services.forum.get(slug=slug, is_active=True)
+ slug = self.match_info["slug"]
+ forum = await self.app.services.forum.get(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
# Get threads
threads = []
- page = int(self.request.query.get("page", 1))
+ page = int(self.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
async for thread in forum.get_threads(limit=limit, offset=offset):
# Get author info
- author = await self.services.user.get(uid=thread["created_by_uid"])
+ author = await self.app.services.user.get(uid=thread["created_by_uid"])
last_post_author = None
if thread["last_post_by_uid"]:
- last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"])
+ last_post_author = await self.app.services.user.get(uid=thread["last_post_by_uid"])
threads.append({
"uid": thread["uid"],
@@ -162,21 +127,17 @@ class ForumView(BaseView):
})
async def create_thread(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""POST /forum/api/forums/:slug/threads - Create new thread"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- slug = self.request.match_info["slug"]
- forum = await self.services.forum.get(slug=slug, is_active=True)
+ slug = self.match_info["slug"]
+ forum = await self.app.services.forum.get(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
- data = await self.request.json()
+ data = await self.json()
title = data.get("title", "").strip()
content = data.get("content", "").strip()
@@ -184,11 +145,11 @@ class ForumView(BaseView):
return web.json_response({"error": "Title and content required"}, status=400)
try:
- thread, post = await self.services.thread.create_thread(
+ thread, post = await self.app.services.thread.create_thread(
forum_uid=forum["uid"],
title=title,
content=content,
- created_by_uid=self.request.session["uid"]
+ created_by_uid=self.session["uid"]
)
return web.json_response({
@@ -202,13 +163,9 @@ class ForumView(BaseView):
return web.json_response({"error": str(e)}, status=400)
async def get_thread(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""GET /forum/api/threads/:thread_slug - Get thread with posts"""
- thread_slug = self.request.match_info["thread_slug"]
- thread = await self.services.thread.get(slug=thread_slug)
+ thread_slug = self.match_info["thread_slug"]
+ thread = await self.app.services.thread.get(slug=thread_slug)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
@@ -217,15 +174,15 @@ class ForumView(BaseView):
await thread.increment_view_count()
# Get forum
- forum = await self.services.forum.get(uid=thread["forum_uid"])
+ forum = await self.app.services.forum.get(uid=thread["forum_uid"])
# Get posts
posts = []
- page = int(self.request.query.get("page", 1))
+ page = int(self.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
- current_user_uid = self.request.session.get("uid")
+ current_user_uid = self.session.get("uid")
async for post in thread.get_posts(limit=limit, offset=offset):
author = await post.get_author()
@@ -250,7 +207,7 @@ class ForumView(BaseView):
})
# Get thread author
- thread_author = await self.services.user.get(uid=thread["created_by_uid"])
+ thread_author = await self.app.services.user.get(uid=thread["created_by_uid"])
return web.json_response({
"thread": {
@@ -280,32 +237,28 @@ class ForumView(BaseView):
})
async def create_post(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/posts - Create new post"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- thread_uid = self.request.match_info["thread_uid"]
- thread = await self.services.thread.get(uid=thread_uid)
+ thread_uid = self.match_info["thread_uid"]
+ thread = await self.app.services.thread.get(uid=thread_uid)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
- data = await self.request.json()
+ data = await self.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
try:
- post = await self.services.post.create_post(
+ post = await self.app.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=thread["forum_uid"],
content=content,
- created_by_uid=self.request.session["uid"]
+ created_by_uid=self.session["uid"]
)
author = await post.get_author()
@@ -329,25 +282,21 @@ class ForumView(BaseView):
return web.json_response({"error": str(e)}, status=400)
async def edit_post(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""PUT /forum/api/posts/:post_uid - Edit post"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- post_uid = self.request.match_info["post_uid"]
- data = await self.request.json()
+ post_uid = self.match_info["post_uid"]
+ data = await self.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
- post = await self.services.post.edit_post(
+ post = await self.app.services.post.edit_post(
post_uid=post_uid,
content=content,
- user_uid=self.request.session["uid"]
+ user_uid=self.session["uid"]
)
if not post:
@@ -362,19 +311,15 @@ class ForumView(BaseView):
})
async def delete_post(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""DELETE /forum/api/posts/:post_uid - Delete post"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- post_uid = self.request.match_info["post_uid"]
+ post_uid = self.match_info["post_uid"]
- success = await self.services.post.delete_post(
+ success = await self.app.services.post.delete_post(
post_uid=post_uid,
- user_uid=self.request.session["uid"]
+ user_uid=self.session["uid"]
)
if not success:
@@ -383,26 +328,22 @@ class ForumView(BaseView):
return web.json_response({"success": True})
async def toggle_like(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""POST /forum/api/posts/:post_uid/like - Toggle post like"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- post_uid = self.request.match_info["post_uid"]
+ post_uid = self.match_info["post_uid"]
- is_liked = await self.services.post_like.toggle_like(
+ is_liked = await self.app.services.post_like.toggle_like(
post_uid=post_uid,
- user_uid=self.request.session["uid"]
+ user_uid=self.session["uid"]
)
if is_liked is None:
return web.json_response({"error": "Failed to toggle like"}, status=400)
# Get updated post
- post = await self.services.post.get(uid=post_uid)
+ post = await self.app.services.post.get(uid=post_uid)
return web.json_response({
"is_liked": is_liked,
@@ -410,19 +351,15 @@ class ForumView(BaseView):
})
async def toggle_pin(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- thread_uid = self.request.match_info["thread_uid"]
+ thread_uid = self.match_info["thread_uid"]
- thread = await self.services.thread.toggle_pin(
+ thread = await self.app.services.thread.toggle_pin(
thread_uid=thread_uid,
- user_uid=self.request.session["uid"]
+ user_uid=self.session["uid"]
)
if not thread:
@@ -431,19 +368,15 @@ class ForumView(BaseView):
return web.json_response({"is_pinned": thread["is_pinned"]})
async def toggle_lock(self):
- request = self
- self = request.app
-
- setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
- if not self.request.session.get("logged_in"):
+ if not self.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
- thread_uid = self.request.match_info["thread_uid"]
+ thread_uid = self.match_info["thread_uid"]
- thread = await self.services.thread.toggle_lock(
+ thread = await self.app.services.thread.toggle_lock(
thread_uid=thread_uid,
- user_uid=self.request.session["uid"]
+ user_uid=self.session["uid"]
)
if not thread:
diff --git a/src/snek/view/git_docs.py b/src/snek/view/git_docs.py
new file mode 100644
index 0000000..ec8004e
--- /dev/null
+++ b/src/snek/view/git_docs.py
@@ -0,0 +1,10 @@
+import logging
+
+from snek.system.view import BaseView
+
+logger = logging.getLogger(__name__)
+
+class GitIntegrationDocsView(BaseView):
+
+ async def get(self):
+ return await self.render_template("GIT_INTEGRATION.md")
diff --git a/src/snek/view/profile_page.py b/src/snek/view/profile_page.py
new file mode 100644
index 0000000..8e12c22
--- /dev/null
+++ b/src/snek/view/profile_page.py
@@ -0,0 +1,45 @@
+import logging
+from aiohttp import web
+from snek.system.view import BaseView
+
+logger = logging.getLogger(__name__)
+
+class ProfilePageView(BaseView):
+ login_required = False
+
+ async def get(self):
+ user_uid = self.request.match_info.get("user_uid")
+ slug = self.request.match_info.get("slug")
+
+ user = await self.services.user.get(uid=user_uid, deleted_at=None)
+ if not user:
+ user = await self.services.user.get(username=user_uid, deleted_at=None)
+ if not user:
+ raise web.HTTPNotFound()
+
+ page = await self.services.profile_page.get(
+ user_uid=user["uid"],
+ slug=slug,
+ deleted_at=None
+ )
+
+ if not page:
+ raise web.HTTPNotFound()
+
+ if not page["is_published"]:
+ if not self.session.get("uid") or self.session.get("uid") != user["uid"]:
+ raise web.HTTPNotFound()
+
+ all_pages = await self.services.profile_page.get_user_pages(
+ user["uid"],
+ include_unpublished=False
+ )
+
+ return await self.render_template(
+ "profile_page.html",
+ {
+ "page": page,
+ "profile_user": user,
+ "all_pages": all_pages
+ }
+ )
diff --git a/src/snek/view/repository.py b/src/snek/view/repository.py
index b47c3b4..05c4f6d 100644
--- a/src/snek/view/repository.py
+++ b/src/snek/view/repository.py
@@ -1,189 +1,33 @@
import asyncio
+import base64
import mimetypes
import os
import urllib.parse
+from datetime import datetime
from pathlib import Path
+import git
import humanize
from aiohttp import web
from snek.system.view import BaseView
-class BareRepoNavigator:
-
- login_required = True
-
- def __init__(self, repo_path):
- """Initialize the navigator with a bare repository path."""
- try:
- self.repo = Repo(repo_path)
- if not self.repo.bare:
- print(f"Error: {repo_path} is not a bare repository.")
- sys.exit(1)
- except git.exc.InvalidGitRepositoryError:
- print(f"Error: {repo_path} is not a valid Git repository.")
- sys.exit(1)
- except Exception as e:
- print(f"Error opening repository: {str(e)}")
- sys.exit(1)
-
- self.repo_path = repo_path
- self.branches = list(self.repo.branches)
- self.current_branch = None
- self.current_commit = None
- self.current_path = ""
- self.history = []
-
- def get_branches(self):
- """Return a list of branch names in the repository."""
- return [branch.name for branch in self.branches]
-
- def set_branch(self, branch_name):
- """Set the current branch."""
- try:
- self.current_branch = self.repo.branches[branch_name]
- self.current_commit = self.current_branch.commit
- self.current_path = ""
- self.history = []
- return True
- except IndexError:
- return False
-
- def get_commits(self, count=10):
- """Get the latest commits on the current branch."""
- if not self.current_branch:
- return []
-
- commits = []
- for commit in self.repo.iter_commits(self.current_branch, max_count=count):
- commits.append(
- {
- "hash": commit.hexsha,
- "short_hash": commit.hexsha[:7],
- "message": commit.message.strip(),
- "author": commit.author.name,
- "date": datetime.fromtimestamp(commit.committed_date).strftime(
- "%Y-%m-%d %H:%M:%S"
- ),
- }
- )
- return commits
-
- def set_commit(self, commit_hash):
- """Set the current commit by hash."""
- try:
- self.current_commit = self.repo.commit(commit_hash)
- self.current_path = ""
- self.history = []
- return True
- except ValueError:
- return False
-
- def list_directory(self, path=""):
- """List the contents of a directory in the current commit."""
- if not self.current_commit:
- return {"dirs": [], "files": []}
-
- dirs = []
- files = []
-
- try:
- # Get the tree at the current path
- if path:
- tree = self.current_commit.tree[path]
- if not hasattr(tree, "trees"): # It's a blob, not a tree
- return {"dirs": [], "files": [path]}
- else:
- tree = self.current_commit.tree
-
- # List directories and files
- for item in tree:
- if item.type == "tree":
- item_path = os.path.join(path, item.name) if path else item.name
- dirs.append(item_path)
- elif item.type == "blob":
- item_path = os.path.join(path, item.name) if path else item.name
- files.append(item_path)
-
- dirs.sort()
- files.sort()
- return {"dirs": dirs, "files": files}
-
- except KeyError:
- return {"dirs": [], "files": []}
-
- def get_file_content(self, file_path):
- """Get the content of a file in the current commit."""
- if not self.current_commit:
- return None
-
- try:
- blob = self.current_commit.tree[file_path]
- return blob.data_stream.read().decode("utf-8", errors="replace")
- except (KeyError, UnicodeDecodeError):
- try:
- # Try to get as binary if text decoding fails
- blob = self.current_commit.tree[file_path]
- return blob.data_stream.read()
- except:
- return None
-
- def navigate_to(self, path):
- """Navigate to a specific path, updating the current path."""
- if not self.current_commit:
- return False
-
- try:
- if path:
- self.current_commit.tree[path] # Check if path exists
- self.history.append(self.current_path)
- self.current_path = path
- return True
- except KeyError:
- return False
-
- def navigate_back(self):
- """Navigate back to the previous path."""
- if self.history:
- self.current_path = self.history.pop()
- return True
- return False
-
-
class RepositoryView(BaseView):
login_required = True
- def checkout_bare_repo(
- self, bare_repo_path: Path, target_path: Path, ref: str = "HEAD"
- ):
- repo = Repo(bare_repo_path)
- assert repo.bare, "Repository is not bare."
-
- commit = repo.commit(ref)
- tree = commit.tree
-
- for blob in tree.traverse():
- target_file = target_path / blob.path
-
- target_file.parent.mkdir(parents=True, exist_ok=True)
- print(blob.path)
-
- with open(target_file, "wb") as f:
- f.write(blob.data_stream.read())
-
async def get(self):
-
- base_repo_path = Path("drive/repositories")
-
authenticated_user_id = self.session.get("uid")
-
username = self.request.match_info.get("username")
repo_name = self.request.match_info.get("repository")
rel_path = self.request.match_info.get("path", "")
+
+ branch = self.request.query.get("branch", "")
+ commit_hash = self.request.query.get("commit", "")
+
user = None
- if not username.count("-") == 4:
+ if not username or username.count("-") != 4:
user = await self.app.services.user.get(username=username)
if not user:
return web.Response(text="404 Not Found", status=404)
@@ -191,99 +35,262 @@ class RepositoryView(BaseView):
else:
user = await self.app.services.user.get(uid=username)
+ if not user:
+ return web.Response(text="404 Not Found", status=404)
+
repo = await self.app.services.repository.get(
name=repo_name, user_uid=user["uid"]
)
if not repo:
return web.Response(text="404 Not Found", status=404)
- if repo["is_private"] and authenticated_user_id != repo["uid"]:
- return web.Response(text="404 Not Found", status=404)
- repo_root_base = (base_repo_path / user["uid"] / (repo_name + ".git")).resolve()
- repo_root = (base_repo_path / user["uid"] / repo_name).resolve()
+ if repo["is_private"] and authenticated_user_id != user["uid"]:
+ return web.Response(text="403 Forbidden", status=403)
+
+ repo_path = (
+ await self.app.services.user.get_repository_path(user["uid"])
+ ) / (repo_name + ".git")
+
+ if not repo_path.exists():
+ return web.Response(text="404 Repository Not Found", status=404)
+
try:
- loop = asyncio.get_event_loop()
- await loop.run_in_executor(
- None, self.checkout_bare_repo, repo_root_base, repo_root
+ git_repo = git.Repo(repo_path)
+ except Exception:
+ return web.Response(text="500 Invalid Repository", status=500)
+
+ if not git_repo.bare:
+ return web.Response(text="500 Repository must be bare", status=500)
+
+ try:
+ branches = [b.name for b in git_repo.branches]
+ except Exception:
+ branches = []
+
+ if not branches:
+ return await self.render_template(
+ "repository_empty.html",
+ {
+ "user": user.record,
+ "repo": repo.record,
+ "repo_name": repo_name,
+ "username": username,
+ "clone_url": self.get_clone_url(username, repo_name),
+ },
)
- except:
- pass
- if not repo_root.exists() or not repo_root.is_dir():
- return web.Response(text="404 Not Found", status=404)
+ if commit_hash:
+ try:
+ current_commit = git_repo.commit(commit_hash)
+ current_branch = commit_hash[:7]
+ except Exception:
+ return web.Response(text="404 Commit Not Found", status=404)
+ elif branch:
+ try:
+ current_commit = git_repo.branches[branch].commit
+ current_branch = branch
+ except Exception:
+ return web.Response(text="404 Branch Not Found", status=404)
+ else:
+ try:
+ current_branch = git_repo.active_branch.name
+ current_commit = git_repo.active_branch.commit
+ except Exception:
+ try:
+ current_branch = branches[0]
+ current_commit = git_repo.branches[branches[0]].commit
+ except Exception:
+ current_branch = "HEAD"
+ current_commit = git_repo.head.commit
- safe_rel_path = os.path.normpath(rel_path).lstrip(os.sep)
- abs_path = (repo_root / safe_rel_path).resolve()
+ if not rel_path:
+ commits = []
+ try:
+ for commit in list(
+ git_repo.iter_commits(current_commit, max_count=10)
+ ):
+ commits.append(
+ {
+ "hash": commit.hexsha,
+ "short_hash": commit.hexsha[:7],
+ "message": commit.message.strip().split("\n")[0],
+ "author": commit.author.name,
+ "date": datetime.fromtimestamp(
+ commit.committed_date
+ ).strftime("%Y-%m-%d %H:%M"),
+ }
+ )
+ except Exception:
+ commits = []
- if not abs_path.exists() or not abs_path.is_relative_to(repo_root):
- return web.Response(text="404 Not Found", status=404)
+ try:
+ tree = current_commit.tree
+ items = []
- if abs_path.is_dir():
- return web.Response(
- text=self.render_directory(
- abs_path, username, repo_name, safe_rel_path
- ),
- content_type="text/html",
+ for item in tree:
+ items.append(
+ {
+ "name": item.name,
+ "path": item.path,
+ "type": item.type,
+ "size": item.size if item.type == "blob" else 0,
+ "is_dir": item.type == "tree",
+ }
+ )
+
+ items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
+
+ except Exception:
+ items = []
+
+ readme_content = None
+ try:
+ for readme_name in ["README.md", "README", "README.txt", "readme.md"]:
+ try:
+ blob = current_commit.tree[readme_name]
+ content = blob.data_stream.read().decode("utf-8", errors="replace")
+ if readme_name.endswith(".md"):
+ import mistune
+ readme_content = mistune.html(content)
+ else:
+ readme_content = f"{content}"
+ break
+ except Exception:
+ continue
+ except Exception:
+ pass
+
+ return await self.render_template(
+ "repository_overview.html",
+ {
+ "user": user.record,
+ "repo": repo.record,
+ "repo_name": repo_name,
+ "username": username,
+ "branches": branches,
+ "current_branch": current_branch,
+ "commits": commits,
+ "items": items,
+ "rel_path": "",
+ "clone_url": self.get_clone_url(username, repo_name),
+ "readme_content": readme_content,
+ },
)
else:
- return web.Response(
- text=self.render_file(abs_path), content_type="text/html"
- )
+ try:
+ tree = current_commit.tree
+ item = tree[rel_path]
- def render_directory(self, abs_path, username, repo_name, safe_rel_path):
- entries = sorted(
- abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
- )
- items = []
+ if item.type == "tree":
+ items = []
+ for child in item:
+ items.append(
+ {
+ "name": child.name,
+ "path": child.path,
+ "type": child.type,
+ "size": child.size if child.type == "blob" else 0,
+ "is_dir": child.type == "tree",
+ }
+ )
- if safe_rel_path:
- parent_path = Path(safe_rel_path).parent
- parent_link = f"/repository/{username}/{repo_name}/{parent_path}".rstrip(
- "/"
- )
- items.append(f'⬅️ ..')
+ items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
- for entry in entries:
- link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))
- link = f"/repository/{username}/{repo_name}/{link_path}".rstrip("/")
- display = entry.name + ("/" if entry.is_dir() else "")
- size = "" if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)
- icon = self.get_icon(entry)
- items.append(f'{icon} {display} {size}')
+ parent_path = str(Path(rel_path).parent) if rel_path != "." else ""
+ if parent_path == ".":
+ parent_path = ""
- html = f"""
-
- 📁 {repo_name}/{safe_rel_path}
-
- 📁 {username}/{repo_name}/{safe_rel_path}
-
-
-
- """
- return html
+ return await self.render_template(
+ "repository_tree.html",
+ {
+ "user": user.record,
+ "repo": repo.record,
+ "repo_name": repo_name,
+ "username": username,
+ "branches": branches,
+ "current_branch": current_branch,
+ "items": items,
+ "rel_path": rel_path,
+ "parent_path": parent_path,
+ "clone_url": self.get_clone_url(username, repo_name),
+ },
+ )
+ else:
+ content = item.data_stream.read()
- def render_file(self, abs_path):
- try:
- with open(abs_path, encoding="utf-8", errors="ignore") as f:
- content = f.read()
- return f"{content}"
- except Exception as e:
- return f"Error
{e}"
+ try:
+ text_content = content.decode("utf-8")
+ is_binary = False
+ except UnicodeDecodeError:
+ text_content = None
+ is_binary = True
- def get_icon(self, file):
- if file.is_dir():
- return "📁"
- mime = mimetypes.guess_type(file.name)[0] or ""
- if mime.startswith("image"):
- return "🖼️"
- if mime.startswith("text"):
- return "📄"
- if mime.startswith("audio"):
- return "🎵"
- if mime.startswith("video"):
- return "🎬"
- if file.name.endswith(".py"):
- return "🐍"
- return "📦"
+ mime_type = mimetypes.guess_type(item.name)[0] or "text/plain"
+
+ if is_binary:
+ if mime_type.startswith("image/"):
+ data_uri = f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
+ return await self.render_template(
+ "repository_file.html",
+ {
+ "user": user.record,
+ "repo": repo.record,
+ "repo_name": repo_name,
+ "username": username,
+ "branches": branches,
+ "current_branch": current_branch,
+ "file_path": rel_path,
+ "file_name": item.name,
+ "is_binary": True,
+ "is_image": True,
+ "image_data": data_uri,
+ "file_size": humanize.naturalsize(len(content)),
+ },
+ )
+ else:
+ return await self.render_template(
+ "repository_file.html",
+ {
+ "user": user.record,
+ "repo": repo.record,
+ "repo_name": repo_name,
+ "username": username,
+ "branches": branches,
+ "current_branch": current_branch,
+ "file_path": rel_path,
+ "file_name": item.name,
+ "is_binary": True,
+ "is_image": False,
+ "file_size": humanize.naturalsize(len(content)),
+ },
+ )
+
+ lines = text_content.split("\n")
+
+ return await self.render_template(
+ "repository_file.html",
+ {
+ "user": user.record,
+ "repo": repo.record,
+ "repo_name": repo_name,
+ "username": username,
+ "branches": branches,
+ "current_branch": current_branch,
+ "file_path": rel_path,
+ "file_name": item.name,
+ "is_binary": False,
+ "content": text_content,
+ "lines": lines,
+ "line_count": len(lines),
+ "file_size": humanize.naturalsize(len(content)),
+ },
+ )
+
+ except KeyError:
+ return web.Response(text="404 Path Not Found", status=404)
+ except Exception as e:
+ return web.Response(text=f"500 Error: {str(e)}", status=500)
+
+ def get_clone_url(self, username, repo_name):
+ host = self.request.host
+ return f"http://{host}/git/{username}/{repo_name}.git"
diff --git a/src/snek/view/settings/profile_pages.py b/src/snek/view/settings/profile_pages.py
new file mode 100644
index 0000000..b6f67c4
--- /dev/null
+++ b/src/snek/view/settings/profile_pages.py
@@ -0,0 +1,128 @@
+import logging
+from aiohttp import web
+from snek.system.view import BaseView
+from snek.system.exceptions import ValidationError, DuplicateResourceError
+
+logger = logging.getLogger(__name__)
+
+class ProfilePagesView(BaseView):
+ login_required = True
+
+ async def get(self):
+ pages = await self.services.profile_page.get_user_pages(
+ self.session.get("uid"),
+ include_unpublished=True
+ )
+ return await self.render_template(
+ "settings/profile_pages/index.html",
+ {"pages": pages}
+ )
+
+class ProfilePageCreateView(BaseView):
+ login_required = True
+
+ async def get(self):
+ return await self.render_template("settings/profile_pages/create.html", {})
+
+ async def post(self):
+ data = await self.request.post()
+ title = data.get("title", "").strip()
+ content = data.get("content", "")
+ is_published = data.get("is_published") == "on"
+
+ if not title:
+ return await self.render_template(
+ "settings/profile_pages/create.html",
+ {"error": "Title is required"}
+ )
+
+ try:
+ await self.services.profile_page.create_page(
+ user_uid=self.session.get("uid"),
+ title=title,
+ content=content,
+ is_published=is_published
+ )
+ return web.HTTPFound("/settings/profile_pages/index.html")
+ except DuplicateResourceError as e:
+ return await self.render_template(
+ "settings/profile_pages/create.html",
+ {"error": str(e), "title": title, "content": content}
+ )
+
+class ProfilePageEditView(BaseView):
+ login_required = True
+
+ async def get(self):
+ page_uid = self.request.match_info.get("page_uid")
+ page = await self.services.profile_page.get(uid=page_uid, deleted_at=None)
+
+ if not page or page["user_uid"] != self.session.get("uid"):
+ raise web.HTTPNotFound()
+
+ return await self.render_template(
+ "settings/profile_pages/edit.html",
+ {"page": page}
+ )
+
+ async def post(self):
+ page_uid = self.request.match_info.get("page_uid")
+ page = await self.services.profile_page.get(uid=page_uid, deleted_at=None)
+
+ if not page or page["user_uid"] != self.session.get("uid"):
+ raise web.HTTPNotFound()
+
+ data = await self.request.post()
+ title = data.get("title", "").strip()
+ content = data.get("content", "")
+ is_published = data.get("is_published") == "on"
+
+ if not title:
+ return await self.render_template(
+ "settings/profile_pages/edit.html",
+ {"page": page, "error": "Title is required"}
+ )
+
+ try:
+ await self.services.profile_page.update_page(
+ page_uid=page_uid,
+ title=title,
+ content=content,
+ is_published=is_published
+ )
+ return web.HTTPFound("/settings/profile_pages/index.html")
+ except DuplicateResourceError as e:
+ page["title"] = title
+ page["content"] = content
+ page["is_published"] = is_published
+ return await self.render_template(
+ "settings/profile_pages/edit.html",
+ {"page": page, "error": str(e)}
+ )
+
+class ProfilePageDeleteView(BaseView):
+ login_required = True
+
+ async def post(self):
+ page_uid = self.request.match_info.get("page_uid")
+ page = await self.services.profile_page.get(uid=page_uid, deleted_at=None)
+
+ if not page or page["user_uid"] != self.session.get("uid"):
+ raise web.HTTPNotFound()
+
+ await self.services.profile_page.delete_page(page_uid)
+ return web.HTTPFound("/settings/profile_pages/index.html")
+
+class ProfilePageReorderView(BaseView):
+ login_required = True
+
+ async def post(self):
+ data = await self.request.json()
+ page_uids = data.get("page_uids", [])
+
+ await self.services.profile_page.reorder_pages(
+ user_uid=self.session.get("uid"),
+ page_uids=page_uids
+ )
+
+ return web.json_response({"success": True})
diff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py
index dcc945b..fab44cc 100644
--- a/src/snek/view/settings/repositories.py
+++ b/src/snek/view/settings/repositories.py
@@ -36,6 +36,7 @@ class RepositoriesCreateView(BaseFormView):
await self.services.repository.create(
user_uid=self.session.get("uid"),
name=data["name"],
+ description=data.get("description", ""),
is_private=int(data.get("is_private", 0)),
)
return web.HTTPFound("/settings/repositories/index.html")
@@ -61,6 +62,7 @@ class RepositoriesUpdateView(BaseFormView):
repository = await self.services.repository.get(
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
)
+ repository["description"] = data.get("description", "")
repository["is_private"] = int(data.get("is_private", 0))
await self.services.repository.save(repository)
return web.HTTPFound("/settings/repositories/index.html")
diff --git a/src/snek/view/user.py b/src/snek/view/user.py
index 4eac05c..043b1c1 100644
--- a/src/snek/view/user.py
+++ b/src/snek/view/user.py
@@ -11,7 +11,16 @@ class UserView(BaseView):
profile_content = (
await self.services.user_property.get(user["uid"], "profile") or ""
)
+ profile_pages = await self.services.profile_page.get_user_pages(
+ user["uid"],
+ include_unpublished=False
+ )
return await self.render_template(
"user.html",
- {"user_uid": user_uid, "user": user.record, "profile": profile_content},
+ {
+ "user_uid": user_uid,
+ "user": user.record,
+ "profile": profile_content,
+ "profile_pages": profile_pages
+ },
)