Update.
This commit is contained in:
parent
e4ebd8b4fd
commit
8db6f39046
@ -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"
|
||||
]
|
||||
|
||||
17
pytest.ini
Normal file
17
pytest.ini
Normal file
@ -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
|
||||
@ -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(
|
||||
|
||||
@ -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 = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Forum</title>
|
||||
<script type="module" src="/forum/static/snek-forum.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<snek-forum></snek-forum>
|
||||
</body>
|
||||
</html>"""
|
||||
return await self.parent.render_template("forum.html", request)
|
||||
|
||||
|
||||
#return aiohttp.web.Response(text=html, content_type="text/html")
|
||||
|
||||
|
||||
# Integration with main app
|
||||
|
||||
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
9
src/snek/mapper/profile_page.py
Normal file
9
src/snek/mapper/profile_page.py
Normal file
@ -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
|
||||
12
src/snek/model/profile_page.py
Normal file
12
src/snek/model/profile_page.py
Normal file
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
85
src/snek/service/profile_page.py
Normal file
85
src/snek/service/profile_page.py
Normal file
@ -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)
|
||||
@ -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)
|
||||
|
||||
233
src/snek/sgit.py
233
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):
|
||||
|
||||
108
src/snek/system/debug.py
Normal file
108
src/snek/system/debug.py
Normal file
@ -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
|
||||
55
src/snek/system/exception_middleware.py
Normal file
55
src/snek/system/exception_middleware.py
Normal file
@ -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
|
||||
)
|
||||
20
src/snek/system/exceptions.py
Normal file
20
src/snek/system/exceptions.py
Normal file
@ -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
|
||||
244
src/snek/templates/GIT_INTEGRATION.md
Normal file
244
src/snek/templates/GIT_INTEGRATION.md
Normal file
@ -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
|
||||
@ -42,7 +42,7 @@
|
||||
<a class="no-select" href="/drive.html">📂</a>
|
||||
<a class="no-select" href="/search-user.html">🔍</a>
|
||||
<a class="no-select" style="display:none" id="install-button" href="#">📥</a>
|
||||
<a class="no-select" href="/threads.html">👥</a>
|
||||
<a class="no-select" href="/forum/index.html">💬</a>
|
||||
<a class="no-select" href="/settings/index.html">⚙️</a>
|
||||
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
|
||||
<a class="no-select" href="/logout.html">🔒</a>
|
||||
|
||||
@ -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(`<a style="display: none;" href="#" data-action="forums">FORUMS</a>`);
|
||||
crumb.push(`<a href="#" data-action="forums">FORUMS</a>`);
|
||||
if (this.currentView === "forum" && this.currentForum) {
|
||||
crumb.push(`<span>›</span>`);
|
||||
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
|
||||
@ -708,7 +717,7 @@ class SnekForum extends HTMLElement {
|
||||
crumb.push(`<span>›</span>`);
|
||||
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
|
||||
}
|
||||
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);
|
||||
</script>
|
||||
<script>
|
||||
window.preloadedForums = {{ forums_json|safe }};
|
||||
</script>
|
||||
<snek-forum></snek-forum>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
35
src/snek/templates/profile_page.html
Normal file
35
src/snek/templates/profile_page.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside class="sidebar" id="channelSidebar">
|
||||
<h2>Navigation</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/user/{{ profile_user.uid }}.html">Back to Profile</a></li>
|
||||
</ul>
|
||||
<h2>Pages</h2>
|
||||
<ul>
|
||||
{% for p in all_pages %}
|
||||
<li>
|
||||
<a class="no-select" href="/user/{{ profile_user.uid }}/{{ p.slug }}.html"
|
||||
{% if p.uid == page.uid %}style="font-weight: bold;"{% endif %}>
|
||||
{{ p.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>No pages</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% endblock %}
|
||||
|
||||
{% block header_text %}<h2 style="color:#fff">{{ page.title }}</h2>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<section class="chat-area" style="padding:10px">
|
||||
{% autoescape false %}
|
||||
{% markdown %}
|
||||
{{ page.content }}
|
||||
{% endmarkdown %}
|
||||
{% endautoescape %}
|
||||
</section>
|
||||
{% endblock main %}
|
||||
26
src/snek/templates/repository_empty.html
Normal file
26
src/snek/templates/repository_empty.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<h2>Repository is Empty</h2>
|
||||
<p>This repository has been created but contains no branches or commits yet.</p>
|
||||
|
||||
<h2>Quick Start</h2>
|
||||
<p>Create a new repository on the command line:</p>
|
||||
<pre>git init
|
||||
git add README.md
|
||||
git commit -m "Initial commit"
|
||||
git remote add origin {{ clone_url }}
|
||||
git push -u origin main</pre>
|
||||
|
||||
<p>Push an existing repository:</p>
|
||||
<pre>git remote add origin {{ clone_url }}
|
||||
git push -u origin main</pre>
|
||||
|
||||
<p><a href="/settings/repositories/index.html">← Back to Repositories</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
46
src/snek/templates/repository_file.html
Normal file
46
src/snek/templates/repository_file.html
Normal file
@ -0,0 +1,46 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<p>
|
||||
<strong>Branch:</strong>
|
||||
<select onchange="window.location.href='/repository/{{ username }}/{{ repo_name }}/{{ file_path }}?branch=' + this.value">
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch }}" {% if branch == current_branch %}selected{% endif %}>{{ branch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="/repository/{{ username }}/{{ repo_name }}?branch={{ current_branch }}">{{ repo_name }}</a>
|
||||
{% 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 %}<strong>{{ part }}</strong>{% else %}<a href="/repository/{{ username }}/{{ repo_name }}/{{ cumulative }}?branch={{ current_branch }}">{{ part }}</a>{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<p><strong>{{ file_name }}</strong> ({{ file_size }})</p>
|
||||
|
||||
{% if is_binary %}
|
||||
{% if is_image %}
|
||||
<img src="{{ image_data }}" alt="{{ file_name }}" style="max-width: 100%;">
|
||||
{% else %}
|
||||
<p>Binary file - cannot display content</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<pre>{{ content }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
59
src/snek/templates/repository_overview.html
Normal file
59
src/snek/templates/repository_overview.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
{% if repo.description %}
|
||||
<p>{{ repo.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<strong>Branch:</strong>
|
||||
<select onchange="window.location.href='/repository/{{ username }}/{{ repo_name }}?branch=' + this.value">
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch }}" {% if branch == current_branch %}selected{% endif %}>{{ branch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<p><strong>Clone URL:</strong></p>
|
||||
<pre>{{ clone_url }}</pre>
|
||||
|
||||
<h2>Recent Commits</h2>
|
||||
{% if commits %}
|
||||
<ul>
|
||||
{% for commit in commits %}
|
||||
<li>
|
||||
<code>{{ commit.short_hash }}</code> {{ commit.message }} - {{ commit.author }} ({{ commit.date }})
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No commits found.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Files</h2>
|
||||
<ul>
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
<li>
|
||||
<a href="/repository/{{ username }}/{{ repo_name }}/{{ item.path }}?branch={{ current_branch }}">
|
||||
{% if item.is_dir %}📁{% else %}📄{% endif %} {{ item.name }}
|
||||
</a>
|
||||
{% if not item.is_dir %}({{ item.size }} bytes){% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No files found.</p>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{% if readme_content %}
|
||||
<h2>README</h2>
|
||||
<div>{{ readme_content|safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
56
src/snek/templates/repository_tree.html
Normal file
56
src/snek/templates/repository_tree.html
Normal file
@ -0,0 +1,56 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block header_text %}{{ username }}/{{ repo_name }}{% endblock %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<p>
|
||||
<strong>Branch:</strong>
|
||||
<select onchange="window.location.href='/repository/{{ username }}/{{ repo_name }}/{{ rel_path }}?branch=' + this.value">
|
||||
{% for branch in branches %}
|
||||
<option value="{{ branch }}" {% if branch == current_branch %}selected{% endif %}>{{ branch }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="/repository/{{ username }}/{{ repo_name }}?branch={{ current_branch }}">{{ repo_name }}</a>
|
||||
{% 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 %}
|
||||
/ <a href="/repository/{{ username }}/{{ repo_name }}/{{ cumulative }}?branch={{ current_branch }}">{{ part }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
{% if parent_path != None %}
|
||||
<li>
|
||||
<a href="/repository/{{ username }}/{{ repo_name }}{% if parent_path %}/{{ parent_path }}{% endif %}?branch={{ current_branch }}">
|
||||
⬆️ ..
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
<li>
|
||||
<a href="/repository/{{ username }}/{{ repo_name }}/{{ item.path }}?branch={{ current_branch }}">
|
||||
{% if item.is_dir %}📁{% else %}📄{% endif %} {{ item.name }}
|
||||
</a>
|
||||
{% if not item.is_dir %}({{ item.size }} bytes){% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>No files found.</p>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
124
src/snek/templates/settings/profile_pages/create.html
Normal file
124
src/snek/templates/settings/profile_pages/create.html
Normal file
@ -0,0 +1,124 @@
|
||||
{% extends "settings/index.html" %}
|
||||
|
||||
{% block header_text %}<h2 style="color:#fff">Create Profile Page</h2>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<section style="padding: 10px; height: 100%; overflow: auto; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<a href="/settings/profile_pages/index.html" style="color: #f05a28; text-decoration: none;">
|
||||
Back to Pages
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div style="background: #c42; color: white; padding: 8px; margin-bottom: 10px;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="title" style="display: block; margin-bottom: 5px; font-weight: bold;">
|
||||
Page Title <span style="color: #c42;">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value="{{ title or '' }}"
|
||||
required
|
||||
placeholder="e.g., About Me, Projects, Blog"
|
||||
style="width: 100%; padding: 8px; background: #2d2d2d; border: 1px solid #444; color: #f0f0f0;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="is_published" style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_published"
|
||||
name="is_published"
|
||||
checked
|
||||
style="margin-right: 8px;"
|
||||
/>
|
||||
<span>Publish immediately</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
|
||||
<label for="content" style="display: block; margin-bottom: 5px; font-weight: bold;">
|
||||
Content (Markdown)
|
||||
</label>
|
||||
<textarea id="content" name="content" style="flex: 1;">{{ content or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; display: flex; gap: 10px;">
|
||||
<button type="submit" style="padding: 8px 16px; background: #f05a28; color: white; border: none; cursor: pointer;">
|
||||
Create Page
|
||||
</button>
|
||||
<a href="/settings/profile_pages/index.html" style="padding: 8px 16px; background: #444; color: white; text-decoration: none; display: inline-block;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script>
|
||||
const textarea = document.getElementById("content");
|
||||
const container = textarea.parentElement;
|
||||
const containerHeight = container.offsetHeight;
|
||||
const editorHeight = Math.max(200, containerHeight - 50);
|
||||
|
||||
const easyMDE = new EasyMDE({
|
||||
element: textarea,
|
||||
minHeight: editorHeight + "px",
|
||||
maxHeight: editorHeight + "px",
|
||||
autosave: {
|
||||
enabled: true,
|
||||
uniqueId: "new_profile_page",
|
||||
delay: 1000,
|
||||
},
|
||||
spellChecker: false,
|
||||
status: ["lines", "words"],
|
||||
placeholder: "Write your content here using Markdown...",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.EasyMDEContainer {
|
||||
background: #2d2d2d;
|
||||
color: #f0f0f0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
background: #2d2d2d;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .editor-toolbar {
|
||||
background: #252525;
|
||||
border: 1px solid #444;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .editor-toolbar button {
|
||||
color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .editor-toolbar button:hover {
|
||||
background: #3d3d3d;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
border-left-color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.editor-statusbar {
|
||||
color: #888 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
130
src/snek/templates/settings/profile_pages/edit.html
Normal file
130
src/snek/templates/settings/profile_pages/edit.html
Normal file
@ -0,0 +1,130 @@
|
||||
{% extends "settings/index.html" %}
|
||||
|
||||
{% block header_text %}<h2 style="color:#fff">Edit Profile Page</h2>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<section style="padding: 10px; height: 100%; overflow: auto; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<a href="/settings/profile_pages/index.html" style="color: #f05a28; text-decoration: none;">
|
||||
Back to Pages
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div style="background: #c42; color: white; padding: 8px; margin-bottom: 10px;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="title" style="display: block; margin-bottom: 5px; font-weight: bold;">
|
||||
Page Title <span style="color: #c42;">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value="{{ page.title }}"
|
||||
required
|
||||
placeholder="e.g., About Me, Projects, Blog"
|
||||
style="width: 100%; padding: 8px; background: #2d2d2d; border: 1px solid #444; color: #f0f0f0;"
|
||||
/>
|
||||
<p style="color: #888; font-size: 0.85em; margin-top: 3px;">
|
||||
Slug: {{ page.slug }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label for="is_published" style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_published"
|
||||
name="is_published"
|
||||
{% if page.is_published %}checked{% endif %}
|
||||
style="margin-right: 8px;"
|
||||
/>
|
||||
<span>Publish this page</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
|
||||
<label for="content" style="display: block; margin-bottom: 5px; font-weight: bold;">
|
||||
Content (Markdown)
|
||||
</label>
|
||||
<textarea id="content" name="content" style="flex: 1;">{{ page.content }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; display: flex; gap: 10px;">
|
||||
<button type="submit" style="padding: 8px 16px; background: #f05a28; color: white; border: none; cursor: pointer;">
|
||||
Save Changes
|
||||
</button>
|
||||
<a href="/user/{{ page.user_uid }}/{{ page.slug }}.html" style="padding: 8px 16px; background: #444; color: white; text-decoration: none; display: inline-block;">
|
||||
Preview
|
||||
</a>
|
||||
<a href="/settings/profile_pages/index.html" style="padding: 8px 16px; background: #444; color: white; text-decoration: none; display: inline-block;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<script>
|
||||
const textarea = document.getElementById("content");
|
||||
const container = textarea.parentElement;
|
||||
const containerHeight = container.offsetHeight;
|
||||
const editorHeight = Math.max(200, containerHeight - 50);
|
||||
|
||||
const easyMDE = new EasyMDE({
|
||||
element: textarea,
|
||||
minHeight: editorHeight + "px",
|
||||
maxHeight: editorHeight + "px",
|
||||
autosave: {
|
||||
enabled: true,
|
||||
uniqueId: "edit_profile_page_{{ page.uid }}",
|
||||
delay: 1000,
|
||||
},
|
||||
spellChecker: false,
|
||||
status: ["lines", "words"],
|
||||
placeholder: "Write your content here using Markdown...",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.EasyMDEContainer {
|
||||
background: #2d2d2d;
|
||||
color: #f0f0f0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
background: #2d2d2d;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .editor-toolbar {
|
||||
background: #252525;
|
||||
border: 1px solid #444;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .editor-toolbar button {
|
||||
color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .editor-toolbar button:hover {
|
||||
background: #3d3d3d;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
border-left-color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.editor-statusbar {
|
||||
color: #888 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
93
src/snek/templates/settings/profile_pages/index.html
Normal file
93
src/snek/templates/settings/profile_pages/index.html
Normal file
@ -0,0 +1,93 @@
|
||||
{% extends "settings/index.html" %}
|
||||
|
||||
{% block header_text %}<h2 style="color:#fff">Profile Pages</h2>{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<section style="padding: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">Your Profile Pages</h2>
|
||||
<a href="/settings/profile_pages/create.html" class="button" style="text-decoration: none;">
|
||||
<i class="fa-solid fa-plus"></i> New Page
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if pages %}
|
||||
<div id="pages-list">
|
||||
{% for page in pages %}
|
||||
<div class="page-item" data-uid="{{ page.uid }}" style="border: 1px solid #444; border-radius: 8px; padding: 15px; margin-bottom: 15px; background: #2d2d2d;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin: 0 0 5px 0;">
|
||||
{{ page.title }}
|
||||
{% if not page.is_published %}
|
||||
<span style="opacity: 0.6; font-size: 0.85em; font-weight: normal;">(Draft)</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p style="margin: 5px 0; color: #888; font-size: 0.9em;">
|
||||
Slug: <code style="background: #1d1d1d; padding: 2px 6px; border-radius: 3px;">{{ page.slug }}</code>
|
||||
</p>
|
||||
<p style="margin: 5px 0; color: #888; font-size: 0.9em;">
|
||||
Order: {{ page.order_index }}
|
||||
</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<a href="/user/{{ user.uid.value }}/{{ page.slug }}.html" class="button" style="text-decoration: none;">
|
||||
<i class="fa-solid fa-eye"></i> View
|
||||
</a>
|
||||
<a href="/settings/profile_pages/{{ page.uid }}/edit.html" class="button" style="text-decoration: none;">
|
||||
<i class="fa-solid fa-edit"></i> Edit
|
||||
</a>
|
||||
<form method="post" action="/settings/profile_pages/{{ page.uid }}/delete.html" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<button type="submit" class="button" style="background: #c42;">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding: 15px; background: #2d2d2d; border-radius: 8px; border: 1px solid #444;">
|
||||
<h3>Page Order</h3>
|
||||
<p style="color: #888;">Drag and drop pages to reorder them (coming soon), or use the order index field when editing.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; background: #2d2d2d; border-radius: 8px; border: 1px solid #444;">
|
||||
<p style="color: #888; font-size: 1.1em; margin-bottom: 20px;">You haven't created any profile pages yet.</p>
|
||||
<a href="/settings/profile_pages/create.html" class="button" style="text-decoration: none;">
|
||||
<i class="fa-solid fa-plus"></i> Create Your First Page
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #0b5ed7;
|
||||
}
|
||||
|
||||
.button i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.page-item:hover {
|
||||
background: #353535 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -10,14 +10,18 @@
|
||||
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Repository name">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description"><i class="fa-solid fa-align-left"></i> Description</label>
|
||||
<input type="text" id="description" name="description" placeholder="Repository description (optional)">
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" value="1">
|
||||
<i class="fa-solid fa-lock"></i> Private
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
<button type="submit"><i class="fa-solid fa-plus"></i> Create</button>
|
||||
<button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -75,8 +75,13 @@
|
||||
{% for repo in repositories %}
|
||||
<div class="repo-row">
|
||||
<div class="repo-info">
|
||||
<span class="repo-name"><i class="fa-solid fa-book"></i> {{ repo.name }}</span>
|
||||
|
||||
<div>
|
||||
<span class="repo-name"><i class="fa-solid fa-book"></i> {{ repo.name }}</span>
|
||||
{% if repo.description %}
|
||||
<div style="color: #888; font-size: 0.9rem; margin-top: 0.25rem;">{{ repo.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<span title="Public">
|
||||
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
|
||||
{% if repo.is_private %}Private{% else %}Public{% endif %}
|
||||
@ -86,9 +91,9 @@
|
||||
<a class="button browse" href="/repository/{{ user.username.value }}/{{ repo.name }}" target="_blank">
|
||||
<i class="fa-solid fa-folder-open"></i> Browse
|
||||
</a>
|
||||
<a class="button clone" href="/git/{{ user.uid.value }}/{{ repo.name.value }}">
|
||||
<i class="fa-solid fa-code-branch"></i> Clone
|
||||
</a>
|
||||
<button class="button clone" onclick="navigator.clipboard.writeText(window.location.protocol + '//' + window.location.host + '/git/{{ user.username.value }}/{{ repo.name }}.git'); alert('Clone URL copied to clipboard!')">
|
||||
<i class="fa-solid fa-code-branch"></i> Clone URL
|
||||
</button>
|
||||
<a class="button edit" href="/settings/repositories/repository/{{ repo.name }}/update.html">
|
||||
<i class="fa-solid fa-pen"></i> Edit
|
||||
</a>
|
||||
|
||||
@ -6,12 +6,15 @@
|
||||
{% include "settings/repositories/form.html" %}
|
||||
<div class="container">
|
||||
<form method="post">
|
||||
<!-- Assume hidden id for backend use -->
|
||||
<input type="hidden" name="id" value="{{ repository.id }}">
|
||||
<div>
|
||||
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ repository.name }}" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label for="description"><i class="fa-solid fa-align-left"></i> Description</label>
|
||||
<input type="text" id="description" name="description" value="{{ repository.description }}" placeholder="Repository description (optional)">
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" value="1" {% if repository.is_private %}checked{% endif %}>
|
||||
@ -19,7 +22,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
|
||||
<button onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
<button type="button" onclick="history.back()" class="cancel"><i class="fa-solid fa-arrow-left"></i>Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<h2>You</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/settings/profile.html">Profile</a></li>
|
||||
<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>
|
||||
|
||||
|
||||
@ -12,7 +12,15 @@
|
||||
<li><a class="no-select" href="/user/{{ user.uid }}.html">Profile</a></li>
|
||||
<li><a class="no-select" href="/channel/{{ user.uid }}.html">DM</a></li>
|
||||
</ul>
|
||||
<h2>Gists</h2>
|
||||
{% if profile_pages %}
|
||||
<h2>Pages</h2>
|
||||
<ul>
|
||||
{% for page in profile_pages %}
|
||||
<li><a class="no-select" href="/user/{{ user.uid }}/{{ page.slug }}.html">{{ page.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<h2>Gists</h2>
|
||||
<ul>
|
||||
<li>No gists</li>
|
||||
</ul>
|
||||
|
||||
@ -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:
|
||||
|
||||
10
src/snek/view/git_docs.py
Normal file
10
src/snek/view/git_docs.py
Normal file
@ -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")
|
||||
45
src/snek/view/profile_page.py
Normal file
45
src/snek/view/profile_page.py
Normal file
@ -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
|
||||
}
|
||||
)
|
||||
@ -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"<pre>{content}</pre>"
|
||||
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'<li><a href="{parent_link}">⬅️ ..</a></li>')
|
||||
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'<li>{icon} <a href="{link}">{display}</a> {size}</li>')
|
||||
parent_path = str(Path(rel_path).parent) if rel_path != "." else ""
|
||||
if parent_path == ".":
|
||||
parent_path = ""
|
||||
|
||||
html = f"""
|
||||
<html>
|
||||
<head><title>📁 {repo_name}/{safe_rel_path}</title></head>
|
||||
<body>
|
||||
<h2>📁 {username}/{repo_name}/{safe_rel_path}</h2>
|
||||
<ul>
|
||||
{''.join(items)}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
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"<pre>{content}</pre>"
|
||||
except Exception as e:
|
||||
return f"<h1>Error</h1><pre>{e}</pre>"
|
||||
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"
|
||||
|
||||
128
src/snek/view/settings/profile_pages.py
Normal file
128
src/snek/view/settings/profile_pages.py
Normal file
@ -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})
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
},
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user