Update.
This commit is contained in:
parent
e4ebd8b4fd
commit
8db6f39046
@ -40,7 +40,8 @@ dependencies = [
|
|||||||
"pillow-heif",
|
"pillow-heif",
|
||||||
"IP2Location",
|
"IP2Location",
|
||||||
"bleach",
|
"bleach",
|
||||||
"sentry-sdk"
|
"sentry-sdk",
|
||||||
|
"bcrypt"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@ -51,3 +52,10 @@ where = ["src"] # <-- this changed
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
snek = "snek.__main__:main"
|
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,
|
RepositoriesIndexView,
|
||||||
RepositoriesUpdateView,
|
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.stats import StatsView
|
||||||
from snek.view.status import StatusView
|
from snek.view.status import StatusView
|
||||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||||
@ -129,6 +136,20 @@ async def trailing_slash_middleware(request, handler):
|
|||||||
|
|
||||||
|
|
||||||
class Application(BaseApplication):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
middlewares = [
|
middlewares = [
|
||||||
cors_middleware,
|
cors_middleware,
|
||||||
@ -174,6 +195,7 @@ class Application(BaseApplication):
|
|||||||
self.on_startup.append(self.start_user_availability_service)
|
self.on_startup.append(self.start_user_availability_service)
|
||||||
self.on_startup.append(self.start_ssh_server)
|
self.on_startup.append(self.start_ssh_server)
|
||||||
self.on_startup.append(self.prepare_database)
|
self.on_startup.append(self.prepare_database)
|
||||||
|
self.on_startup.append(self.create_default_forum)
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -310,11 +332,12 @@ class Application(BaseApplication):
|
|||||||
self.router.add_view("/threads.html", ThreadsView)
|
self.router.add_view("/threads.html", ThreadsView)
|
||||||
self.router.add_view("/terminal.ws", TerminalSocketView)
|
self.router.add_view("/terminal.ws", TerminalSocketView)
|
||||||
self.router.add_view("/terminal.html", TerminalView)
|
self.router.add_view("/terminal.html", TerminalView)
|
||||||
#self.router.add_view("/drive.json", DriveApiView)
|
self.router.add_view("/drive.json", DriveApiView)
|
||||||
#self.router.add_view("/drive.html", DriveView)
|
self.router.add_view("/drive.html", DriveView)
|
||||||
#self.router.add_view("/drive/{drive}.json", DriveView)
|
self.router.add_view("/drive/{rel_path:.*}", DriveView)
|
||||||
self.router.add_view("/stats.json", StatsView)
|
self.router.add_view("/stats.json", StatsView)
|
||||||
self.router.add_view("/user/{user}.html", UserView)
|
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}", RepositoryView)
|
||||||
self.router.add_view(
|
self.router.add_view(
|
||||||
"/repository/{username}/{repository}/{path:.*}", RepositoryView
|
"/repository/{username}/{repository}/{path:.*}", RepositoryView
|
||||||
@ -331,6 +354,14 @@ class Application(BaseApplication):
|
|||||||
"/settings/repositories/repository/{name}/delete.html",
|
"/settings/repositories/repository/{name}/delete.html",
|
||||||
RepositoriesDeleteView,
|
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/index.html", ContainersIndexView)
|
||||||
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
self.router.add_view("/settings/containers/create.html", ContainersCreateView)
|
||||||
self.router.add_view(
|
self.router.add_view(
|
||||||
|
|||||||
@ -78,29 +78,7 @@ class ForumApplication(aiohttp.web.Application):
|
|||||||
|
|
||||||
async def serve_forum_html(self, request):
|
async def serve_forum_html(self, request):
|
||||||
"""Serve the forum HTML with the web component"""
|
"""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 await self.parent.render_template("forum.html", request)
|
||||||
|
|
||||||
|
|
||||||
#return aiohttp.web.Response(text=html, content_type="text/html")
|
|
||||||
|
|
||||||
|
|
||||||
# Integration with main app
|
# Integration with main app
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
|
|||||||
from snek.mapper.repository import RepositoryMapper
|
from snek.mapper.repository import RepositoryMapper
|
||||||
from snek.mapper.user import UserMapper
|
from snek.mapper.user import UserMapper
|
||||||
from snek.mapper.user_property import UserPropertyMapper
|
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.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
|
||||||
from snek.system.object import Object
|
from snek.system.object import Object
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ def get_mappers(app=None):
|
|||||||
"thread": ThreadMapper(app=app),
|
"thread": ThreadMapper(app=app),
|
||||||
"post": PostMapper(app=app),
|
"post": PostMapper(app=app),
|
||||||
"post_like": PostLikeMapper(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)
|
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)
|
is_private = ModelField(name="is_private", required=False, kind=bool)
|
||||||
|
|||||||
@ -1,32 +1,33 @@
|
|||||||
CREATE TABLE IF NOT EXISTS http_access (
|
CREATE TABLE user (
|
||||||
id INTEGER NOT NULL,
|
|
||||||
created TEXT,
|
|
||||||
path TEXT,
|
|
||||||
duration FLOAT,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS user (
|
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
|
city TEXT,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
|
country_long TEXT,
|
||||||
|
country_short TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
email TEXT,
|
email TEXT,
|
||||||
|
ip TEXT,
|
||||||
is_admin TEXT,
|
is_admin TEXT,
|
||||||
last_ping TEXT,
|
last_ping TEXT,
|
||||||
|
latitude TEXT,
|
||||||
|
longitude TEXT,
|
||||||
nick TEXT,
|
nick TEXT,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
|
region TEXT,
|
||||||
uid TEXT,
|
uid TEXT,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
username TEXT,
|
username TEXT,
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);
|
CREATE INDEX ix_user_e2577dd78b54fe28 ON user (uid);
|
||||||
CREATE TABLE IF NOT EXISTS channel (
|
CREATE TABLE channel (
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
created_by_uid TEXT,
|
created_by_uid TEXT,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
history_start TEXT,
|
||||||
"index" BIGINT,
|
"index" BIGINT,
|
||||||
is_listed BOOLEAN,
|
is_listed BOOLEAN,
|
||||||
is_private BOOLEAN,
|
is_private BOOLEAN,
|
||||||
@ -37,46 +38,48 @@ CREATE TABLE IF NOT EXISTS channel (
|
|||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);
|
CREATE INDEX ix_channel_e2577dd78b54fe28 ON channel (uid);
|
||||||
CREATE TABLE IF NOT EXISTS channel_member (
|
CREATE TABLE 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 (
|
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
channel_uid TEXT,
|
channel_uid TEXT,
|
||||||
message TEXT,
|
|
||||||
created_at 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)
|
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,
|
id INTEGER NOT NULL,
|
||||||
channel_uid TEXT,
|
channel_uid TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
html TEXT,
|
html TEXT,
|
||||||
|
is_final BOOLEAN,
|
||||||
message TEXT,
|
message TEXT,
|
||||||
uid TEXT,
|
uid TEXT,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
user_uid TEXT,
|
user_uid TEXT,
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
CREATE INDEX ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);
|
||||||
CREATE TABLE IF NOT EXISTS notification (
|
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,
|
id INTEGER NOT NULL,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
@ -89,11 +92,38 @@ CREATE TABLE IF NOT EXISTS notification (
|
|||||||
user_uid TEXT,
|
user_uid TEXT,
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);
|
CREATE INDEX ix_notification_e2577dd78b54fe28 ON notification (uid);
|
||||||
CREATE TABLE IF NOT EXISTS repository (
|
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,
|
id INTEGER NOT NULL,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
deleted_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,
|
is_private BIGINT,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
uid TEXT,
|
uid TEXT,
|
||||||
@ -101,4 +131,19 @@ CREATE TABLE IF NOT EXISTS repository (
|
|||||||
user_uid TEXT,
|
user_uid TEXT,
|
||||||
PRIMARY KEY (id)
|
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.system.object import Object
|
||||||
from snek.service.statistics import StatisticsService
|
from snek.service.statistics import StatisticsService
|
||||||
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
|
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
|
||||||
|
from snek.service.profile_page import ProfilePageService
|
||||||
_service_registry = {}
|
_service_registry = {}
|
||||||
|
|
||||||
def register_service(name, service_cls):
|
def register_service(name, service_cls):
|
||||||
@ -62,4 +63,5 @@ register_service("forum", ForumService)
|
|||||||
register_service("thread", ThreadService)
|
register_service("thread", ThreadService)
|
||||||
register_service("post", PostService)
|
register_service("post", PostService)
|
||||||
register_service("post_like", PostLikeService)
|
register_service("post_like", PostLikeService)
|
||||||
|
register_service("profile_page", ProfilePageService)
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any, Awaitable, Callable, Dict, List
|
from typing import Any, Awaitable, Callable, Dict, List
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
||||||
class BaseForumService(BaseService):
|
class BaseForumService(BaseService):
|
||||||
@ -42,10 +44,12 @@ class BaseForumService(BaseService):
|
|||||||
async def _dispatch_event(self, event_name: str, data: Any) -> None:
|
async def _dispatch_event(self, event_name: str, data: Any) -> None:
|
||||||
"""Invoke every listener for the given event."""
|
"""Invoke every listener for the given event."""
|
||||||
for listener in self._listeners.get(event_name, []):
|
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)
|
await listener(event_name, data)
|
||||||
else: # plain sync function
|
else:
|
||||||
listener(event_name, data)
|
result = listener(event_name, data)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
async def notify(self, event_name: str, data: Any) -> None:
|
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()
|
loop = asyncio.get_event_loop()
|
||||||
repository_path = (
|
repository_path = (
|
||||||
await self.services.user.get_repository_path(user_uid)
|
await self.services.user.get_repository_path(user_uid)
|
||||||
).joinpath(name)
|
).joinpath(name + ".git")
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(None, shutil.rmtree, repository_path)
|
await loop.run_in_executor(None, shutil.rmtree, repository_path)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@ -39,7 +39,7 @@ class RepositoryService(BaseService):
|
|||||||
stdout, stderr = await process.communicate()
|
stdout, stderr = await process.communicate()
|
||||||
return process.returncode == 0
|
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):
|
if await self.exists(user_uid=user_uid, name=name):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -50,4 +50,14 @@ class RepositoryService(BaseService):
|
|||||||
model["user_uid"] = user_uid
|
model["user_uid"] = user_uid
|
||||||
model["name"] = name
|
model["name"] = name
|
||||||
model["is_private"] = is_private
|
model["is_private"] = is_private
|
||||||
|
model["description"] = description or ""
|
||||||
return await self.save(model)
|
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):
|
class GitApplication(web.Application):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
# import git
|
import git
|
||||||
# globals()['git'] = git
|
globals()['git'] = git
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
|
super().__init__(client_max_size=1024 * 1024 * 1024 * 5)
|
||||||
self.add_routes(
|
self.add_routes(
|
||||||
[
|
[
|
||||||
web.post("/create/{repo_name}", self.create_repository),
|
web.post("/{username}/{repo_name}/create", self.create_repository),
|
||||||
web.delete("/delete/{repo_name}", self.delete_repository),
|
web.delete("/{username}/{repo_name}/delete", self.delete_repository),
|
||||||
web.get("/clone/{repo_name}", self.clone_repository),
|
web.get("/{username}/{repo_name}/clone", self.clone_repository),
|
||||||
web.post("/push/{repo_name}", self.push_repository),
|
web.post("/{username}/{repo_name}/push", self.push_repository),
|
||||||
web.post("/pull/{repo_name}", self.pull_repository),
|
web.post("/{username}/{repo_name}/pull", self.pull_repository),
|
||||||
web.get("/status/{repo_name}", self.status_repository),
|
web.get("/{username}/{repo_name}/status", self.status_repository),
|
||||||
# web.get('/list', self.list_repositories),
|
web.get("/{username}/{repo_name}/branches", self.list_branches),
|
||||||
web.get("/branches/{repo_name}", self.list_branches),
|
web.post("/{username}/{repo_name}/branches", self.create_branch),
|
||||||
web.post("/branches/{repo_name}", self.create_branch),
|
web.get("/{username}/{repo_name}/log", self.commit_log),
|
||||||
web.get("/log/{repo_name}", self.commit_log),
|
web.get("/{username}/{repo_name}/file/{file_path:.*}", self.file_content),
|
||||||
web.get("/file/{repo_name}/{file_path:.*}", self.file_content),
|
web.get("/{username}/{repo_name}.git/info/refs", self.git_smart_http),
|
||||||
web.get("/{path:.+}/info/refs", self.git_smart_http),
|
web.post("/{username}/{repo_name}.git/git-upload-pack", self.git_smart_http),
|
||||||
web.post("/{path:.+}/git-upload-pack", self.git_smart_http),
|
web.post("/{username}/{repo_name}.git/git-receive-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),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def check_basic_auth(self, request):
|
async def check_basic_auth(self, request):
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
if not auth_header.startswith("Basic "):
|
if not auth_header.startswith("Basic "):
|
||||||
return None, None
|
return None, None, None
|
||||||
encoded_creds = auth_header.split("Basic ")[1]
|
encoded_creds = auth_header.split("Basic ")[1]
|
||||||
decoded_creds = base64.b64decode(encoded_creds).decode()
|
decoded_creds = base64.b64decode(encoded_creds).decode()
|
||||||
username, password = decoded_creds.split(":", 1)
|
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
|
username=username, password=password
|
||||||
)
|
)
|
||||||
if not request["user"]:
|
if not request["auth_user"]:
|
||||||
return None, None
|
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"] = (
|
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
|
@staticmethod
|
||||||
def require_auth(handler):
|
def require_auth(handler):
|
||||||
async def wrapped(self, request, *args, **kwargs):
|
async def wrapped(self, request, *args, **kwargs):
|
||||||
username, repository_path = await self.check_basic_auth(request)
|
username, target_user, repository_path = await self.check_basic_auth(request)
|
||||||
if not username or not repository_path:
|
if not username or not target_user or not repository_path:
|
||||||
return web.Response(
|
return web.Response(
|
||||||
status=401,
|
status=401,
|
||||||
headers={"WWW-Authenticate": "Basic"},
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
text="Authentication required",
|
text="Authentication required",
|
||||||
)
|
)
|
||||||
request["username"] = username
|
request["username"] = username
|
||||||
|
request["target_user"] = target_user
|
||||||
request["repository_path"] = repository_path
|
request["repository_path"] = repository_path
|
||||||
return await handler(self, request, *args, **kwargs)
|
return await handler(self, request, *args, **kwargs)
|
||||||
|
|
||||||
@ -87,9 +98,17 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def create_repository(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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:
|
if not repo_name or "/" in repo_name or ".." in repo_name:
|
||||||
return web.Response(text="Invalid repository name", status=400)
|
return web.Response(text="Invalid repository name", status=400)
|
||||||
repo_dir = self.repo_path(repository_path, repo_name)
|
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)
|
return web.Response(text="Repository already exists", status=400)
|
||||||
try:
|
try:
|
||||||
git.Repo.init(repo_dir, bare=True)
|
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}")
|
return web.Response(text=f"Created repository {repo_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating repository {repo_name}: {str(e)}")
|
logger.error(f"Error creating repository {repo_name}: {str(e)}")
|
||||||
@ -105,16 +124,22 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def delete_repository(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
#'''
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(self.repo_path(repository_path, repo_name))
|
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}")
|
return web.Response(text=f"Deleted repository {repo_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
|
logger.error(f"Error deleting repository {repo_name}: {str(e)}")
|
||||||
@ -122,9 +147,20 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def clone_repository(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -139,9 +175,16 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def push_repository(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -175,14 +218,21 @@ class GitApplication(web.Application):
|
|||||||
temp_repo.index.commit(commit_message)
|
temp_repo.index.commit(commit_message)
|
||||||
origin = temp_repo.remote("origin")
|
origin = temp_repo.remote("origin")
|
||||||
origin.push(refspec=f"{branch}:{branch}")
|
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}")
|
return web.Response(text=f"Successfully pushed changes to {repo_name}")
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def pull_repository(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -210,7 +260,7 @@ class GitApplication(web.Application):
|
|||||||
origin = local_repo.remote("origin")
|
origin = local_repo.remote("origin")
|
||||||
origin.push()
|
origin.push()
|
||||||
logger.info(
|
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(
|
return web.Response(
|
||||||
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
|
text=f"Successfully pulled changes from {remote_url} to {repo_name}"
|
||||||
@ -221,9 +271,20 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def status_repository(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -291,9 +352,20 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def list_branches(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -306,9 +378,17 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def create_branch(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -328,7 +408,7 @@ class GitApplication(web.Application):
|
|||||||
temp_repo.git.branch(branch_name, start_point)
|
temp_repo.git.branch(branch_name, start_point)
|
||||||
temp_repo.git.push("origin", branch_name)
|
temp_repo.git.push("origin", branch_name)
|
||||||
logger.info(
|
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}")
|
return web.Response(text=f"Created branch {branch_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -339,9 +419,20 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def commit_log(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -383,11 +474,22 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def file_content(self, request):
|
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"]
|
repo_name = request.match_info["repo_name"]
|
||||||
file_path = request.match_info.get("file_path", "")
|
file_path = request.match_info.get("file_path", "")
|
||||||
branch = request.query.get("branch", "main")
|
branch = request.query.get("branch", "main")
|
||||||
repository_path = request["repository_path"]
|
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)
|
error_response = self.check_repo_exists(repository_path, repo_name)
|
||||||
if error_response:
|
if error_response:
|
||||||
return error_response
|
return error_response
|
||||||
@ -433,25 +535,42 @@ class GitApplication(web.Application):
|
|||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def git_smart_http(self, request):
|
async def git_smart_http(self, request):
|
||||||
request["username"]
|
auth_user = request["auth_user"]
|
||||||
|
target_user = request["target_user"]
|
||||||
repository_path = request["repository_path"]
|
repository_path = request["repository_path"]
|
||||||
|
repo_name = request.match_info.get("repo_name")
|
||||||
|
path_username = request.match_info.get("username")
|
||||||
path = request.path
|
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():
|
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")
|
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
|
return repo_dir
|
||||||
|
|
||||||
async def handle_info_refs(service):
|
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="/drive.html">📂</a>
|
||||||
<a class="no-select" href="/search-user.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" 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="/settings/index.html">⚙️</a>
|
||||||
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
|
<a class="no-select" href="#" onclick="registerNotificationsServiceWorker()">✉️</a>
|
||||||
<a class="no-select" href="/logout.html">🔒</a>
|
<a class="no-select" href="/logout.html">🔒</a>
|
||||||
|
|||||||
@ -117,6 +117,13 @@ class SnekForum extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadForums() {
|
async loadForums() {
|
||||||
|
if (window.preloadedForums) {
|
||||||
|
this.currentView = 'forums';
|
||||||
|
this.renderForums(window.preloadedForums);
|
||||||
|
this.updateBreadcrumb();
|
||||||
|
window.preloadedForums = null; // Clear it after use
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await this.fetchAPI('/forum/api/forums');
|
const data = await this.fetchAPI('/forum/api/forums');
|
||||||
this.currentView = 'forums';
|
this.currentView = 'forums';
|
||||||
@ -695,9 +702,11 @@ class SnekForum extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBreadcrumb() {
|
updateBreadcrumb() {
|
||||||
return;
|
const breadcrumbContainer = document.getElementById('breadcrumb');
|
||||||
|
if (!breadcrumbContainer) return;
|
||||||
|
|
||||||
const crumb = [];
|
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) {
|
if (this.currentView === "forum" && this.currentForum) {
|
||||||
crumb.push(`<span>›</span>`);
|
crumb.push(`<span>›</span>`);
|
||||||
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
|
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
|
||||||
@ -708,7 +717,7 @@ class SnekForum extends HTMLElement {
|
|||||||
crumb.push(`<span>›</span>`);
|
crumb.push(`<span>›</span>`);
|
||||||
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
|
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
|
||||||
}
|
}
|
||||||
this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
|
breadcrumbContainer.innerHTML = crumb.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForums(forums) {
|
renderForums(forums) {
|
||||||
@ -905,6 +914,9 @@ class SnekForum extends HTMLElement {
|
|||||||
}
|
}
|
||||||
customElements.define('snek-forum', SnekForum);
|
customElements.define('snek-forum', SnekForum);
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
window.preloadedForums = {{ forums_json|safe }};
|
||||||
|
</script>
|
||||||
<snek-forum></snek-forum>
|
<snek-forum></snek-forum>
|
||||||
{% endblock %}
|
{% 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>
|
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||||
<input type="text" id="name" name="name" required placeholder="Repository name">
|
<input type="text" id="name" name="name" required placeholder="Repository name">
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="is_private" value="1">
|
<input type="checkbox" name="is_private" value="1">
|
||||||
<i class="fa-solid fa-lock"></i> Private
|
<i class="fa-solid fa-lock"></i> Private
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
|
<button type="submit"><i class="fa-solid fa-plus"></i> Create</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -75,8 +75,13 @@
|
|||||||
{% for repo in repositories %}
|
{% for repo in repositories %}
|
||||||
<div class="repo-row">
|
<div class="repo-row">
|
||||||
<div class="repo-info">
|
<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">
|
<span title="Public">
|
||||||
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
|
<i class="fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}"></i>
|
||||||
{% if repo.is_private %}Private{% else %}Public{% endif %}
|
{% 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">
|
<a class="button browse" href="/repository/{{ user.username.value }}/{{ repo.name }}" target="_blank">
|
||||||
<i class="fa-solid fa-folder-open"></i> Browse
|
<i class="fa-solid fa-folder-open"></i> Browse
|
||||||
</a>
|
</a>
|
||||||
<a class="button clone" href="/git/{{ user.uid.value }}/{{ repo.name.value }}">
|
<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
|
<i class="fa-solid fa-code-branch"></i> Clone URL
|
||||||
</a>
|
</button>
|
||||||
<a class="button edit" href="/settings/repositories/repository/{{ repo.name }}/update.html">
|
<a class="button edit" href="/settings/repositories/repository/{{ repo.name }}/update.html">
|
||||||
<i class="fa-solid fa-pen"></i> Edit
|
<i class="fa-solid fa-pen"></i> Edit
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -6,12 +6,15 @@
|
|||||||
{% include "settings/repositories/form.html" %}
|
{% include "settings/repositories/form.html" %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<!-- Assume hidden id for backend use -->
|
|
||||||
<input type="hidden" name="id" value="{{ repository.id }}">
|
<input type="hidden" name="id" value="{{ repository.id }}">
|
||||||
<div>
|
<div>
|
||||||
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
<label for="name"><i class="fa-solid fa-book"></i> Name</label>
|
||||||
<input type="text" id="name" name="name" value="{{ repository.name }}" readonly>
|
<input type="text" id="name" name="name" value="{{ repository.name }}" readonly>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="is_private" value="1" {% if repository.is_private %}checked{% endif %}>
|
<input type="checkbox" name="is_private" value="1" {% if repository.is_private %}checked{% endif %}>
|
||||||
@ -19,7 +22,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit"><i class="fa-solid fa-pen"></i> Update</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
<h2>You</h2>
|
<h2>You</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a class="no-select" href="/settings/profile.html">Profile</a></li>
|
<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>
|
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,15 @@
|
|||||||
<li><a class="no-select" href="/user/{{ user.uid }}.html">Profile</a></li>
|
<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>
|
<li><a class="no-select" href="/channel/{{ user.uid }}.html">DM</a></li>
|
||||||
</ul>
|
</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>
|
<ul>
|
||||||
<li>No gists</li>
|
<li>No gists</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -17,57 +17,27 @@ class ForumIndexView(BaseView):
|
|||||||
async def get(self):
|
async def get(self):
|
||||||
if self.login_required and not self.session.get("logged_in"):
|
if self.login_required and not self.session.get("logged_in"):
|
||||||
return web.HTTPFound("/")
|
return web.HTTPFound("/")
|
||||||
channel = await self.services.channel.get(
|
|
||||||
uid=self.request.match_info.get("channel")
|
forums = []
|
||||||
)
|
async for forum in self.services.forum.get_active_forums():
|
||||||
if not channel:
|
forums.append({
|
||||||
user = await self.services.user.get(
|
"uid": forum["uid"],
|
||||||
uid=self.request.match_info.get("channel")
|
"name": forum["name"],
|
||||||
)
|
"description": forum["description"],
|
||||||
if user:
|
"slug": forum["slug"],
|
||||||
channel = await self.services.channel.get_dm(
|
"icon": forum["icon"],
|
||||||
self.session.get("uid"), user["uid"]
|
"thread_count": forum["thread_count"],
|
||||||
)
|
"post_count": forum["post_count"],
|
||||||
if channel:
|
"last_post_at": forum["last_post_at"],
|
||||||
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
|
"last_thread_uid": forum["last_thread_uid"]
|
||||||
if not channel:
|
})
|
||||||
return web.HTTPNotFound()
|
|
||||||
|
|
||||||
channel_member = await self.app.services.channel_member.get(
|
|
||||||
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
|
|
||||||
)
|
|
||||||
if not channel_member:
|
|
||||||
if not channel["is_private"]:
|
|
||||||
channel_member = await self.app.services.channel_member.create(
|
|
||||||
channel_uid=channel["uid"],
|
|
||||||
user_uid=self.session.get("uid"),
|
|
||||||
is_moderator=False,
|
|
||||||
is_read_only=False,
|
|
||||||
is_muted=False,
|
|
||||||
is_banned=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
return web.HTTPNotFound()
|
|
||||||
|
|
||||||
channel_member["new_count"] = 0
|
|
||||||
await self.app.services.channel_member.save(channel_member)
|
|
||||||
|
|
||||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
await self.app.services.channel_message.to_extended_dict(message)
|
|
||||||
for message in await self.app.services.channel_message.offset(
|
|
||||||
channel["uid"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for message in messages:
|
|
||||||
await self.app.services.notification.mark_as_read(
|
|
||||||
self.session.get("uid"), message["uid"]
|
|
||||||
)
|
|
||||||
name = await channel_member.get_name()
|
|
||||||
return await self.render_template(
|
return await self.render_template(
|
||||||
"forum.html",
|
"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
|
login_required = True
|
||||||
|
|
||||||
async def get_forums(self):
|
async def get_forums(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
"""GET /forum/api/forums - Get all active forums"""
|
"""GET /forum/api/forums - Get all active forums"""
|
||||||
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({
|
forums.append({
|
||||||
"uid": forum["uid"],
|
"uid": forum["uid"],
|
||||||
"name": forum["name"],
|
"name": forum["name"],
|
||||||
@ -99,28 +67,25 @@ class ForumView(BaseView):
|
|||||||
return web.json_response({"forums": forums})
|
return web.json_response({"forums": forums})
|
||||||
|
|
||||||
async def get_forum(self):
|
async def get_forum(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""GET /forum/api/forums/:slug - Get forum by slug"""
|
"""GET /forum/api/forums/:slug - Get forum by slug"""
|
||||||
slug = self.request.match_info["slug"]
|
slug = self.match_info["slug"]
|
||||||
forum = await self.services.forum.get(slug=slug, is_active=True)
|
forum = await self.app.services.forum.get(slug=slug, is_active=True)
|
||||||
|
|
||||||
if not forum:
|
if not forum:
|
||||||
return web.json_response({"error": "Forum not found"}, status=404)
|
return web.json_response({"error": "Forum not found"}, status=404)
|
||||||
|
|
||||||
# Get threads
|
# Get threads
|
||||||
threads = []
|
threads = []
|
||||||
page = int(self.request.query.get("page", 1))
|
page = int(self.query.get("page", 1))
|
||||||
limit = 50
|
limit = 50
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
async for thread in forum.get_threads(limit=limit, offset=offset):
|
async for thread in forum.get_threads(limit=limit, offset=offset):
|
||||||
# Get author info
|
# 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
|
last_post_author = None
|
||||||
if thread["last_post_by_uid"]:
|
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({
|
threads.append({
|
||||||
"uid": thread["uid"],
|
"uid": thread["uid"],
|
||||||
@ -162,21 +127,17 @@ class ForumView(BaseView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def create_thread(self):
|
async def create_thread(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""POST /forum/api/forums/:slug/threads - Create new thread"""
|
"""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)
|
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||||
|
|
||||||
slug = self.request.match_info["slug"]
|
slug = self.match_info["slug"]
|
||||||
forum = await self.services.forum.get(slug=slug, is_active=True)
|
forum = await self.app.services.forum.get(slug=slug, is_active=True)
|
||||||
|
|
||||||
if not forum:
|
if not forum:
|
||||||
return web.json_response({"error": "Forum not found"}, status=404)
|
return web.json_response({"error": "Forum not found"}, status=404)
|
||||||
|
|
||||||
data = await self.request.json()
|
data = await self.json()
|
||||||
title = data.get("title", "").strip()
|
title = data.get("title", "").strip()
|
||||||
content = data.get("content", "").strip()
|
content = data.get("content", "").strip()
|
||||||
|
|
||||||
@ -184,11 +145,11 @@ class ForumView(BaseView):
|
|||||||
return web.json_response({"error": "Title and content required"}, status=400)
|
return web.json_response({"error": "Title and content required"}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
thread, post = await self.services.thread.create_thread(
|
thread, post = await self.app.services.thread.create_thread(
|
||||||
forum_uid=forum["uid"],
|
forum_uid=forum["uid"],
|
||||||
title=title,
|
title=title,
|
||||||
content=content,
|
content=content,
|
||||||
created_by_uid=self.request.session["uid"]
|
created_by_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
@ -202,13 +163,9 @@ class ForumView(BaseView):
|
|||||||
return web.json_response({"error": str(e)}, status=400)
|
return web.json_response({"error": str(e)}, status=400)
|
||||||
|
|
||||||
async def get_thread(self):
|
async def get_thread(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""GET /forum/api/threads/:thread_slug - Get thread with posts"""
|
"""GET /forum/api/threads/:thread_slug - Get thread with posts"""
|
||||||
thread_slug = self.request.match_info["thread_slug"]
|
thread_slug = self.match_info["thread_slug"]
|
||||||
thread = await self.services.thread.get(slug=thread_slug)
|
thread = await self.app.services.thread.get(slug=thread_slug)
|
||||||
|
|
||||||
if not thread:
|
if not thread:
|
||||||
return web.json_response({"error": "Thread not found"}, status=404)
|
return web.json_response({"error": "Thread not found"}, status=404)
|
||||||
@ -217,15 +174,15 @@ class ForumView(BaseView):
|
|||||||
await thread.increment_view_count()
|
await thread.increment_view_count()
|
||||||
|
|
||||||
# Get forum
|
# 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
|
# Get posts
|
||||||
posts = []
|
posts = []
|
||||||
page = int(self.request.query.get("page", 1))
|
page = int(self.query.get("page", 1))
|
||||||
limit = 50
|
limit = 50
|
||||||
offset = (page - 1) * limit
|
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):
|
async for post in thread.get_posts(limit=limit, offset=offset):
|
||||||
author = await post.get_author()
|
author = await post.get_author()
|
||||||
@ -250,7 +207,7 @@ class ForumView(BaseView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Get thread author
|
# 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({
|
return web.json_response({
|
||||||
"thread": {
|
"thread": {
|
||||||
@ -280,32 +237,28 @@ class ForumView(BaseView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def create_post(self):
|
async def create_post(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""POST /forum/api/threads/:thread_uid/posts - Create new post"""
|
"""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)
|
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.get(uid=thread_uid)
|
thread = await self.app.services.thread.get(uid=thread_uid)
|
||||||
|
|
||||||
if not thread:
|
if not thread:
|
||||||
return web.json_response({"error": "Thread not found"}, status=404)
|
return web.json_response({"error": "Thread not found"}, status=404)
|
||||||
|
|
||||||
data = await self.request.json()
|
data = await self.json()
|
||||||
content = data.get("content", "").strip()
|
content = data.get("content", "").strip()
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
return web.json_response({"error": "Content required"}, status=400)
|
return web.json_response({"error": "Content required"}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
post = await self.services.post.create_post(
|
post = await self.app.services.post.create_post(
|
||||||
thread_uid=thread["uid"],
|
thread_uid=thread["uid"],
|
||||||
forum_uid=thread["forum_uid"],
|
forum_uid=thread["forum_uid"],
|
||||||
content=content,
|
content=content,
|
||||||
created_by_uid=self.request.session["uid"]
|
created_by_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
author = await post.get_author()
|
author = await post.get_author()
|
||||||
@ -329,25 +282,21 @@ class ForumView(BaseView):
|
|||||||
return web.json_response({"error": str(e)}, status=400)
|
return web.json_response({"error": str(e)}, status=400)
|
||||||
|
|
||||||
async def edit_post(self):
|
async def edit_post(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""PUT /forum/api/posts/:post_uid - Edit post"""
|
"""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)
|
return web.json_response({"error": "Unauthorized"}, status=401)
|
||||||
|
|
||||||
post_uid = self.request.match_info["post_uid"]
|
post_uid = self.match_info["post_uid"]
|
||||||
data = await self.request.json()
|
data = await self.json()
|
||||||
content = data.get("content", "").strip()
|
content = data.get("content", "").strip()
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
return web.json_response({"error": "Content required"}, status=400)
|
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,
|
post_uid=post_uid,
|
||||||
content=content,
|
content=content,
|
||||||
user_uid=self.request.session["uid"]
|
user_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if not post:
|
if not post:
|
||||||
@ -362,19 +311,15 @@ class ForumView(BaseView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def delete_post(self):
|
async def delete_post(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""DELETE /forum/api/posts/:post_uid - Delete post"""
|
"""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)
|
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,
|
post_uid=post_uid,
|
||||||
user_uid=self.request.session["uid"]
|
user_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@ -383,26 +328,22 @@ class ForumView(BaseView):
|
|||||||
return web.json_response({"success": True})
|
return web.json_response({"success": True})
|
||||||
|
|
||||||
async def toggle_like(self):
|
async def toggle_like(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""POST /forum/api/posts/:post_uid/like - Toggle post like"""
|
"""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)
|
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,
|
post_uid=post_uid,
|
||||||
user_uid=self.request.session["uid"]
|
user_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_liked is None:
|
if is_liked is None:
|
||||||
return web.json_response({"error": "Failed to toggle like"}, status=400)
|
return web.json_response({"error": "Failed to toggle like"}, status=400)
|
||||||
|
|
||||||
# Get updated post
|
# 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({
|
return web.json_response({
|
||||||
"is_liked": is_liked,
|
"is_liked": is_liked,
|
||||||
@ -410,19 +351,15 @@ class ForumView(BaseView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def toggle_pin(self):
|
async def toggle_pin(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
|
"""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)
|
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,
|
thread_uid=thread_uid,
|
||||||
user_uid=self.request.session["uid"]
|
user_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if not thread:
|
if not thread:
|
||||||
@ -431,19 +368,15 @@ class ForumView(BaseView):
|
|||||||
return web.json_response({"is_pinned": thread["is_pinned"]})
|
return web.json_response({"is_pinned": thread["is_pinned"]})
|
||||||
|
|
||||||
async def toggle_lock(self):
|
async def toggle_lock(self):
|
||||||
request = self
|
|
||||||
self = request.app
|
|
||||||
|
|
||||||
setattr(self, "request", request)
|
|
||||||
"""POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
|
"""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)
|
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,
|
thread_uid=thread_uid,
|
||||||
user_uid=self.request.session["uid"]
|
user_uid=self.session["uid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if not thread:
|
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 asyncio
|
||||||
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import git
|
||||||
import humanize
|
import humanize
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.view import BaseView
|
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):
|
class RepositoryView(BaseView):
|
||||||
|
|
||||||
login_required = True
|
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):
|
async def get(self):
|
||||||
|
|
||||||
base_repo_path = Path("drive/repositories")
|
|
||||||
|
|
||||||
authenticated_user_id = self.session.get("uid")
|
authenticated_user_id = self.session.get("uid")
|
||||||
|
|
||||||
username = self.request.match_info.get("username")
|
username = self.request.match_info.get("username")
|
||||||
repo_name = self.request.match_info.get("repository")
|
repo_name = self.request.match_info.get("repository")
|
||||||
rel_path = self.request.match_info.get("path", "")
|
rel_path = self.request.match_info.get("path", "")
|
||||||
|
|
||||||
|
branch = self.request.query.get("branch", "")
|
||||||
|
commit_hash = self.request.query.get("commit", "")
|
||||||
|
|
||||||
user = None
|
user = None
|
||||||
if not username.count("-") == 4:
|
if not username or username.count("-") != 4:
|
||||||
user = await self.app.services.user.get(username=username)
|
user = await self.app.services.user.get(username=username)
|
||||||
if not user:
|
if not user:
|
||||||
return web.Response(text="404 Not Found", status=404)
|
return web.Response(text="404 Not Found", status=404)
|
||||||
@ -191,99 +35,262 @@ class RepositoryView(BaseView):
|
|||||||
else:
|
else:
|
||||||
user = await self.app.services.user.get(uid=username)
|
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(
|
repo = await self.app.services.repository.get(
|
||||||
name=repo_name, user_uid=user["uid"]
|
name=repo_name, user_uid=user["uid"]
|
||||||
)
|
)
|
||||||
if not repo:
|
if not repo:
|
||||||
return web.Response(text="404 Not Found", status=404)
|
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()
|
if repo["is_private"] and authenticated_user_id != user["uid"]:
|
||||||
repo_root = (base_repo_path / user["uid"] / repo_name).resolve()
|
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:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
git_repo = git.Repo(repo_path)
|
||||||
await loop.run_in_executor(
|
except Exception:
|
||||||
None, self.checkout_bare_repo, repo_root_base, repo_root
|
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():
|
if commit_hash:
|
||||||
return web.Response(text="404 Not Found", status=404)
|
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)
|
if not rel_path:
|
||||||
abs_path = (repo_root / safe_rel_path).resolve()
|
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):
|
try:
|
||||||
return web.Response(text="404 Not Found", status=404)
|
tree = current_commit.tree
|
||||||
|
items = []
|
||||||
|
|
||||||
if abs_path.is_dir():
|
for item in tree:
|
||||||
return web.Response(
|
items.append(
|
||||||
text=self.render_directory(
|
{
|
||||||
abs_path, username, repo_name, safe_rel_path
|
"name": item.name,
|
||||||
),
|
"path": item.path,
|
||||||
content_type="text/html",
|
"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:
|
else:
|
||||||
return web.Response(
|
try:
|
||||||
text=self.render_file(abs_path), content_type="text/html"
|
tree = current_commit.tree
|
||||||
)
|
item = tree[rel_path]
|
||||||
|
|
||||||
def render_directory(self, abs_path, username, repo_name, safe_rel_path):
|
if item.type == "tree":
|
||||||
entries = sorted(
|
items = []
|
||||||
abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
|
for child in item:
|
||||||
)
|
items.append(
|
||||||
items = []
|
{
|
||||||
|
"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:
|
items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||||||
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>')
|
|
||||||
|
|
||||||
for entry in entries:
|
parent_path = str(Path(rel_path).parent) if rel_path != "." else ""
|
||||||
link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))
|
if parent_path == ".":
|
||||||
link = f"/repository/{username}/{repo_name}/{link_path}".rstrip("/")
|
parent_path = ""
|
||||||
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>')
|
|
||||||
|
|
||||||
html = f"""
|
return await self.render_template(
|
||||||
<html>
|
"repository_tree.html",
|
||||||
<head><title>📁 {repo_name}/{safe_rel_path}</title></head>
|
{
|
||||||
<body>
|
"user": user.record,
|
||||||
<h2>📁 {username}/{repo_name}/{safe_rel_path}</h2>
|
"repo": repo.record,
|
||||||
<ul>
|
"repo_name": repo_name,
|
||||||
{''.join(items)}
|
"username": username,
|
||||||
</ul>
|
"branches": branches,
|
||||||
</body>
|
"current_branch": current_branch,
|
||||||
</html>
|
"items": items,
|
||||||
"""
|
"rel_path": rel_path,
|
||||||
return html
|
"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:
|
||||||
try:
|
text_content = content.decode("utf-8")
|
||||||
with open(abs_path, encoding="utf-8", errors="ignore") as f:
|
is_binary = False
|
||||||
content = f.read()
|
except UnicodeDecodeError:
|
||||||
return f"<pre>{content}</pre>"
|
text_content = None
|
||||||
except Exception as e:
|
is_binary = True
|
||||||
return f"<h1>Error</h1><pre>{e}</pre>"
|
|
||||||
|
|
||||||
def get_icon(self, file):
|
mime_type = mimetypes.guess_type(item.name)[0] or "text/plain"
|
||||||
if file.is_dir():
|
|
||||||
return "📁"
|
if is_binary:
|
||||||
mime = mimetypes.guess_type(file.name)[0] or ""
|
if mime_type.startswith("image/"):
|
||||||
if mime.startswith("image"):
|
data_uri = f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
|
||||||
return "🖼️"
|
return await self.render_template(
|
||||||
if mime.startswith("text"):
|
"repository_file.html",
|
||||||
return "📄"
|
{
|
||||||
if mime.startswith("audio"):
|
"user": user.record,
|
||||||
return "🎵"
|
"repo": repo.record,
|
||||||
if mime.startswith("video"):
|
"repo_name": repo_name,
|
||||||
return "🎬"
|
"username": username,
|
||||||
if file.name.endswith(".py"):
|
"branches": branches,
|
||||||
return "🐍"
|
"current_branch": current_branch,
|
||||||
return "📦"
|
"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(
|
await self.services.repository.create(
|
||||||
user_uid=self.session.get("uid"),
|
user_uid=self.session.get("uid"),
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
|
description=data.get("description", ""),
|
||||||
is_private=int(data.get("is_private", 0)),
|
is_private=int(data.get("is_private", 0)),
|
||||||
)
|
)
|
||||||
return web.HTTPFound("/settings/repositories/index.html")
|
return web.HTTPFound("/settings/repositories/index.html")
|
||||||
@ -61,6 +62,7 @@ class RepositoriesUpdateView(BaseFormView):
|
|||||||
repository = await self.services.repository.get(
|
repository = await self.services.repository.get(
|
||||||
user_uid=self.session.get("uid"), name=self.request.match_info["name"]
|
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))
|
repository["is_private"] = int(data.get("is_private", 0))
|
||||||
await self.services.repository.save(repository)
|
await self.services.repository.save(repository)
|
||||||
return web.HTTPFound("/settings/repositories/index.html")
|
return web.HTTPFound("/settings/repositories/index.html")
|
||||||
|
|||||||
@ -11,7 +11,16 @@ class UserView(BaseView):
|
|||||||
profile_content = (
|
profile_content = (
|
||||||
await self.services.user_property.get(user["uid"], "profile") or ""
|
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(
|
return await self.render_template(
|
||||||
"user.html",
|
"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