forked from retoor/devplacepy
fix: correct "bugs" to "issues" in routing table and README references across multiple documentation files
This commit is contained in:
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import httpx
|
||||
from devplacepy import stealth
|
||||
from devplacepy.database import get_table, db, get_setting
|
||||
from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR
|
||||
from devplacepy.utils import generate_uid
|
||||
@@ -293,7 +294,7 @@ async def fetch_remote_file(url, filename=None):
|
||||
await _guard_public_url(url)
|
||||
max_bytes = _get_max_upload_bytes()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with stealth.stealth_async_client(
|
||||
follow_redirects=True,
|
||||
timeout=REMOTE_FETCH_TIMEOUT,
|
||||
headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
|
||||
|
||||
@@ -44,6 +44,14 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
|
||||
PORT = 10500
|
||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
|
||||
# Forking XML-RPC bridge. A standalone ForkingMixIn XML-RPC server binds this
|
||||
# port on loopback and translates every documented REST endpoint into an XML-RPC
|
||||
# method (generated from docs_api.API_GROUPS). The FastAPI app reverse-proxies
|
||||
# /xmlrpc to it; nginx forwards /xmlrpc in production. The bind host stays
|
||||
# loopback because the app and nginx are the only intended front doors.
|
||||
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
|
||||
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
|
||||
|
||||
# Cache-busting version stamped onto every app-owned static URL. Computed once at
|
||||
# process start, so a restart/deploy changes it and busts every browser cache while
|
||||
# assets stay heavily cached (immutable, 1 year) between deploys. Set
|
||||
|
||||
@@ -17,6 +17,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_poll_for_post,
|
||||
update_target_stars,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
soft_delete_in,
|
||||
soft_delete_engagement,
|
||||
@@ -30,8 +33,11 @@ from devplacepy.utils import (
|
||||
generate_uid,
|
||||
make_combined_slug,
|
||||
award_rewards,
|
||||
create_notification,
|
||||
create_mention_notifications,
|
||||
is_admin,
|
||||
XP_COMMENT,
|
||||
XP_UPVOTE,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
@@ -128,6 +134,258 @@ def create_content_item(
|
||||
return uid, slug
|
||||
|
||||
|
||||
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
|
||||
|
||||
|
||||
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
|
||||
votes = get_table("votes")
|
||||
existing = votes.find_one(
|
||||
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
|
||||
)
|
||||
old_value = int(existing["value"]) if existing else 0
|
||||
did_upvote = False
|
||||
new_value = value
|
||||
if existing:
|
||||
if existing.get("deleted_at"):
|
||||
votes.update(
|
||||
{
|
||||
"id": existing["id"],
|
||||
"value": value,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
},
|
||||
["id"],
|
||||
)
|
||||
did_upvote = value == 1
|
||||
elif int(existing["value"]) == value:
|
||||
votes.update(
|
||||
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
|
||||
["id"],
|
||||
)
|
||||
new_value = 0
|
||||
else:
|
||||
votes.update({"id": existing["id"], "value": value}, ["id"])
|
||||
did_upvote = value == 1
|
||||
else:
|
||||
votes.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"target_uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"value": value,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
did_upvote = value == 1
|
||||
|
||||
up_count = votes.count(
|
||||
target_uid=target_uid, target_type=target_type, value=1, deleted_at=None
|
||||
)
|
||||
down_count = votes.count(
|
||||
target_uid=target_uid, target_type=target_type, value=-1, deleted_at=None
|
||||
)
|
||||
net = up_count - down_count
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
|
||||
vote_links = [audit.target(target_type, target_uid)]
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
vote_links.append(audit.author(owner_uid))
|
||||
audit.record(
|
||||
request,
|
||||
f"vote.{target_type}.{direction}",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
metadata={"value_old": old_value, "value_new": new_value, "net": net},
|
||||
summary=f"{user['username']} {direction} vote on {target_type} {target_uid}",
|
||||
links=vote_links,
|
||||
)
|
||||
|
||||
if did_upvote and target_type in VOTE_NOTIFY_TYPES:
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
target_url = resolve_object_url(target_type, target_uid)
|
||||
create_notification(
|
||||
owner_uid,
|
||||
"vote",
|
||||
f"{user['username']} ++'d your {target_type}",
|
||||
user["uid"],
|
||||
target_url,
|
||||
)
|
||||
award_rewards(owner_uid, XP_UPVOTE)
|
||||
|
||||
current = votes.find_one(
|
||||
user_uid=user["uid"],
|
||||
target_uid=target_uid,
|
||||
target_type=target_type,
|
||||
deleted_at=None,
|
||||
)
|
||||
current_value = int(current["value"]) if current else 0
|
||||
return {"net": net, "up": up_count, "down": down_count, "value": current_value}
|
||||
|
||||
|
||||
def create_comment_record(
|
||||
request,
|
||||
user: dict,
|
||||
target_type: str,
|
||||
target_uid: str,
|
||||
content: str,
|
||||
parent_uid: str | None = None,
|
||||
attachment_uids: list | None = None,
|
||||
) -> tuple[str, str]:
|
||||
comment_uid = generate_uid()
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
insert = {
|
||||
"uid": comment_uid,
|
||||
"target_uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"user_uid": user["uid"],
|
||||
"content": content,
|
||||
"parent_uid": parent_uid or None,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
if target_type == "post":
|
||||
insert["post_uid"] = target_uid
|
||||
get_table("comments").insert(insert)
|
||||
|
||||
if attachment_uids:
|
||||
link_attachments(attachment_uids, "comment", comment_uid)
|
||||
|
||||
award_rewards(user["uid"], XP_COMMENT, "First Comment")
|
||||
|
||||
comment_url = f"{redirect_url}#comment-{comment_uid}"
|
||||
|
||||
if target_type == "post":
|
||||
if parent_uid:
|
||||
parent = get_table("comments").find_one(uid=parent_uid, deleted_at=None)
|
||||
if parent and parent["user_uid"] != user["uid"]:
|
||||
create_notification(
|
||||
parent["user_uid"],
|
||||
"reply",
|
||||
f"{user['username']} replied to your comment",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
else:
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=target_uid)
|
||||
if not post:
|
||||
post = posts.find_one(slug=target_uid)
|
||||
if post and post["user_uid"] != user["uid"]:
|
||||
create_notification(
|
||||
post["user_uid"],
|
||||
"comment",
|
||||
f"{user['username']} commented on your post",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||
comment_links = [
|
||||
audit.target("comment", comment_uid),
|
||||
audit.parent(target_type, target_uid),
|
||||
]
|
||||
if parent_uid:
|
||||
comment_links.append(audit.link("parent_comment", "comment", parent_uid))
|
||||
audit.record(
|
||||
request,
|
||||
f"comment.create.{target_type}",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} commented on {target_type} {target_uid}: {content}",
|
||||
links=comment_links,
|
||||
)
|
||||
return comment_uid, comment_url
|
||||
|
||||
|
||||
def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]:
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
actor = user["uid"]
|
||||
stamp = _now_iso()
|
||||
soft_delete_attachments_for("comment", [comment["uid"]], actor)
|
||||
soft_delete(
|
||||
"votes", actor, stamp=stamp, target_uid=comment["uid"], target_type="comment"
|
||||
)
|
||||
soft_delete_engagement("comment", [comment["uid"]], actor)
|
||||
soft_delete("comments", actor, stamp=stamp, uid=comment["uid"])
|
||||
logger.info(f"Comment {comment['uid']} soft-deleted by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"comment.delete",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment["uid"],
|
||||
summary=f"{user['username']} deleted a comment under {target_type} {target_uid}",
|
||||
links=[
|
||||
audit.target("comment", comment["uid"]),
|
||||
audit.parent(target_type, target_uid),
|
||||
],
|
||||
)
|
||||
return target_type, target_uid
|
||||
|
||||
|
||||
def set_bookmark(
|
||||
request, user: dict, target_type: str, target_uid: str, saved: bool
|
||||
) -> bool:
|
||||
bookmarks = get_table("bookmarks")
|
||||
existing = bookmarks.find_one(
|
||||
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
|
||||
)
|
||||
changed = False
|
||||
if saved:
|
||||
if existing and existing.get("deleted_at"):
|
||||
bookmarks.update(
|
||||
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
|
||||
)
|
||||
changed = True
|
||||
elif not existing:
|
||||
bookmarks.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"target_uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
changed = True
|
||||
else:
|
||||
if existing and not existing.get("deleted_at"):
|
||||
bookmarks.update(
|
||||
{
|
||||
"id": existing["id"],
|
||||
"deleted_at": _now_iso(),
|
||||
"deleted_by": user["uid"],
|
||||
},
|
||||
["id"],
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
audit.record(
|
||||
request,
|
||||
"bookmark.add" if saved else "bookmark.remove",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
summary=f"{user['username']} {'bookmarked' if saved else 'removed bookmark from'} {target_type} {target_uid}",
|
||||
links=[audit.target(target_type, target_uid)],
|
||||
)
|
||||
return saved
|
||||
|
||||
|
||||
def detail_context(
|
||||
request,
|
||||
user: dict | None,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
from curl_cffi import CurlHttpVersion
|
||||
from curl_cffi.requests import AsyncSession
|
||||
from curl_cffi.requests.exceptions import RequestException, Timeout
|
||||
|
||||
IMPERSONATE_TARGET: str = "chrome146"
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS: float = 30.0
|
||||
|
||||
STRIP_REQUEST_HEADERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"host",
|
||||
"connection",
|
||||
"proxy-connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"user-agent",
|
||||
"accept-encoding",
|
||||
}
|
||||
)
|
||||
|
||||
STRIP_RESPONSE_HEADERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
}
|
||||
)
|
||||
|
||||
HTTP_VERSION_LABELS: dict[int, bytes] = {
|
||||
int(CurlHttpVersion.V1_0): b"HTTP/1.0",
|
||||
int(CurlHttpVersion.V1_1): b"HTTP/1.1",
|
||||
int(CurlHttpVersion.V2_0): b"HTTP/2",
|
||||
int(CurlHttpVersion.V2TLS): b"HTTP/2",
|
||||
int(CurlHttpVersion.V2_PRIOR_KNOWLEDGE): b"HTTP/2",
|
||||
int(CurlHttpVersion.V3): b"HTTP/3",
|
||||
int(CurlHttpVersion.V3ONLY): b"HTTP/3",
|
||||
}
|
||||
|
||||
|
||||
def resolve_timeout(request: httpx.Request) -> float:
|
||||
extension = request.extensions.get("timeout") or {}
|
||||
for key in ("read", "connect", "pool"):
|
||||
value = extension.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
class CurlResponseStream(httpx.AsyncByteStream):
|
||||
def __init__(self, response: object) -> None:
|
||||
self._response = response
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
async for chunk in self._response.aiter_content():
|
||||
yield chunk
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._response.aclose()
|
||||
|
||||
|
||||
class CurlTransport(httpx.AsyncBaseTransport):
|
||||
def __init__(self, *, impersonate: str = IMPERSONATE_TARGET, verify: bool = True) -> None:
|
||||
self._session = AsyncSession()
|
||||
self._impersonate = impersonate
|
||||
self._verify = verify
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in STRIP_REQUEST_HEADERS
|
||||
}
|
||||
body = await request.aread()
|
||||
try:
|
||||
response = await self._session.request(
|
||||
request.method,
|
||||
str(request.url),
|
||||
headers=headers,
|
||||
data=body or None,
|
||||
impersonate=self._impersonate,
|
||||
verify=self._verify,
|
||||
stream=True,
|
||||
allow_redirects=False,
|
||||
timeout=resolve_timeout(request),
|
||||
)
|
||||
except Timeout as exc:
|
||||
raise httpx.ConnectTimeout(str(exc), request=request) from exc
|
||||
except RequestException as exc:
|
||||
raise httpx.ConnectError(str(exc), request=request) from exc
|
||||
|
||||
response_headers = [
|
||||
(key, value)
|
||||
for key, value in response.headers.items()
|
||||
if key.lower() not in STRIP_RESPONSE_HEADERS
|
||||
]
|
||||
http_version = HTTP_VERSION_LABELS.get(int(response.http_version), b"HTTP/2")
|
||||
return httpx.Response(
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
stream=CurlResponseStream(response),
|
||||
extensions={"http_version": http_version},
|
||||
request=request,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._session.close()
|
||||
+60
-16
@@ -135,11 +135,12 @@ SOFT_DELETE_TABLES = [
|
||||
"devii_virtual_tools",
|
||||
"user_customizations",
|
||||
"project_forks",
|
||||
"bug_tickets",
|
||||
"bug_comment_authors",
|
||||
"issue_tickets",
|
||||
"issue_comment_authors",
|
||||
"notification_preferences",
|
||||
"deepsearch_sessions",
|
||||
"deepsearch_messages",
|
||||
"devrant_tokens",
|
||||
]
|
||||
|
||||
|
||||
@@ -155,6 +156,30 @@ def ensure_soft_delete_columns(table, *, db_handle=None):
|
||||
_index(handle, table, f"idx_{table}_deleted", ["deleted_at"])
|
||||
|
||||
|
||||
BUG_TABLE_RENAMES = (
|
||||
("bug_tickets", "issue_tickets"),
|
||||
("bug_comment_authors", "issue_comment_authors"),
|
||||
)
|
||||
|
||||
|
||||
def migrate_bug_tables_to_issue_tables() -> None:
|
||||
for source_name, destination_name in BUG_TABLE_RENAMES:
|
||||
if source_name not in db.tables:
|
||||
continue
|
||||
source = db[source_name]
|
||||
destination = get_table(destination_name)
|
||||
copied = 0
|
||||
for row in source.all():
|
||||
payload = {key: value for key, value in row.items() if key != "id"}
|
||||
destination.insert_ignore(payload, ["uid"])
|
||||
copied += 1
|
||||
logger.info(
|
||||
"Migrated %s rows from %s into %s", copied, source_name, destination_name
|
||||
)
|
||||
source.drop()
|
||||
logger.info("Dropped table %s after migration", source_name)
|
||||
|
||||
|
||||
def init_db():
|
||||
tables = db.tables
|
||||
_index(db, "users", "idx_users_username", ["username"])
|
||||
@@ -163,6 +188,24 @@ def init_db():
|
||||
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
if "posts" in tables:
|
||||
posts_table = get_table("posts")
|
||||
if not posts_table.has_column("tags"):
|
||||
posts_table.create_column_by_example("tags", "")
|
||||
if "devrant_tokens" in tables:
|
||||
devrant_tokens = get_table("devrant_tokens")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("key", ""),
|
||||
("user_uid", ""),
|
||||
("user_id", 0),
|
||||
("expire_time", 0),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not devrant_tokens.has_column(column):
|
||||
devrant_tokens.create_column_by_example(column, example)
|
||||
_index(db, "devrant_tokens", "idx_devrant_tokens_key", ["key"])
|
||||
_index(db, "devrant_tokens", "idx_devrant_tokens_user", ["user_uid"])
|
||||
_index(db, "comments", "idx_comments_post_uid", ["post_uid"])
|
||||
_index(db, "comments", "idx_comments_target", ["target_type", "target_uid"])
|
||||
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
|
||||
@@ -276,7 +319,7 @@ def init_db():
|
||||
_index(db, "news_images", "idx_news_images_news_uid", ["news_uid"])
|
||||
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
|
||||
|
||||
bug_tickets = get_table("bug_tickets")
|
||||
issue_tickets = get_table("issue_tickets")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("gitea_number", 0),
|
||||
@@ -292,10 +335,10 @@ def init_db():
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not bug_tickets.has_column(column):
|
||||
bug_tickets.create_column_by_example(column, example)
|
||||
if not issue_tickets.has_column(column):
|
||||
issue_tickets.create_column_by_example(column, example)
|
||||
|
||||
bug_comment_authors = get_table("bug_comment_authors")
|
||||
issue_comment_authors = get_table("issue_comment_authors")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("gitea_comment_id", 0),
|
||||
@@ -305,20 +348,21 @@ def init_db():
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not bug_comment_authors.has_column(column):
|
||||
bug_comment_authors.create_column_by_example(column, example)
|
||||
if not issue_comment_authors.has_column(column):
|
||||
issue_comment_authors.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "bug_tickets", "idx_bug_tickets_number", ["gitea_number"])
|
||||
_index(db, "bug_tickets", "idx_bug_tickets_author", ["author_uid"])
|
||||
_index(db, "issue_tickets", "idx_issue_tickets_number", ["gitea_number"])
|
||||
_index(db, "issue_tickets", "idx_issue_tickets_author", ["author_uid"])
|
||||
_index(
|
||||
db,
|
||||
"bug_comment_authors",
|
||||
"idx_bug_comment_authors_comment",
|
||||
"issue_comment_authors",
|
||||
"idx_issue_comment_authors_comment",
|
||||
["gitea_comment_id"],
|
||||
)
|
||||
_index(
|
||||
db, "bug_comment_authors", "idx_bug_comment_authors_number", ["gitea_number"]
|
||||
db, "issue_comment_authors", "idx_issue_comment_authors_number", ["gitea_number"]
|
||||
)
|
||||
migrate_bug_tables_to_issue_tables()
|
||||
_index(db, "service_state", "idx_service_state_name", ["name"])
|
||||
if "devii_conversations" in db.tables:
|
||||
conversations = get_table("devii_conversations")
|
||||
@@ -1465,7 +1509,7 @@ NOTIFICATION_TYPES = [
|
||||
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "bug", "label": "Bug tracker", "description": "Updates on bug reports you filed"},
|
||||
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
|
||||
]
|
||||
|
||||
NOTIFICATION_CHANNELS = ("in_app", "push")
|
||||
@@ -2032,8 +2076,8 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
||||
if article:
|
||||
return f"/news/{article.get('slug', '') or article['uid']}"
|
||||
return "/news"
|
||||
if target_type == "bug":
|
||||
return f"/bugs?highlight={target_uid}"
|
||||
if target_type == "issue":
|
||||
return f"/issues?highlight={target_uid}"
|
||||
if target_type == "gist":
|
||||
gist = resolve_by_slug(get_table("gists"), target_uid)
|
||||
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
|
||||
|
||||
+41
-41
@@ -7,7 +7,7 @@ from devplacepy.docs_examples import schema_example, action_example
|
||||
VOTE_TARGETS = ["post", "comment", "gist", "project"]
|
||||
REACTION_TARGETS = ["post", "comment", "gist", "project"]
|
||||
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
|
||||
COMMENT_TARGETS = ["post", "project", "news", "bug", "gist"]
|
||||
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist"]
|
||||
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
||||
GIST_LANGUAGES = [
|
||||
"python",
|
||||
@@ -1724,7 +1724,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, bug.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@@ -1743,7 +1743,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, bug.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@@ -2145,7 +2145,7 @@ four ways to sign requests.
|
||||
|
||||
Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
|
||||
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
|
||||
comment, project, gist, message, or bug - see
|
||||
comment, project, gist, message, or issue - see
|
||||
[Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
|
||||
play inline once posted; other types render as download links. The record's `is_image` and
|
||||
`is_video` flags indicate how the file is displayed.
|
||||
@@ -3487,12 +3487,12 @@ four ways to sign requests.
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "bugs",
|
||||
"title": "Bug Reports",
|
||||
"slug": "issues",
|
||||
"title": "Issue Reports",
|
||||
"intro": """
|
||||
# Bug Reports
|
||||
# Issue Reports
|
||||
|
||||
The bug tracker is a full integration with a Gitea repository. The listing and detail views read
|
||||
The issue tracker is a full integration with a Gitea repository. The listing and detail views read
|
||||
issues straight from Gitea with their live status, and a report you file is first rewritten by the
|
||||
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
|
||||
reporter is notified when a developer replies or the status changes, and a comment posted here is
|
||||
@@ -3504,11 +3504,11 @@ four ways to sign requests.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="bugs-list",
|
||||
id="issues-list",
|
||||
method="GET",
|
||||
path="/bugs",
|
||||
title="List bug tickets",
|
||||
summary="Render the bug board from Gitea, paginated and filterable by state.",
|
||||
path="/issues",
|
||||
title="List issue tickets",
|
||||
summary="Render the issue board from Gitea, paginated and filterable by state.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -3524,11 +3524,11 @@ four ways to sign requests.
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-create",
|
||||
id="issues-create",
|
||||
method="POST",
|
||||
path="/bugs/create",
|
||||
title="Report a bug",
|
||||
summary="Enqueue a bug report. It is enhanced by AI and filed on the tracker.",
|
||||
path="/issues/create",
|
||||
title="Report an issue",
|
||||
summary="Enqueue an issue report. It is enhanced by AI and filed on the tracker.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
ajax=True,
|
||||
@@ -3551,27 +3551,27 @@ four ways to sign requests.
|
||||
"Description, 1-5000 characters.",
|
||||
),
|
||||
],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the bug number."],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the issue number."],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"status_url": "/bugs/jobs/JOB_UID",
|
||||
"status_url": "/issues/jobs/JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-job",
|
||||
id="issues-job",
|
||||
method="GET",
|
||||
path="/bugs/jobs/{uid}",
|
||||
title="Bug filing job status",
|
||||
summary="Poll the filing job; the result carries the new bug number and url.",
|
||||
path="/issues/jobs/{uid}",
|
||||
title="Issue filing job status",
|
||||
summary="Poll the filing job; the result carries the new issue number and url.",
|
||||
auth="public",
|
||||
ajax=True,
|
||||
params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"kind": "bug_create",
|
||||
"kind": "issue_create",
|
||||
"status": "done",
|
||||
"number": 42,
|
||||
"bug_url": "/bugs/42",
|
||||
"issue_url": "/issues/42",
|
||||
"enhanced": True,
|
||||
"error": None,
|
||||
"created_at": "2026-06-12T09:00:00+00:00",
|
||||
@@ -3579,28 +3579,28 @@ four ways to sign requests.
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-detail",
|
||||
id="issues-detail",
|
||||
method="GET",
|
||||
path="/bugs/{number}",
|
||||
title="View a bug ticket",
|
||||
path="/issues/{number}",
|
||||
title="View an issue ticket",
|
||||
summary="Render a Gitea issue and its comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Bug number.")
|
||||
field("number", "path", "integer", True, "12", "Issue number.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-comment",
|
||||
id="issues-comment",
|
||||
method="POST",
|
||||
path="/bugs/{number}/comment",
|
||||
title="Comment on a bug",
|
||||
path="/issues/{number}/comment",
|
||||
title="Comment on an issue",
|
||||
summary="Post a comment to the Gitea issue, attributed to the current user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Bug number."),
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field(
|
||||
"body",
|
||||
"form",
|
||||
@@ -3612,16 +3612,16 @@ four ways to sign requests.
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-status",
|
||||
id="issues-status",
|
||||
method="POST",
|
||||
path="/bugs/{number}/status",
|
||||
title="Change a bug status",
|
||||
path="/issues/{number}/status",
|
||||
title="Change an issue status",
|
||||
summary="Open or close the Gitea issue. Admin only.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Bug number."),
|
||||
field("number", "path", "integer", True, "12", "Issue number."),
|
||||
field(
|
||||
"status",
|
||||
"form",
|
||||
@@ -4231,7 +4231,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, bug.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
@@ -4314,8 +4314,8 @@ _PAGE_RESPONSES = {
|
||||
"leaderboard": schemas.LeaderboardOut,
|
||||
"messages-inbox": schemas.MessagesOut,
|
||||
"notifications-list": schemas.NotificationsOut,
|
||||
"bugs-list": schemas.BugsOut,
|
||||
"bugs-detail": schemas.BugDetailOut,
|
||||
"issues-list": schemas.IssuesOut,
|
||||
"issues-detail": schemas.IssueDetailOut,
|
||||
"bookmarks-saved": schemas.SavedOut,
|
||||
"admin-users": schemas.AdminUsersOut,
|
||||
"admin-news-list": schemas.AdminNewsOut,
|
||||
@@ -4373,8 +4373,8 @@ _ACTION_RESPONSES = {
|
||||
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
||||
"notifications-mark-read": ("/notifications", None),
|
||||
"notifications-mark-all-read": ("/notifications", None),
|
||||
"bugs-comment": ("/bugs/12", {"comment_id": 1}),
|
||||
"bugs-status": ("/bugs/12", {"state": "closed"}),
|
||||
"issues-comment": ("/issues/12", {"comment_id": 1}),
|
||||
"issues-status": ("/issues/12", {"state": "closed"}),
|
||||
"admin-user-role": ("/admin/users", None),
|
||||
"admin-user-password": ("/admin/users", None),
|
||||
"admin-user-toggle": ("/admin/users", None),
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
ROLE_LABELS = {"public": "Public", "user": "Member"}
|
||||
|
||||
|
||||
def field(name, location, type="string", required=False, example="", description="", options=None):
|
||||
spec = {
|
||||
"name": name,
|
||||
"location": location,
|
||||
"type": type,
|
||||
"required": required,
|
||||
"example": example,
|
||||
"description": description,
|
||||
}
|
||||
if options:
|
||||
spec["options"] = list(options)
|
||||
return spec
|
||||
|
||||
|
||||
def endpoint(
|
||||
id,
|
||||
method,
|
||||
path,
|
||||
title,
|
||||
summary,
|
||||
auth="user",
|
||||
encoding="none",
|
||||
destructive=False,
|
||||
params=None,
|
||||
sample_response=None,
|
||||
):
|
||||
return {
|
||||
"id": id,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"auth": auth,
|
||||
"min_role": ROLE_LABELS.get(auth, auth.title()),
|
||||
"encoding": encoding,
|
||||
"destructive": destructive,
|
||||
"params": params or [],
|
||||
"sample_response": sample_response,
|
||||
}
|
||||
|
||||
|
||||
AUTH_TOKEN_SAMPLE = {
|
||||
"success": True,
|
||||
"auth_token": {"id": 18966518, "key": "z6uXRZrQ...", "expire_time": 1782024794, "user_id": 42},
|
||||
}
|
||||
|
||||
RANT_SAMPLE = {
|
||||
"id": 1,
|
||||
"text": "My first rant about Python",
|
||||
"score": 3,
|
||||
"created_time": 1781419994,
|
||||
"attached_image": "",
|
||||
"num_comments": 2,
|
||||
"tags": ["python", "devrant"],
|
||||
"vote_state": 0,
|
||||
"edited": False,
|
||||
"link": "rants/1/my-first-rant-about-python",
|
||||
"rt": 1,
|
||||
"rc": 0,
|
||||
"user_id": 42,
|
||||
"user_username": "alice",
|
||||
"user_score": 17,
|
||||
"user_avatar": {"b": "5c47fe", "i": "u/alice.png"},
|
||||
"user_avatar_lg": {"b": "5c47fe", "i": "u/alice.png"},
|
||||
"editable": False,
|
||||
}
|
||||
|
||||
COMMENT_SAMPLE = {
|
||||
"id": 9,
|
||||
"rant_id": 1,
|
||||
"body": "Nice rant!",
|
||||
"score": 0,
|
||||
"created_time": 1781420050,
|
||||
"vote_state": 0,
|
||||
"user_id": 7,
|
||||
"user_username": "bob",
|
||||
"user_score": 4,
|
||||
"user_avatar": {"b": "1188ff", "i": "u/bob.png"},
|
||||
}
|
||||
|
||||
|
||||
DEVRANT_GROUPS = {
|
||||
"devrant-auth": [
|
||||
endpoint(
|
||||
"devrant-login",
|
||||
"POST",
|
||||
"/api/users/auth-token",
|
||||
"Log in",
|
||||
"Authenticate with username (or email) and password. Running this here logs you in for every widget on these pages.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "body", required=True, example="YOUR_USERNAME", description="Username or email."),
|
||||
field("password", "body", type="password", required=True, example="", description="Account password."),
|
||||
],
|
||||
sample_response=AUTH_TOKEN_SAMPLE,
|
||||
),
|
||||
endpoint(
|
||||
"devrant-register",
|
||||
"POST",
|
||||
"/api/users",
|
||||
"Register",
|
||||
"Create a new account. Returns an auth token (you are logged in immediately).",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "body", required=True, example="newdev", description="3-32 letters, numbers, hyphens, underscores."),
|
||||
field("email", "body", required=True, example="newdev@example.com", description="Valid email address."),
|
||||
field("password", "body", type="password", required=True, example="", description="At least 6 characters."),
|
||||
],
|
||||
sample_response=AUTH_TOKEN_SAMPLE,
|
||||
),
|
||||
endpoint(
|
||||
"devrant-delete-account",
|
||||
"DELETE",
|
||||
"/api/users/me",
|
||||
"Deactivate account",
|
||||
"Deactivate the logged-in account and revoke its tokens.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
sample_response={"success": True},
|
||||
),
|
||||
],
|
||||
"devrant-rants": [
|
||||
endpoint(
|
||||
"devrant-feed",
|
||||
"GET",
|
||||
"/api/devrant/rants",
|
||||
"Rant feed",
|
||||
"List rants. Sort by recent, top, or algo.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("sort", "query", type="enum", example="recent", options=["recent", "top", "algo"], description="Sort order."),
|
||||
field("limit", "query", type="int", example="20", description="Page size, 1-50."),
|
||||
field("skip", "query", type="int", example="0", description="Offset for pagination."),
|
||||
],
|
||||
sample_response={"success": True, "rants": [RANT_SAMPLE], "num_notifs": 0},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-get-rant",
|
||||
"GET",
|
||||
"/api/devrant/rants/{rant_id}",
|
||||
"Single rant with comments",
|
||||
"Fetch one rant and its comments.",
|
||||
auth="public",
|
||||
params=[field("rant_id", "path", type="int", required=True, example="1", description="Rant id.")],
|
||||
sample_response={"success": True, "rant": RANT_SAMPLE, "comments": [COMMENT_SAMPLE], "subscribed": 0},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-create-rant",
|
||||
"POST",
|
||||
"/api/devrant/rants",
|
||||
"Post a rant",
|
||||
"Create a new rant. Tags are comma-separated and stored verbatim.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("rant", "body", type="textarea", required=True, example="Posted from the docs widget", description="Rant text, 1-125000 chars."),
|
||||
field("tags", "body", example="python,devrant", description="Comma-separated tags."),
|
||||
],
|
||||
sample_response={"success": True, "rant_id": 12},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-edit-rant",
|
||||
"POST",
|
||||
"/api/devrant/rants/{rant_id}",
|
||||
"Edit a rant",
|
||||
"Replace a rant's text and tags (owner only).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("rant_id", "path", type="int", required=True, example="1", description="Rant id."),
|
||||
field("rant", "body", type="textarea", required=True, example="Edited text", description="New rant text."),
|
||||
field("tags", "body", example="python", description="Comma-separated tags."),
|
||||
],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-delete-rant",
|
||||
"DELETE",
|
||||
"/api/devrant/rants/{rant_id}",
|
||||
"Delete a rant",
|
||||
"Soft-delete a rant (owner or admin).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[field("rant_id", "path", type="int", required=True, example="1", description="Rant id.")],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-vote-rant",
|
||||
"POST",
|
||||
"/api/devrant/rants/{rant_id}/vote",
|
||||
"Vote on a rant",
|
||||
"Upvote (1), downvote (-1), or clear (0). Returns the updated rant.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("rant_id", "path", type="int", required=True, example="1", description="Rant id."),
|
||||
field("vote", "body", type="enum", required=True, example="1", options=["1", "-1", "0"], description="Vote value."),
|
||||
],
|
||||
sample_response={"success": True, "rant": RANT_SAMPLE},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-favorite",
|
||||
"POST",
|
||||
"/api/devrant/rants/{rant_id}/favorite",
|
||||
"Favorite a rant",
|
||||
"Bookmark a rant.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[field("rant_id", "path", type="int", required=True, example="1", description="Rant id.")],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-unfavorite",
|
||||
"POST",
|
||||
"/api/devrant/rants/{rant_id}/unfavorite",
|
||||
"Unfavorite a rant",
|
||||
"Remove a rant bookmark.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[field("rant_id", "path", type="int", required=True, example="1", description="Rant id.")],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-comment-rant",
|
||||
"POST",
|
||||
"/api/devrant/rants/{rant_id}/comments",
|
||||
"Comment on a rant",
|
||||
"Post a comment on a rant.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("rant_id", "path", type="int", required=True, example="1", description="Rant id."),
|
||||
field("comment", "body", type="textarea", required=True, example="Great rant!", description="Comment text, 1-1000 chars."),
|
||||
],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-search",
|
||||
"GET",
|
||||
"/api/devrant/search",
|
||||
"Search rants",
|
||||
"Search rants by text in the title and body.",
|
||||
auth="public",
|
||||
params=[field("term", "query", required=True, example="python", description="Search text.")],
|
||||
sample_response={"success": True, "results": [RANT_SAMPLE]},
|
||||
),
|
||||
],
|
||||
"devrant-comments": [
|
||||
endpoint(
|
||||
"devrant-get-comment",
|
||||
"GET",
|
||||
"/api/comments/{comment_id}",
|
||||
"Get a comment",
|
||||
"Fetch a single comment by id.",
|
||||
auth="public",
|
||||
params=[field("comment_id", "path", type="int", required=True, example="1", description="Comment id.")],
|
||||
sample_response={"success": True, "comment": COMMENT_SAMPLE},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-edit-comment",
|
||||
"POST",
|
||||
"/api/comments/{comment_id}",
|
||||
"Edit a comment",
|
||||
"Replace a comment's text (owner only).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("comment_id", "path", type="int", required=True, example="1", description="Comment id."),
|
||||
field("comment", "body", type="textarea", required=True, example="Edited comment", description="New comment text."),
|
||||
],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-delete-comment",
|
||||
"DELETE",
|
||||
"/api/comments/{comment_id}",
|
||||
"Delete a comment",
|
||||
"Soft-delete a comment (owner or admin).",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[field("comment_id", "path", type="int", required=True, example="1", description="Comment id.")],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-vote-comment",
|
||||
"POST",
|
||||
"/api/comments/{comment_id}/vote",
|
||||
"Vote on a comment",
|
||||
"Upvote (1), downvote (-1), or clear (0).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("comment_id", "path", type="int", required=True, example="1", description="Comment id."),
|
||||
field("vote", "body", type="enum", required=True, example="1", options=["1", "-1", "0"], description="Vote value."),
|
||||
],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
],
|
||||
"devrant-users": [
|
||||
endpoint(
|
||||
"devrant-get-user-id",
|
||||
"GET",
|
||||
"/api/get-user-id",
|
||||
"Resolve username to id",
|
||||
"Look up a user's integer id from their username.",
|
||||
auth="public",
|
||||
params=[field("username", "query", required=True, example="alice", description="Username to look up.")],
|
||||
sample_response={"success": True, "user_id": 42},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-profile",
|
||||
"GET",
|
||||
"/api/users/{user_id}",
|
||||
"Get a profile",
|
||||
"Fetch a user's devRant profile with their rants and comments.",
|
||||
auth="public",
|
||||
params=[field("user_id", "path", type="int", required=True, example="1", description="User id.")],
|
||||
sample_response={
|
||||
"success": True,
|
||||
"profile": {
|
||||
"username": "alice",
|
||||
"score": 17,
|
||||
"about": "I build things with Python",
|
||||
"location": "Amsterdam",
|
||||
"created_time": 1781419994,
|
||||
"skills": "I build things with Python",
|
||||
"github": "alice",
|
||||
"website": "https://alice.dev",
|
||||
"avatar": {"b": "5c47fe", "i": "u/alice.png"},
|
||||
"content": {
|
||||
"content": {"rants": [RANT_SAMPLE], "upvoted": [], "comments": [COMMENT_SAMPLE], "favorites": [], "viewed": []},
|
||||
"counts": {"rants": 1, "upvoted": 0, "comments": 1, "favorites": 0, "collabs": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-edit-profile",
|
||||
"POST",
|
||||
"/api/users/me/edit-profile",
|
||||
"Edit your profile",
|
||||
"Update bio, location, git link, and website. profile_skills is accepted but ignored (skills are derived from the bio).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("profile_about", "body", type="textarea", example="I build with Python and Rust", description="Bio."),
|
||||
field("profile_location", "body", example="Amsterdam", description="Location."),
|
||||
field("profile_github", "body", example="alice", description="Git link."),
|
||||
field("profile_website", "body", example="https://alice.dev", description="Website."),
|
||||
],
|
||||
sample_response={"success": True},
|
||||
),
|
||||
],
|
||||
"devrant-notifications": [
|
||||
endpoint(
|
||||
"devrant-notif-feed",
|
||||
"GET",
|
||||
"/api/users/me/notif-feed",
|
||||
"Notification feed",
|
||||
"Fetch the logged-in user's notification feed with unread counts.",
|
||||
auth="user",
|
||||
sample_response={
|
||||
"success": True,
|
||||
"data": {
|
||||
"items": [{"type": "comment_discuss", "rant_id": 0, "comment_id": 0, "created_time": 1781420050, "read": 0, "uid": 7, "username": "bob"}],
|
||||
"check_time": 1781420060,
|
||||
"username_map": {"7": "bob"},
|
||||
"unread": {"all": 1, "upvotes": 0, "mentions": 0, "comments": 1, "subs": 0, "total": 1},
|
||||
"num_unread": 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
"devrant-clear-notif-feed",
|
||||
"DELETE",
|
||||
"/api/users/me/notif-feed",
|
||||
"Clear notifications",
|
||||
"Mark every notification read.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
sample_response={"success": True},
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def devrant_endpoints(slug: str) -> list:
|
||||
return DEVRANT_GROUPS.get(slug, [])
|
||||
+21
-8
@@ -54,7 +54,7 @@ from devplacepy.routers import (
|
||||
follow,
|
||||
admin,
|
||||
seo,
|
||||
bugs,
|
||||
issues,
|
||||
news,
|
||||
gists,
|
||||
uploads,
|
||||
@@ -71,19 +71,23 @@ from devplacepy.routers import (
|
||||
forks,
|
||||
proxy,
|
||||
tools,
|
||||
xmlrpc,
|
||||
devrant,
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.news import NewsService
|
||||
from devplacepy.services.bot import BotsService
|
||||
from devplacepy.services.openai_gateway import GatewayService
|
||||
from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||
from devplacepy.services.jobs.issue_create_service import IssueCreateService
|
||||
from devplacepy.services.jobs.seo.service import SeoService
|
||||
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.gitea.service import BugTrackerService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
@@ -172,7 +176,7 @@ class CachedStaticFiles(StaticFiles):
|
||||
app = FastAPI(
|
||||
title="DevPlace",
|
||||
docs_url="/swagger",
|
||||
redoc_url="/redoc",
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
app.mount(
|
||||
@@ -316,7 +320,7 @@ app.include_router(push.router)
|
||||
app.include_router(docs.router)
|
||||
app.include_router(openai_gateway.router, prefix="/openai")
|
||||
app.include_router(devii.router, prefix="/devii")
|
||||
app.include_router(bugs.router, prefix="/bugs")
|
||||
app.include_router(issues.router, prefix="/issues")
|
||||
app.include_router(gists.router, prefix="/gists")
|
||||
app.include_router(news.router, prefix="/news")
|
||||
app.include_router(uploads.router, prefix="/uploads")
|
||||
@@ -325,6 +329,8 @@ app.include_router(zips.router, prefix="/zips")
|
||||
app.include_router(forks.router, prefix="/forks")
|
||||
app.include_router(proxy.router, prefix="/p")
|
||||
app.include_router(tools.router, prefix="/tools")
|
||||
app.include_router(xmlrpc.router, prefix="/xmlrpc")
|
||||
app.include_router(devrant.router, prefix="/api")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -339,6 +345,10 @@ async def add_security_headers(request: Request, call_next):
|
||||
if not response.headers.get("X-Robots-Tag"):
|
||||
response.headers["X-Robots-Tag"] = "index, follow"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
if not request.url.path.startswith("/p/"):
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
if request.url.path.startswith("/admin"):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
@@ -355,7 +365,7 @@ async def rate_limit_middleware(request: Request, call_next):
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
) and not request.url.path.startswith("/openai"):
|
||||
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
|
||||
limit = _worker_rate_limit(
|
||||
max(1, get_int_setting("rate_limit_per_minute", RATE_LIMIT))
|
||||
)
|
||||
@@ -440,11 +450,13 @@ async def startup():
|
||||
service_manager.register(ForkService())
|
||||
service_manager.register(SeoService())
|
||||
service_manager.register(DeepsearchService())
|
||||
service_manager.register(BugCreateService())
|
||||
service_manager.register(BugTrackerService())
|
||||
service_manager.register(IssueCreateService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(XmlrpcService())
|
||||
service_manager.register(AuditService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
await background.start()
|
||||
if acquire_service_lock():
|
||||
service_manager.set_lock_owner(True)
|
||||
service_manager.supervise()
|
||||
@@ -460,6 +472,7 @@ async def startup():
|
||||
async def shutdown():
|
||||
logger.info("Shutting down services...")
|
||||
await service_manager.shutdown_all()
|
||||
await background.stop()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -157,7 +157,7 @@ class CommentForm(BaseModel):
|
||||
content: str = Field(min_length=3, max_length=1000)
|
||||
target_uid: str = Field(default="", max_length=36)
|
||||
post_uid: str = Field(default="", max_length=36)
|
||||
target_type: Literal["post", "project", "news", "bug", "gist"] = "post"
|
||||
target_type: Literal["post", "project", "news", "issue", "gist"] = "post"
|
||||
parent_uid: str = Field(default="", max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
@@ -331,16 +331,16 @@ class GistEditForm(BaseModel):
|
||||
language: str = Field(default="plaintext", max_length=50)
|
||||
|
||||
|
||||
class BugForm(BaseModel):
|
||||
class IssueForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
description: str = Field(min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class BugCommentForm(BaseModel):
|
||||
class IssueCommentForm(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class BugStatusForm(BaseModel):
|
||||
class IssueStatusForm(BaseModel):
|
||||
status: Literal["open", "closed"]
|
||||
|
||||
|
||||
|
||||
+12
-4
@@ -10,6 +10,8 @@ from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
|
||||
NAT64_PREFIXES = (
|
||||
ipaddress.ip_network("64:ff9b::/96"),
|
||||
ipaddress.ip_network("64:ff9b:1::/48"),
|
||||
@@ -64,12 +66,18 @@ async def guard_public_url(url: str, *, allow_private: bool = False) -> str:
|
||||
return host
|
||||
|
||||
|
||||
class _GuardedTransport(httpx.AsyncHTTPTransport):
|
||||
class _GuardedTransport(httpx.AsyncBaseTransport):
|
||||
def __init__(self, inner: httpx.AsyncBaseTransport) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
await guard_public_url(str(request.url))
|
||||
return await super().handle_async_request(request)
|
||||
return await self._inner.handle_async_request(request)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._inner.aclose()
|
||||
|
||||
|
||||
def guarded_async_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
transport = _GuardedTransport()
|
||||
return httpx.AsyncClient(transport=transport, **kwargs)
|
||||
transport = _GuardedTransport(stealth.stealth_transport())
|
||||
return stealth.stealth_async_client(transport=transport, **kwargs)
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.hashes import SHA256
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import (
|
||||
SECONDS_PER_DAY,
|
||||
VAPID_PRIVATE_KEY_FILE,
|
||||
@@ -239,7 +240,7 @@ async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
body = json.dumps(payload)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with stealth.stealth_async_client(timeout=10.0) as client:
|
||||
for subscription in registrations:
|
||||
endpoint = subscription["endpoint"]
|
||||
try:
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import get_table, get_setting, get_int_setting
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import (
|
||||
hash_password,
|
||||
create_session,
|
||||
generate_uid,
|
||||
get_current_user,
|
||||
award_badge,
|
||||
register_account,
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.models import SignupForm
|
||||
@@ -93,29 +90,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
|
||||
},
|
||||
)
|
||||
|
||||
uid = generate_uid()
|
||||
is_first = users.count() == 0
|
||||
users.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"api_key": generate_uid(),
|
||||
"password_hash": hash_password(password),
|
||||
"bio": "",
|
||||
"location": "",
|
||||
"git_link": "",
|
||||
"website": "",
|
||||
"role": "Admin" if is_first else "Member",
|
||||
"is_active": True,
|
||||
"level": 1,
|
||||
"xp": 0,
|
||||
"stars": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
award_badge(uid, "Member")
|
||||
uid, role, is_first = register_account(username, email, password)
|
||||
|
||||
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
|
||||
token = create_session(uid, max_age)
|
||||
@@ -124,7 +99,6 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
|
||||
key="session", value=token, max_age=max_age, httponly=True, samesite="lax"
|
||||
)
|
||||
logger.info(f"User {username} signed up")
|
||||
role = "Admin" if is_first else "Member"
|
||||
audit.record(
|
||||
request,
|
||||
"auth.signup",
|
||||
|
||||
+11
-106
@@ -2,27 +2,11 @@
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
soft_delete_engagement,
|
||||
_now_iso,
|
||||
)
|
||||
from devplacepy.attachments import link_attachments, soft_delete_attachments_for
|
||||
from devplacepy.content import is_owner
|
||||
from devplacepy.utils import (
|
||||
generate_uid,
|
||||
require_user,
|
||||
create_mention_notifications,
|
||||
create_notification,
|
||||
award_rewards,
|
||||
is_admin,
|
||||
XP_COMMENT,
|
||||
)
|
||||
from devplacepy.database import get_table, resolve_object_url
|
||||
from devplacepy.content import is_owner, create_comment_record, delete_comment_record
|
||||
from devplacepy.utils import require_user, is_admin
|
||||
from devplacepy.models import CommentForm
|
||||
from devplacepy.responses import action_result, wants_json, json_error
|
||||
from devplacepy.services.audit import record as audit
|
||||
@@ -34,77 +18,15 @@ router = APIRouter()
|
||||
@router.post("/create")
|
||||
async def create_comment(request: Request, data: Annotated[CommentForm, Form()]):
|
||||
user = require_user(request)
|
||||
content = data.content.strip()
|
||||
target_uid = data.target_uid or data.post_uid
|
||||
target_type = data.target_type
|
||||
parent_uid = data.parent_uid
|
||||
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
|
||||
comment_uid = generate_uid()
|
||||
insert = {
|
||||
"uid": comment_uid,
|
||||
"target_uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"user_uid": user["uid"],
|
||||
"content": content,
|
||||
"parent_uid": parent_uid or None,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
if target_type == "post":
|
||||
insert["post_uid"] = target_uid
|
||||
get_table("comments").insert(insert)
|
||||
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "comment", comment_uid)
|
||||
|
||||
award_rewards(user["uid"], XP_COMMENT, "First Comment")
|
||||
|
||||
comment_url = f"{redirect_url}#comment-{comment_uid}"
|
||||
|
||||
if target_type == "post":
|
||||
if parent_uid:
|
||||
parent = get_table("comments").find_one(uid=parent_uid, deleted_at=None)
|
||||
if parent and parent["user_uid"] != user["uid"]:
|
||||
create_notification(
|
||||
parent["user_uid"],
|
||||
"reply",
|
||||
f"{user['username']} replied to your comment",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
else:
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=target_uid)
|
||||
if not post:
|
||||
post = posts.find_one(slug=target_uid)
|
||||
if post and post["user_uid"] != user["uid"]:
|
||||
create_notification(
|
||||
post["user_uid"],
|
||||
"comment",
|
||||
f"{user['username']} commented on your post",
|
||||
user["uid"],
|
||||
comment_url,
|
||||
)
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||
comment_links = [
|
||||
audit.target("comment", comment_uid),
|
||||
audit.parent(target_type, target_uid),
|
||||
]
|
||||
if parent_uid:
|
||||
comment_links.append(audit.link("parent_comment", "comment", parent_uid))
|
||||
audit.record(
|
||||
comment_uid, comment_url = create_comment_record(
|
||||
request,
|
||||
f"comment.create.{target_type}",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} commented on {target_type} {target_uid}: {content}",
|
||||
links=comment_links,
|
||||
user,
|
||||
data.target_type,
|
||||
target_uid,
|
||||
data.content.strip(),
|
||||
data.parent_uid,
|
||||
data.attachment_uids,
|
||||
)
|
||||
return action_result(
|
||||
request, comment_url, data={"uid": comment_uid, "url": comment_url}
|
||||
@@ -129,23 +51,6 @@ async def delete_comment(request: Request, comment_uid: str):
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
target_type = comment.get("target_type", "post")
|
||||
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
|
||||
actor = user["uid"]
|
||||
stamp = _now_iso()
|
||||
soft_delete_attachments_for("comment", [comment_uid], actor)
|
||||
soft_delete("votes", actor, stamp=stamp, target_uid=comment_uid, target_type="comment")
|
||||
soft_delete_engagement("comment", [comment_uid], actor)
|
||||
soft_delete("comments", actor, stamp=stamp, uid=comment_uid)
|
||||
logger.info(f"Comment {comment_uid} soft-deleted by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"comment.delete",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} deleted a comment under {target_type} {target_uid}",
|
||||
links=[audit.target("comment", comment_uid), audit.parent(target_type, target_uid)],
|
||||
)
|
||||
target_type, target_uid = delete_comment_record(request, user, comment)
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
return action_result(request, redirect_url)
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import uuid_utils
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.seo import base_seo_context, site_url
|
||||
@@ -183,7 +183,7 @@ async def clippy_proxy(request: Request):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cfg.get("devii_ai_key"):
|
||||
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
||||
async with httpx.AsyncClient(timeout=45.0) as client:
|
||||
async with stealth.stealth_async_client(timeout=45.0) as client:
|
||||
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
|
||||
audit.record(
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from devplacepy.routers.devrant._shared import ensure_enabled
|
||||
from devplacepy.routers.devrant import auth, rants, comments, notifs
|
||||
|
||||
router = APIRouter(dependencies=[Depends(ensure_enabled)])
|
||||
router.include_router(auth.router)
|
||||
router.include_router(rants.router)
|
||||
router.include_router(comments.router)
|
||||
router.include_router(notifs.router)
|
||||
@@ -0,0 +1,37 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_setting
|
||||
|
||||
|
||||
def api_enabled() -> bool:
|
||||
return get_setting("devrant_api_enabled", "1") == "1"
|
||||
|
||||
|
||||
def ensure_enabled() -> None:
|
||||
if not api_enabled():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
def dr_json(payload: dict, status_code: int = 200) -> JSONResponse:
|
||||
return JSONResponse(payload, status_code=status_code)
|
||||
|
||||
|
||||
def dr_ok(**fields) -> JSONResponse:
|
||||
return dr_json({"success": True, **fields})
|
||||
|
||||
|
||||
def dr_error(message: str, status_code: int = 200, **extra) -> JSONResponse:
|
||||
return dr_json({"success": False, "error": message, **extra}, status_code=status_code)
|
||||
|
||||
|
||||
def unauthorized() -> JSONResponse:
|
||||
return dr_error("Authentication required.", 401)
|
||||
|
||||
|
||||
def not_found(message: str = "Not found.") -> Optional[JSONResponse]:
|
||||
return dr_error(message)
|
||||
@@ -0,0 +1,222 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_table, get_setting
|
||||
from devplacepy.utils import verify_password, register_account
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import issue_token, resolve_user, revoke_all
|
||||
from devplacepy.services.devrant.profile import build_profile
|
||||
from devplacepy.services.devrant.ids import user_by_id
|
||||
from devplacepy.services.devrant.avatar import render_png
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
|
||||
PASSWORD_MIN = 6
|
||||
|
||||
_avatar_cache = TTLCache(ttl=SECONDS_PER_DAY, max_size=4096)
|
||||
|
||||
|
||||
@router.post("/users/auth-token")
|
||||
async def auth_token(request: Request):
|
||||
params = await merge_params(request)
|
||||
identifier = (params.get("username") or "").strip()
|
||||
password = params.get("password") or ""
|
||||
invalid = "Invalid login credentials entered. Please try again."
|
||||
if not identifier or not password:
|
||||
return dr_error(invalid, 400)
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=identifier) or users.find_one(
|
||||
email=identifier.lower()
|
||||
)
|
||||
if (
|
||||
not user
|
||||
or not user.get("is_active", True)
|
||||
or not verify_password(password, user["password_hash"])
|
||||
):
|
||||
audit.record(
|
||||
request,
|
||||
"auth.login.failure",
|
||||
user=None,
|
||||
actor_kind="guest",
|
||||
result="failure",
|
||||
origin="devrant",
|
||||
metadata={"identifier": identifier},
|
||||
summary=f"failed devrant login for {identifier}",
|
||||
)
|
||||
return dr_error(invalid, 400)
|
||||
token = issue_token(user)
|
||||
logger.info("devrant login for %s", user["username"])
|
||||
audit.record(
|
||||
request,
|
||||
"auth.login.success",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
origin="devrant",
|
||||
summary=f"user {user['username']} logged in via devrant",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return dr_ok(auth_token=token)
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def register(request: Request):
|
||||
params = await merge_params(request)
|
||||
username = (params.get("username") or "").strip()
|
||||
email = (params.get("email") or "").strip().lower()
|
||||
password = params.get("password") or ""
|
||||
if get_setting("registration_open", "1") != "1":
|
||||
return dr_error("Registration is closed.", error_field="username")
|
||||
if not USERNAME_PATTERN.match(username):
|
||||
return dr_error(
|
||||
"Username must be 3-32 letters, numbers, hyphens or underscores.",
|
||||
error_field="username",
|
||||
)
|
||||
if "@" not in email:
|
||||
return dr_error("Please enter a valid email address.", error_field="email")
|
||||
if len(password) < PASSWORD_MIN:
|
||||
return dr_error(
|
||||
"Password must be at least 6 characters.", error_field="password"
|
||||
)
|
||||
users = get_table("users")
|
||||
if users.find_one(username=username):
|
||||
return dr_error("That username is already taken.", error_field="username")
|
||||
if users.find_one(email=email):
|
||||
return dr_error("That email is already registered.", error_field="email")
|
||||
uid, role, is_first = register_account(username, email, password)
|
||||
logger.info("devrant registration for %s", username)
|
||||
audit.record(
|
||||
request,
|
||||
"auth.signup",
|
||||
user={"uid": uid, "username": username, "role": role},
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=username,
|
||||
new_value=role,
|
||||
origin="devrant",
|
||||
metadata={"first_user": is_first},
|
||||
summary=f"user {username} registered via devrant",
|
||||
links=[audit.target("user", uid, username)],
|
||||
)
|
||||
user = users.find_one(uid=uid)
|
||||
return dr_ok(auth_token=issue_token(user))
|
||||
|
||||
|
||||
@router.get("/get-user-id")
|
||||
async def get_user_id(request: Request):
|
||||
params = await merge_params(request)
|
||||
username = (params.get("username") or "").strip()
|
||||
user = get_table("users").find_one(username=username) if username else None
|
||||
if not user:
|
||||
return dr_error("User not found.")
|
||||
return dr_ok(user_id=int(user["id"]))
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def profile(request: Request, user_id: str):
|
||||
params = await merge_params(request)
|
||||
user = user_by_id(user_id)
|
||||
if not user:
|
||||
return dr_error("User not found.")
|
||||
viewer = resolve_user(params)
|
||||
return dr_ok(profile=build_profile(user, viewer))
|
||||
|
||||
|
||||
@router.post("/users/me/edit-profile")
|
||||
async def edit_profile(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
updates = {"uid": user["uid"]}
|
||||
field_map = {
|
||||
"profile_about": "bio",
|
||||
"profile_location": "location",
|
||||
"profile_github": "git_link",
|
||||
"profile_website": "website",
|
||||
}
|
||||
for source, column in field_map.items():
|
||||
if source in params:
|
||||
updates[column] = (params.get(source) or "")[:500]
|
||||
if len(updates) > 1:
|
||||
get_table("users").update(updates, ["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"user.profile.edit",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
origin="devrant",
|
||||
summary=f"{user['username']} edited profile via devrant",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.post("/users/forgot-password")
|
||||
async def forgot_password(request: Request):
|
||||
await merge_params(request)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.post("/users/me/mark-news-read")
|
||||
async def mark_news_read(request: Request):
|
||||
await merge_params(request)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.post("/users/me/resend-confirm")
|
||||
async def resend_confirm(request: Request):
|
||||
await merge_params(request)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.delete("/users/me")
|
||||
async def delete_account(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
get_table("users").update(
|
||||
{"uid": user["uid"], "is_active": False}, ["uid"]
|
||||
)
|
||||
revoke_all(user["uid"])
|
||||
logger.info("devrant account deactivated for %s", user["username"])
|
||||
audit.record(
|
||||
request,
|
||||
"auth.account.disable",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
origin="devrant",
|
||||
summary=f"{user['username']} deactivated account via devrant",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.get("/avatars/u/{seed}.png")
|
||||
async def avatar_image(request: Request, seed: str, size: int = 128):
|
||||
size = max(16, min(512, size))
|
||||
cache_key = f"{seed}:{size}"
|
||||
png = _avatar_cache.get(cache_key)
|
||||
if png is None:
|
||||
png = render_png(seed, size)
|
||||
_avatar_cache.set(cache_key, png)
|
||||
headers = {"Cache-Control": f"public, max-age={SECONDS_PER_DAY}, immutable"}
|
||||
return Response(content=png, media_type="image/png", headers=headers)
|
||||
@@ -0,0 +1,121 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
get_user_votes,
|
||||
get_vote_counts,
|
||||
get_user_stars,
|
||||
)
|
||||
from devplacepy.content import apply_vote, delete_comment_record, is_owner, is_admin
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.services.devrant.ids import as_int, comment_by_id
|
||||
from devplacepy.services.devrant.serializers import serialize_comment
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _rant_id_for(comment: dict) -> int:
|
||||
post = get_table("posts").find_one(uid=comment.get("target_uid"))
|
||||
return int(post["id"]) if post else 0
|
||||
|
||||
|
||||
def _serialize_single(comment: dict, viewer) -> dict:
|
||||
authors = get_users_by_uids([comment["user_uid"]])
|
||||
user_scores = {comment["user_uid"]: get_user_stars(comment["user_uid"])}
|
||||
ups, downs = get_vote_counts([comment["uid"]])
|
||||
score_map = {comment["uid"]: ups.get(comment["uid"], 0) - downs.get(comment["uid"], 0)}
|
||||
my_votes = get_user_votes(viewer["uid"], [comment["uid"]]) if viewer else {}
|
||||
return serialize_comment(
|
||||
comment,
|
||||
_rant_id_for(comment),
|
||||
authors=authors,
|
||||
user_scores=user_scores,
|
||||
my_votes=my_votes,
|
||||
score_map=score_map,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comments/{comment_id}")
|
||||
async def get_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_user(params)
|
||||
comment = comment_by_id(comment_id)
|
||||
if not comment:
|
||||
return dr_error("Invalid comment specified in path.")
|
||||
return dr_ok(comment=_serialize_single(comment, viewer))
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}")
|
||||
async def edit_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
comment = comment_by_id(comment_id)
|
||||
if not comment:
|
||||
return dr_error("Invalid comment specified in path.")
|
||||
if not is_owner(comment, user):
|
||||
return dr_error("You can only edit your own comments.", fail_reason="not_owner")
|
||||
text = (params.get("comment") or "").strip()
|
||||
if len(text) < 1 or len(text) > 1000:
|
||||
return dr_error("Invalid comment length.", fail_reason="length")
|
||||
get_table("comments").update(
|
||||
{
|
||||
"uid": comment["uid"],
|
||||
"content": text,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"comment.edit",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment["uid"],
|
||||
origin="devrant",
|
||||
summary=f"{user['username']} edited comment {comment['id']}",
|
||||
links=[audit.target("comment", comment["uid"])],
|
||||
)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
comment = comment_by_id(comment_id)
|
||||
if not comment:
|
||||
return dr_error("Invalid comment specified in path.")
|
||||
if not (is_owner(comment, user) or is_admin(user)):
|
||||
return dr_error("You can only delete your own comments.")
|
||||
delete_comment_record(request, user, comment)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/vote")
|
||||
async def vote_comment(request: Request, comment_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
comment = comment_by_id(comment_id)
|
||||
if not comment:
|
||||
return dr_error("Invalid comment specified in path.")
|
||||
value = as_int(params.get("vote"))
|
||||
if value not in (-1, 0, 1):
|
||||
return dr_error("Invalid vote.")
|
||||
apply_vote(request, user, "comment", comment["uid"], value)
|
||||
return dr_ok()
|
||||
@@ -0,0 +1,32 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.services.devrant.notifications import build_notif_feed, clear_notifications
|
||||
from devplacepy.routers.devrant._shared import dr_ok, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/users/me/notif-feed")
|
||||
async def notif_feed(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
return dr_ok(data=build_notif_feed(user))
|
||||
|
||||
|
||||
@router.delete("/users/me/notif-feed")
|
||||
async def clear_notif_feed(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
clear_notifications(user)
|
||||
return dr_ok()
|
||||
@@ -0,0 +1,229 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import XP_POST
|
||||
from devplacepy.content import (
|
||||
create_content_item,
|
||||
delete_content_item,
|
||||
apply_vote,
|
||||
create_comment_record,
|
||||
set_bookmark,
|
||||
is_owner,
|
||||
is_admin,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.devrant.params import merge_params
|
||||
from devplacepy.services.devrant.tokens import resolve_user
|
||||
from devplacepy.services.devrant.ids import as_int, post_by_id
|
||||
from devplacepy.services.devrant.feed import list_rants, search_rants, load_rant_detail
|
||||
from devplacepy.services.devrant.serializers import encode_tags
|
||||
from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 50
|
||||
|
||||
|
||||
def _parse_tags(raw: object) -> list:
|
||||
if not raw:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(tag).strip() for tag in raw if str(tag).strip()]
|
||||
return [tag.strip() for tag in str(raw).split(",") if tag.strip()]
|
||||
|
||||
|
||||
@router.get("/devrant/rants")
|
||||
async def rant_feed(request: Request):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_user(params)
|
||||
sort = params.get("sort") or "recent"
|
||||
limit = min(MAX_LIMIT, max(1, as_int(params.get("limit"), DEFAULT_LIMIT)))
|
||||
skip = max(0, as_int(params.get("skip"), 0))
|
||||
rants = list_rants(sort, limit, skip, viewer)
|
||||
num_notifs = 0
|
||||
if viewer:
|
||||
num_notifs = get_table("notifications").count(
|
||||
user_uid=viewer["uid"], read=False
|
||||
)
|
||||
return dr_ok(
|
||||
rants=rants,
|
||||
settings={"notif_state": -1, "notif_token": ""},
|
||||
set=secrets.token_hex(6),
|
||||
wrw=0,
|
||||
dpp=0,
|
||||
num_notifs=num_notifs,
|
||||
unread={"total": num_notifs},
|
||||
news=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devrant/search")
|
||||
async def search(request: Request):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_user(params)
|
||||
term = (params.get("term") or "").strip()
|
||||
return dr_ok(results=search_rants(term, viewer) if term else [])
|
||||
|
||||
|
||||
@router.post("/devrant/rants")
|
||||
async def create_rant(request: Request):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
text = (params.get("rant") or "").strip()
|
||||
if len(text) < 1:
|
||||
return dr_error("Your rant is too short.")
|
||||
if len(text) > 125000:
|
||||
return dr_error("Your rant is too long.")
|
||||
tags = _parse_tags(params.get("tags"))
|
||||
uid, slug = create_content_item(
|
||||
"posts",
|
||||
"post",
|
||||
user,
|
||||
{
|
||||
"title": None,
|
||||
"content": text,
|
||||
"topic": "rant",
|
||||
"project_uid": None,
|
||||
"image": None,
|
||||
"tags": encode_tags(tags),
|
||||
},
|
||||
text[:50],
|
||||
XP_POST,
|
||||
"First Post",
|
||||
text,
|
||||
None,
|
||||
request,
|
||||
)
|
||||
post = get_table("posts").find_one(uid=uid)
|
||||
return dr_ok(rant_id=int(post["id"]))
|
||||
|
||||
|
||||
@router.get("/devrant/rants/{rant_id}")
|
||||
async def get_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
viewer = resolve_user(params)
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.")
|
||||
detail = load_rant_detail(post, viewer)
|
||||
return dr_ok(rant=detail["rant"], comments=detail["comments"], subscribed=0)
|
||||
|
||||
|
||||
@router.post("/devrant/rants/{rant_id}")
|
||||
async def edit_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.", fail_reason="not_found")
|
||||
if not is_owner(post, user):
|
||||
return dr_error("You can only edit your own rants.", fail_reason="not_owner")
|
||||
text = (params.get("rant") or "").strip()
|
||||
if len(text) < 1 or len(text) > 125000:
|
||||
return dr_error("Invalid rant length.", fail_reason="length")
|
||||
tags = _parse_tags(params.get("tags"))
|
||||
get_table("posts").update(
|
||||
{
|
||||
"uid": post["uid"],
|
||||
"content": text,
|
||||
"tags": encode_tags(tags),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"post.edit",
|
||||
user=user,
|
||||
target_type="post",
|
||||
target_uid=post["uid"],
|
||||
origin="devrant",
|
||||
summary=f"{user['username']} edited rant {post['id']}",
|
||||
links=[audit.target("post", post["uid"])],
|
||||
)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.delete("/devrant/rants/{rant_id}")
|
||||
async def delete_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.")
|
||||
if not (is_owner(post, user) or is_admin(user)):
|
||||
return dr_error("You can only delete your own rants.")
|
||||
delete_content_item(request, "posts", "post", user, post["slug"], "/feed")
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.post("/devrant/rants/{rant_id}/vote")
|
||||
async def vote_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.")
|
||||
value = as_int(params.get("vote"))
|
||||
if value not in (-1, 0, 1):
|
||||
return dr_error("Invalid vote.")
|
||||
apply_vote(request, user, "post", post["uid"], value)
|
||||
fresh = get_table("posts").find_one(uid=post["uid"])
|
||||
detail = load_rant_detail(fresh, user)
|
||||
return dr_ok(rant=detail["rant"])
|
||||
|
||||
|
||||
@router.post("/devrant/rants/{rant_id}/favorite")
|
||||
async def favorite_rant(request: Request, rant_id: str):
|
||||
return await _set_favorite(request, rant_id, True)
|
||||
|
||||
|
||||
@router.post("/devrant/rants/{rant_id}/unfavorite")
|
||||
async def unfavorite_rant(request: Request, rant_id: str):
|
||||
return await _set_favorite(request, rant_id, False)
|
||||
|
||||
|
||||
async def _set_favorite(request: Request, rant_id: str, saved: bool):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.")
|
||||
set_bookmark(request, user, "post", post["uid"], saved)
|
||||
return dr_ok()
|
||||
|
||||
|
||||
@router.post("/devrant/rants/{rant_id}/comments")
|
||||
async def comment_rant(request: Request, rant_id: str):
|
||||
params = await merge_params(request)
|
||||
user = resolve_user(params)
|
||||
if not user:
|
||||
return unauthorized()
|
||||
post = post_by_id(rant_id)
|
||||
if not post:
|
||||
return dr_error("This rant does not exist.")
|
||||
text = (params.get("comment") or "").strip()
|
||||
if len(text) < 1:
|
||||
return dr_error("Your comment is too short.")
|
||||
if len(text) > 1000:
|
||||
return dr_error("Your comment is too long.")
|
||||
create_comment_record(request, user, "post", post["uid"], text)
|
||||
return dr_ok()
|
||||
@@ -7,6 +7,7 @@ SECTION_TOOLS = "Tools"
|
||||
SECTION_COMPONENTS = "Components"
|
||||
SECTION_STYLES = "Styles"
|
||||
SECTION_API = "API"
|
||||
SECTION_DEVRANT = "devRant API"
|
||||
SECTION_ADMIN = "Administration"
|
||||
SECTION_DEVII = "Devii internals"
|
||||
SECTION_BOTS = "Bots internals"
|
||||
@@ -23,7 +24,10 @@ AUDIENCE_OPERATE = "Operate"
|
||||
|
||||
AUDIENCES = [
|
||||
(AUDIENCE_START, [SECTION_GENERAL, SECTION_TOOLS]),
|
||||
(AUDIENCE_BUILD, [SECTION_API, SECTION_COMPONENTS, SECTION_STYLES]),
|
||||
(
|
||||
AUDIENCE_BUILD,
|
||||
[SECTION_API, SECTION_DEVRANT, SECTION_COMPONENTS, SECTION_STYLES],
|
||||
),
|
||||
(
|
||||
AUDIENCE_CONTRIBUTE,
|
||||
[
|
||||
@@ -226,6 +230,55 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_API,
|
||||
},
|
||||
{
|
||||
"slug": "xmlrpc",
|
||||
"title": "XML-RPC API",
|
||||
"kind": "prose",
|
||||
"section": SECTION_API,
|
||||
},
|
||||
# devRant API - legacy-compatible protocol, spread over focused pages
|
||||
{
|
||||
"slug": "devrant",
|
||||
"title": "Overview",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
{
|
||||
"slug": "devrant-auth",
|
||||
"title": "Authentication & accounts",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
{
|
||||
"slug": "devrant-rants",
|
||||
"title": "Rants",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
{
|
||||
"slug": "devrant-comments",
|
||||
"title": "Comments",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
{
|
||||
"slug": "devrant-users",
|
||||
"title": "Users & avatars",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
{
|
||||
"slug": "devrant-notifications",
|
||||
"title": "Notifications",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
{
|
||||
"slug": "devrant-clients",
|
||||
"title": "Client scripts",
|
||||
"kind": "prose",
|
||||
"section": SECTION_DEVRANT,
|
||||
},
|
||||
*[
|
||||
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
|
||||
for page in api_doc_pages()
|
||||
|
||||
@@ -145,7 +145,7 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=gist.get("title", "Gist"),
|
||||
description=gist.get("description", "")[:160],
|
||||
description=(gist.get("description") or "")[:160],
|
||||
og_type="article",
|
||||
og_image=first_image_url(gist, detail["attachments"]),
|
||||
breadcrumbs=[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.routers.bugs import comment, create, status
|
||||
from devplacepy.routers.bugs.index import router
|
||||
from devplacepy.routers.issues import comment, create, status
|
||||
from devplacepy.routers.issues.index import router
|
||||
|
||||
router.include_router(create.router)
|
||||
router.include_router(comment.router)
|
||||
@@ -57,13 +57,13 @@ def comment_item(comment: dict, author_uid: str | None, users_map: dict) -> dict
|
||||
}
|
||||
|
||||
|
||||
def bug_seo(request: Request, **extra) -> dict:
|
||||
def issue_seo(request: Request, **extra) -> dict:
|
||||
base = site_url(request)
|
||||
return base_seo_context(
|
||||
request,
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Bug Reports", "url": "/bugs"},
|
||||
{"name": "Issue Reports", "url": "/issues"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
**extra,
|
||||
@@ -72,11 +72,11 @@ def bug_seo(request: Request, **extra) -> dict:
|
||||
|
||||
def tracker_unavailable(request: Request):
|
||||
if wants_json(request):
|
||||
return json_error(503, "The bug tracker is temporarily unavailable")
|
||||
return json_error(503, "The issue tracker is temporarily unavailable")
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Bug tracker unavailable - DevPlace",
|
||||
description="The bug tracker is temporarily unavailable.",
|
||||
title="Issue tracker unavailable - DevPlace",
|
||||
description="The issue tracker is temporarily unavailable.",
|
||||
robots="noindex",
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
@@ -86,7 +86,7 @@ def tracker_unavailable(request: Request):
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"error_code": 503,
|
||||
"error_message": "The bug tracker is temporarily unavailable.",
|
||||
"error_message": "The issue tracker is temporarily unavailable.",
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
@@ -6,12 +6,13 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Form, Request
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.models import BugCommentForm
|
||||
from devplacepy.models import IssueCommentForm
|
||||
from devplacepy.responses import action_result, json_error
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.gitea import runtime, store
|
||||
from devplacepy.services.gitea.client import GiteaError
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.utils import create_notification, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,25 +20,25 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _notify_admins(actor: dict, number: int) -> None:
|
||||
for admin in get_table("users").find(role="admin"):
|
||||
for admin in get_table("users").find(role="Admin"):
|
||||
if admin["uid"] == actor["uid"]:
|
||||
continue
|
||||
create_notification(
|
||||
admin["uid"],
|
||||
"bug",
|
||||
f"{actor['username']} commented on bug #{number}",
|
||||
"issue",
|
||||
f"{actor['username']} commented on issue #{number}",
|
||||
str(number),
|
||||
f"/bugs/{number}",
|
||||
f"/issues/{number}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{number}/comment")
|
||||
async def comment_bug(
|
||||
request: Request, number: int, data: Annotated[BugCommentForm, Form()]
|
||||
async def comment_issue(
|
||||
request: Request, number: int, data: Annotated[IssueCommentForm, Form()]
|
||||
):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The bug tracker is not configured")
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
client = runtime.get_client()
|
||||
body = (
|
||||
f"{data.body.strip()}\n\n---\n"
|
||||
@@ -49,27 +50,27 @@ async def comment_bug(
|
||||
logger.warning("Could not post comment on #%s: %s", number, exc)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.comment",
|
||||
"issue.comment",
|
||||
user=user,
|
||||
result="failure",
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"Failed to comment on bug #{number}: {exc}",
|
||||
summary=f"Failed to comment on issue #{number}: {exc}",
|
||||
)
|
||||
return json_error(exc.status or 502, "Could not post the comment")
|
||||
|
||||
comment_id = int(comment.get("id", 0))
|
||||
store.record_comment_author(comment_id, number, user["uid"])
|
||||
_notify_admins(user, number)
|
||||
background.submit(_notify_admins, user, number)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.comment",
|
||||
"issue.comment",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} commented on bug #{number}",
|
||||
summary=f"{user['username']} commented on issue #{number}",
|
||||
metadata={"number": number, "comment_id": comment_id},
|
||||
links=[audit.target("bug", str(number))],
|
||||
links=[audit.target("issue", str(number))],
|
||||
)
|
||||
logger.info("Comment posted on bug #%s by %s", number, user["username"])
|
||||
return action_result(request, f"/bugs/{number}", data={"comment_id": comment_id})
|
||||
logger.info("Comment posted on issue #%s by %s", number, user["username"])
|
||||
return action_result(request, f"/issues/{number}", data={"comment_id": comment_id})
|
||||
@@ -6,9 +6,9 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.models import BugForm
|
||||
from devplacepy.models import IssueForm
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.schemas import BugJobOut
|
||||
from devplacepy.schemas import IssueJobOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.jobs import queue
|
||||
@@ -19,13 +19,13 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
|
||||
async def create_issue(request: Request, data: Annotated[IssueForm, Form()]):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The bug tracker is not configured")
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
title = data.title.strip()
|
||||
uid = queue.enqueue(
|
||||
"bug_create",
|
||||
"issue_create",
|
||||
{
|
||||
"author_uid": user["uid"],
|
||||
"title": title,
|
||||
@@ -37,21 +37,21 @@ async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.create.request",
|
||||
"issue.create.request",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
summary=f"{user['username']} requested a bug report: {title[:60]}",
|
||||
target_type="issue",
|
||||
summary=f"{user['username']} requested an issue report: {title[:60]}",
|
||||
metadata={"title": title},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
logger.info("Bug report enqueued by %s: %s", user["username"], title[:60])
|
||||
return JSONResponse({"uid": uid, "status_url": f"/bugs/jobs/{uid}"})
|
||||
logger.info("Issue report enqueued by %s: %s", user["username"], title[:60])
|
||||
return JSONResponse({"uid": uid, "status_url": f"/issues/jobs/{uid}"})
|
||||
|
||||
|
||||
@router.get("/jobs/{uid}")
|
||||
async def bug_job_status(request: Request, uid: str):
|
||||
async def issue_job_status(request: Request, uid: str):
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "bug_create":
|
||||
if not job or job.get("kind") != "issue_create":
|
||||
raise not_found("Job not found")
|
||||
result = job.get("result", {})
|
||||
done = job.get("status") == queue.DONE
|
||||
@@ -60,10 +60,10 @@ async def bug_job_status(request: Request, uid: str):
|
||||
"kind": job.get("kind", ""),
|
||||
"status": job.get("status", ""),
|
||||
"number": result.get("number") if done else None,
|
||||
"bug_url": result.get("bug_url") if done else None,
|
||||
"issue_url": result.get("issue_url") if done else None,
|
||||
"enhanced": bool(result.get("enhanced")) if done else False,
|
||||
"error": job.get("error") or None,
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at") or None,
|
||||
}
|
||||
return JSONResponse(BugJobOut.model_validate(payload).model_dump(mode="json"))
|
||||
return JSONResponse(IssueJobOut.model_validate(payload).model_dump(mode="json"))
|
||||
@@ -7,15 +7,15 @@ from fastapi.responses import HTMLResponse
|
||||
|
||||
from devplacepy.database import build_pagination, get_users_by_uids
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import BugDetailOut, BugsOut
|
||||
from devplacepy.schemas import IssueDetailOut, IssuesOut
|
||||
from devplacepy.services.gitea import runtime, store
|
||||
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.utils import get_current_user, is_admin, not_found
|
||||
|
||||
from devplacepy.routers.bugs._shared import (
|
||||
from devplacepy.routers.issues._shared import (
|
||||
PER_PAGE,
|
||||
bug_seo,
|
||||
issue_seo,
|
||||
comment_item,
|
||||
issue_item,
|
||||
state_or_default,
|
||||
@@ -27,21 +27,21 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def bugs_page(request: Request, state: str = STATE_OPEN, page: int = 1):
|
||||
async def issues_page(request: Request, state: str = STATE_OPEN, page: int = 1):
|
||||
user = get_current_user(request)
|
||||
state = state_or_default(state)
|
||||
page = max(1, page)
|
||||
seo_ctx = bug_seo(
|
||||
seo_ctx = issue_seo(
|
||||
request,
|
||||
title="Bug Reports",
|
||||
description="Report bugs and track their progress on DevPlace.",
|
||||
title="Issue Reports",
|
||||
description="Report issues and track their progress on DevPlace.",
|
||||
)
|
||||
base_ctx = {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"state": state,
|
||||
"bugs": [],
|
||||
"issues": [],
|
||||
"pagination": None,
|
||||
"configured": True,
|
||||
"error_message": None,
|
||||
@@ -50,46 +50,46 @@ async def bugs_page(request: Request, state: str = STATE_OPEN, page: int = 1):
|
||||
config = gitea_config()
|
||||
if not config.is_configured:
|
||||
base_ctx["configured"] = False
|
||||
base_ctx["error_message"] = "The bug tracker is not configured yet."
|
||||
return respond(request, "bugs.html", base_ctx, model=BugsOut)
|
||||
base_ctx["error_message"] = "The issue tracker is not configured yet."
|
||||
return respond(request, "issues.html", base_ctx, model=IssuesOut)
|
||||
|
||||
try:
|
||||
issues, total = await runtime.get_client().list_issues(
|
||||
state=state, page=page, limit=PER_PAGE
|
||||
)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load bug list: %s", exc)
|
||||
base_ctx["error_message"] = "The bug tracker is temporarily unavailable."
|
||||
return respond(request, "bugs.html", base_ctx, model=BugsOut)
|
||||
logger.warning("Could not load issue list: %s", exc)
|
||||
base_ctx["error_message"] = "The issue tracker is temporarily unavailable."
|
||||
return respond(request, "issues.html", base_ctx, model=IssuesOut)
|
||||
|
||||
numbers = [int(issue.get("number", 0)) for issue in issues]
|
||||
author_by_number = store.author_map(numbers)
|
||||
users_map = get_users_by_uids([uid for uid in author_by_number.values() if uid])
|
||||
base_ctx["bugs"] = [
|
||||
base_ctx["issues"] = [
|
||||
issue_item(issue, author_by_number.get(int(issue.get("number", 0))), users_map)
|
||||
for issue in issues
|
||||
]
|
||||
base_ctx["pagination"] = build_pagination(page, total, PER_PAGE)
|
||||
return respond(request, "bugs.html", base_ctx, model=BugsOut)
|
||||
return respond(request, "issues.html", base_ctx, model=IssuesOut)
|
||||
|
||||
|
||||
@router.get("/{number}", response_class=HTMLResponse)
|
||||
async def bug_detail(request: Request, number: int):
|
||||
async def issue_detail(request: Request, number: int):
|
||||
user = get_current_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
raise not_found("Bug not found")
|
||||
raise not_found("Issue not found")
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
issue = await client.get_issue(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status == 404:
|
||||
raise not_found("Bug not found")
|
||||
logger.warning("Could not load bug #%s: %s", number, exc)
|
||||
raise not_found("Issue not found")
|
||||
logger.warning("Could not load issue #%s: %s", number, exc)
|
||||
return tracker_unavailable(request)
|
||||
try:
|
||||
comments = await client.list_comments(number)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load comments for bug #%s: %s", number, exc)
|
||||
logger.warning("Could not load comments for issue #%s: %s", number, exc)
|
||||
comments = []
|
||||
|
||||
author_uid = store.author_uid_for_issue(number)
|
||||
@@ -99,30 +99,30 @@ async def bug_detail(request: Request, number: int):
|
||||
uids = [uid for uid in [author_uid, *comment_authors.values()] if uid]
|
||||
users_map = get_users_by_uids(uids)
|
||||
|
||||
bug = issue_item(issue, author_uid, users_map)
|
||||
issue = issue_item(issue, author_uid, users_map)
|
||||
comment_items = [
|
||||
comment_item(
|
||||
comment, comment_authors.get(int(comment.get("id", 0))), users_map
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
seo_ctx = bug_seo(
|
||||
seo_ctx = issue_seo(
|
||||
request,
|
||||
title=f"Bug #{number}: {issue.get('title', '')}",
|
||||
title=f"Issue #{number}: {issue.get('title', '')}",
|
||||
description=issue.get("title", ""),
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"bug_detail.html",
|
||||
"issue_detail.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"bug": bug,
|
||||
"issue": issue,
|
||||
"body": issue.get("body", ""),
|
||||
"comments": comment_items,
|
||||
"can_comment": user is not None,
|
||||
"viewer_is_admin": is_admin(user),
|
||||
},
|
||||
model=BugDetailOut,
|
||||
model=IssueDetailOut,
|
||||
)
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
|
||||
from devplacepy.models import BugStatusForm
|
||||
from devplacepy.models import IssueStatusForm
|
||||
from devplacepy.responses import action_result, json_error
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.gitea import runtime
|
||||
@@ -18,48 +18,48 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{number}/status")
|
||||
async def status_bug(
|
||||
request: Request, number: int, data: Annotated[BugStatusForm, Form()]
|
||||
async def status_issue(
|
||||
request: Request, number: int, data: Annotated[IssueStatusForm, Form()]
|
||||
):
|
||||
user = require_user(request)
|
||||
if not is_admin(user):
|
||||
audit.record(
|
||||
request,
|
||||
"bug.status",
|
||||
"issue.status",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} denied changing status of bug #{number}",
|
||||
summary=f"{user['username']} denied changing status of issue #{number}",
|
||||
)
|
||||
return json_error(403, "Admins only")
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The bug tracker is not configured")
|
||||
return json_error(503, "The issue tracker is not configured")
|
||||
try:
|
||||
await runtime.get_client().set_state(number, data.status)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not change status of #%s: %s", number, exc)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.status",
|
||||
"issue.status",
|
||||
user=user,
|
||||
result="failure",
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
summary=f"Failed to change status of bug #{number}: {exc}",
|
||||
summary=f"Failed to change status of issue #{number}: {exc}",
|
||||
)
|
||||
return json_error(exc.status or 502, "Could not change the status")
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"bug.status",
|
||||
"issue.status",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
new_value=data.status,
|
||||
summary=f"{user['username']} set bug #{number} to {data.status}",
|
||||
summary=f"{user['username']} set issue #{number} to {data.status}",
|
||||
metadata={"number": number, "state": data.status},
|
||||
links=[audit.target("bug", str(number))],
|
||||
links=[audit.target("issue", str(number))],
|
||||
)
|
||||
logger.info("Bug #%s set to %s by %s", number, data.status, user["username"])
|
||||
return action_result(request, f"/bugs/{number}", data={"state": data.status})
|
||||
logger.info("Issue #%s set to %s by %s", number, data.status, user["username"])
|
||||
return action_result(request, f"/issues/{number}", data={"state": data.status})
|
||||
+6
-115
@@ -2,31 +2,15 @@
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
update_target_stars,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
_now_iso,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
generate_uid,
|
||||
require_user,
|
||||
create_notification,
|
||||
award_rewards,
|
||||
XP_UPVOTE,
|
||||
)
|
||||
from devplacepy.utils import require_user
|
||||
from devplacepy.content import apply_vote
|
||||
from devplacepy.models import VoteForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
|
||||
|
||||
|
||||
@router.post("/{target_type}/{target_uid}")
|
||||
async def vote(
|
||||
@@ -36,110 +20,17 @@ async def vote(
|
||||
data: Annotated[VoteForm, Form()],
|
||||
):
|
||||
user = require_user(request)
|
||||
value = data.value
|
||||
|
||||
votes = get_table("votes")
|
||||
existing = votes.find_one(
|
||||
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
|
||||
)
|
||||
|
||||
old_value = int(existing["value"]) if existing else 0
|
||||
did_upvote = False
|
||||
new_value = value
|
||||
if existing:
|
||||
if existing.get("deleted_at"):
|
||||
votes.update(
|
||||
{
|
||||
"id": existing["id"],
|
||||
"value": value,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
},
|
||||
["id"],
|
||||
)
|
||||
did_upvote = value == 1
|
||||
elif int(existing["value"]) == value:
|
||||
votes.update(
|
||||
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
|
||||
["id"],
|
||||
)
|
||||
new_value = 0
|
||||
else:
|
||||
votes.update({"id": existing["id"], "value": value}, ["id"])
|
||||
did_upvote = value == 1
|
||||
else:
|
||||
votes.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"target_uid": target_uid,
|
||||
"target_type": target_type,
|
||||
"value": value,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
did_upvote = value == 1
|
||||
|
||||
up_count = votes.count(
|
||||
target_uid=target_uid, target_type=target_type, value=1, deleted_at=None
|
||||
)
|
||||
down_count = votes.count(
|
||||
target_uid=target_uid, target_type=target_type, value=-1, deleted_at=None
|
||||
)
|
||||
net = up_count - down_count
|
||||
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
|
||||
vote_links = [audit.target(target_type, target_uid)]
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
vote_links.append(audit.author(owner_uid))
|
||||
audit.record(
|
||||
request,
|
||||
f"vote.{target_type}.{direction}",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
metadata={"value_old": old_value, "value_new": new_value, "net": net},
|
||||
summary=f"{user['username']} {direction} vote on {target_type} {target_uid}",
|
||||
links=vote_links,
|
||||
)
|
||||
|
||||
if did_upvote and target_type in NOTIFY_ON_VOTE:
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
target_url = resolve_object_url(target_type, target_uid)
|
||||
create_notification(
|
||||
owner_uid,
|
||||
"vote",
|
||||
f"{user['username']} ++'d your {target_type}",
|
||||
user["uid"],
|
||||
target_url,
|
||||
)
|
||||
award_rewards(owner_uid, XP_UPVOTE)
|
||||
result = apply_vote(request, user, target_type, target_uid, data.value)
|
||||
|
||||
if request.headers.get("x-requested-with") == "fetch":
|
||||
current = votes.find_one(
|
||||
user_uid=user["uid"],
|
||||
target_uid=target_uid,
|
||||
target_type=target_type,
|
||||
deleted_at=None,
|
||||
)
|
||||
current_value = int(current["value"]) if current else 0
|
||||
logger.debug(
|
||||
"ajax vote response target=%s/%s net=%s value=%s",
|
||||
target_type,
|
||||
target_uid,
|
||||
net,
|
||||
current_value,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"net": net, "up": up_count, "down": down_count, "value": current_value}
|
||||
result["net"],
|
||||
result["value"],
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
referer = request.headers.get("Referer", "/feed")
|
||||
return RedirectResponse(url=referer, status_code=302)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
|
||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
CONNECT_HOST = XMLRPC_BIND if XMLRPC_BIND not in ("0.0.0.0", "::") else "127.0.0.1"
|
||||
TARGET_URL = f"http://{CONNECT_HOST}:{XMLRPC_PORT}/"
|
||||
TIMEOUT_SECONDS = 65.0
|
||||
FORWARD_HEADERS = (
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-real-ip",
|
||||
"x-forwarded-for",
|
||||
"content-type",
|
||||
)
|
||||
|
||||
USAGE = (
|
||||
"DevPlace XML-RPC bridge\n\n"
|
||||
"POST XML-RPC method calls to this endpoint. Every documented REST endpoint is\n"
|
||||
"exposed as a method named after its id with dots (for example posts.create,\n"
|
||||
"feed.index, profile.update). Each method takes ONE struct of named parameters;\n"
|
||||
"authenticate with 'api_key' inside the struct or an X-API-KEY / Bearer header.\n\n"
|
||||
"Introspection: system.listMethods, system.methodHelp, system.methodSignature.\n"
|
||||
"Batching: system.multicall.\n\n"
|
||||
"Python example:\n"
|
||||
" import xmlrpc.client\n"
|
||||
" proxy = xmlrpc.client.ServerProxy('https://your-host/xmlrpc', allow_none=True)\n"
|
||||
" print(proxy.system.listMethods())\n"
|
||||
" proxy.posts.create({'title': 'Hi', 'body': '...', 'api_key': 'YOUR_KEY'})\n"
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("/")
|
||||
@router.get("/{path:path}")
|
||||
async def info(request: Request, path: str = "") -> PlainTextResponse:
|
||||
return PlainTextResponse(USAGE)
|
||||
|
||||
|
||||
@router.post("")
|
||||
@router.post("/")
|
||||
@router.post("/{path:path}")
|
||||
async def proxy(request: Request, path: str = "") -> Response:
|
||||
body = await request.body()
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() in FORWARD_HEADERS
|
||||
}
|
||||
headers.setdefault("content-type", "text/xml")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS) as client:
|
||||
upstream = await client.post(TARGET_URL, content=body, headers=headers)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("XML-RPC bridge unavailable: %s", exc)
|
||||
return PlainTextResponse(
|
||||
"XML-RPC bridge is not available.", status_code=502
|
||||
)
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
status_code=upstream.status_code,
|
||||
media_type=upstream.headers.get("content-type", "text/xml"),
|
||||
)
|
||||
@@ -422,7 +422,7 @@ class NotificationGroupOut(_Out):
|
||||
entries: list[NotificationItemOut] = []
|
||||
|
||||
|
||||
class BugItemOut(_Out):
|
||||
class IssueItemOut(_Out):
|
||||
number: int = 0
|
||||
title: str = ""
|
||||
state: str = "open"
|
||||
@@ -436,7 +436,7 @@ class BugItemOut(_Out):
|
||||
is_local_author: bool = False
|
||||
|
||||
|
||||
class BugCommentOut(_Out):
|
||||
class IssueCommentOut(_Out):
|
||||
id: int = 0
|
||||
body: str = ""
|
||||
html_url: Optional[str] = None
|
||||
@@ -447,20 +447,20 @@ class BugCommentOut(_Out):
|
||||
is_local_author: bool = False
|
||||
|
||||
|
||||
class BugDetailOut(_Out):
|
||||
bug: BugItemOut
|
||||
class IssueDetailOut(_Out):
|
||||
issue: IssueItemOut
|
||||
body: str = ""
|
||||
comments: list[BugCommentOut] = []
|
||||
comments: list[IssueCommentOut] = []
|
||||
can_comment: bool = False
|
||||
viewer_is_admin: bool = False
|
||||
|
||||
|
||||
class BugJobOut(_Out):
|
||||
class IssueJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
number: Optional[int] = None
|
||||
bug_url: Optional[str] = None
|
||||
issue_url: Optional[str] = None
|
||||
enhanced: bool = False
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
@@ -671,8 +671,8 @@ class LeaderboardOut(_Out):
|
||||
featured_news: list[NewsOut] = []
|
||||
|
||||
|
||||
class BugsOut(_Out):
|
||||
bugs: list[BugItemOut] = []
|
||||
class IssuesOut(_Out):
|
||||
issues: list[IssueItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
state: str = "open"
|
||||
configured: bool = True
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ def _build_sitemap(base_url):
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
||||
)
|
||||
urlset.append(url_element(f"{base_url}/bugs", changefreq="daily", priority="0.6"))
|
||||
urlset.append(url_element(f"{base_url}/issues", changefreq="daily", priority="0.6"))
|
||||
urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5"))
|
||||
urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5"))
|
||||
|
||||
@@ -10,7 +10,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"post": "content",
|
||||
"comment": "content",
|
||||
"gist": "content",
|
||||
"bug": "content",
|
||||
"issue": "content",
|
||||
"vote": "engagement",
|
||||
"reaction": "engagement",
|
||||
"bookmark": "engagement",
|
||||
|
||||
@@ -4,9 +4,10 @@ import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.utils import strip_html
|
||||
from devplacepy.utils import strip_html, generate_uid
|
||||
from devplacepy.services.audit import store
|
||||
from devplacepy.services.audit.categories import category_for
|
||||
from devplacepy.services.background import background
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -160,6 +161,11 @@ def _coerce_scalar(value: Any) -> Optional[str]:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _persist(row: dict, links: list[dict]) -> None:
|
||||
audit_uid = store.insert_event(row)
|
||||
store.insert_links(audit_uid, links)
|
||||
|
||||
|
||||
def _write(
|
||||
*,
|
||||
event_key: str,
|
||||
@@ -184,7 +190,10 @@ def _write(
|
||||
resolved_via = via_agent
|
||||
if resolved_via is None:
|
||||
resolved_via = 1 if request_fields.get("devii") else 0
|
||||
audit_uid = generate_uid()
|
||||
row = {
|
||||
"uid": audit_uid,
|
||||
"created_at": store.now(),
|
||||
"event_key": event_key,
|
||||
"category": category or category_for(event_key),
|
||||
"actor_kind": base_actor.get("actor_kind"),
|
||||
@@ -213,8 +222,7 @@ def _write(
|
||||
all_links.append(
|
||||
actor(base_actor["actor_uid"], base_actor.get("actor_username"))
|
||||
)
|
||||
audit_uid = store.insert_event(row)
|
||||
store.insert_links(audit_uid, all_links)
|
||||
background.submit(_persist, row, all_links)
|
||||
logger.debug(
|
||||
"audit %s by %s/%s result=%s origin=%s",
|
||||
event_key,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUEUE_MAXSIZE = 10000
|
||||
DRAIN_BATCH = 64
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
def __init__(self, maxsize: int = QUEUE_MAXSIZE) -> None:
|
||||
self._queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._submitted = 0
|
||||
self._processed = 0
|
||||
self._failed = 0
|
||||
self._inline = 0
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
||||
self._submitted += 1
|
||||
if not self.running:
|
||||
self._execute(fn, args, kwargs, inline=True)
|
||||
return
|
||||
try:
|
||||
self._queue.put_nowait((fn, args, kwargs))
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("background queue full; running task inline")
|
||||
self._execute(fn, args, kwargs, inline=True)
|
||||
|
||||
def _execute(self, fn: Callable[..., Any], args: tuple, kwargs: dict, inline: bool = False) -> None:
|
||||
if inline:
|
||||
self._inline += 1
|
||||
try:
|
||||
fn(*args, **kwargs)
|
||||
self._processed += 1
|
||||
except Exception as exc:
|
||||
self._failed += 1
|
||||
logger.warning("background task %s failed: %s", getattr(fn, "__name__", fn), exc)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.running:
|
||||
return
|
||||
self._task = asyncio.create_task(self._drain())
|
||||
logger.info("background task queue started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
task = self._task
|
||||
self._task = None
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._flush_remaining()
|
||||
logger.info(
|
||||
"background task queue stopped processed=%s failed=%s inline=%s",
|
||||
self._processed,
|
||||
self._failed,
|
||||
self._inline,
|
||||
)
|
||||
|
||||
def _flush_remaining(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
fn, args, kwargs = self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
self._execute(fn, args, kwargs)
|
||||
self._queue.task_done()
|
||||
|
||||
async def _drain(self) -> None:
|
||||
while True:
|
||||
fn, args, kwargs = await self._queue.get()
|
||||
self._execute(fn, args, kwargs)
|
||||
self._queue.task_done()
|
||||
for _ in range(DRAIN_BATCH - 1):
|
||||
try:
|
||||
fn, args, kwargs = self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
self._execute(fn, args, kwargs)
|
||||
self._queue.task_done()
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"running": self.running,
|
||||
"depth": self._queue.qsize(),
|
||||
"submitted": self._submitted,
|
||||
"processed": self._processed,
|
||||
"failed": self._failed,
|
||||
"inline": self._inline,
|
||||
}
|
||||
|
||||
|
||||
background = BackgroundQueue()
|
||||
@@ -17,7 +17,6 @@ from devplacepy.services.bot.config import (
|
||||
COMMENT_COOLDOWN_MIN_SECONDS,
|
||||
FEED_TOPICS,
|
||||
GIST_LANGUAGES,
|
||||
HANDLE_SUFFIX_WORDS,
|
||||
MAX_SIBLING_COMMENTS,
|
||||
MAX_THREAD_REPLIES,
|
||||
MENTION_REPLIES_PER_SESSION,
|
||||
@@ -36,6 +35,7 @@ from devplacepy.services.bot.config import (
|
||||
persona_article_score,
|
||||
pick_category,
|
||||
)
|
||||
from devplacepy.services.bot.handles import make_handle
|
||||
from devplacepy.services.bot.llm import LLMClient
|
||||
from devplacepy.services.bot.news_fetcher import NewsFetcher
|
||||
from devplacepy.services.bot.registry import ArticleRegistry
|
||||
@@ -91,7 +91,7 @@ class DevPlaceBot:
|
||||
self._session_mention_replies = 0
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
self._project_cache: list[tuple[str, str]] = []
|
||||
self._bug_cache: list[tuple[str, str]] = []
|
||||
self._issue_cache: list[tuple[str, str]] = []
|
||||
self._session_start_cost: float = 0.0
|
||||
self._session_start_calls: int = 0
|
||||
self._session_history: list[str] = []
|
||||
@@ -214,10 +214,10 @@ class DevPlaceBot:
|
||||
self.llm.generate_project_desc, t, self.state.persona
|
||||
)
|
||||
self._project_cache.append((t, desc))
|
||||
elif kind == "bug":
|
||||
elif kind == "issue":
|
||||
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
|
||||
bug = await asyncio.to_thread(self.llm.generate_bug, topic)
|
||||
self._bug_cache.append(bug)
|
||||
issue = await asyncio.to_thread(self.llm.generate_issue, topic)
|
||||
self._issue_cache.append(issue)
|
||||
except Exception as e:
|
||||
logger.warning("Cache refill (%s) failed: %s", kind, e)
|
||||
|
||||
@@ -225,36 +225,33 @@ class DevPlaceBot:
|
||||
self._log("Warming content cache (background)...")
|
||||
self._post_cache = []
|
||||
self._project_cache = []
|
||||
self._bug_cache = []
|
||||
self._issue_cache = []
|
||||
self.news.fetch()
|
||||
asyncio.create_task(self._refill_cache_async("post"))
|
||||
asyncio.create_task(self._refill_cache_async("project"))
|
||||
asyncio.create_task(self._refill_cache_async("bug"))
|
||||
asyncio.create_task(self._refill_cache_async("issue"))
|
||||
|
||||
def _generate_handle(self) -> str:
|
||||
first = re.sub(r"[^a-z]", "", self.faker.first_name().lower()) or "dev"
|
||||
last = re.sub(r"[^a-z]", "", self.faker.last_name().lower()) or "coder"
|
||||
styles = [
|
||||
first,
|
||||
f"{first}.{last}",
|
||||
f"{first}{last}",
|
||||
f"{first}_{last}",
|
||||
f"{first[0]}{last}",
|
||||
f"{first}{last[0]}",
|
||||
f"{last}{first[0]}",
|
||||
]
|
||||
handle = random.choice(styles)
|
||||
if random.random() < 0.18:
|
||||
word = random.choice(HANDLE_SUFFIX_WORDS)
|
||||
sep = random.choice(["", "_", ".", "-"])
|
||||
handle = f"{handle}{sep}{word}"
|
||||
handle = re.sub(r"[^a-z0-9._-]", "", handle)[:20]
|
||||
return handle or "dev"
|
||||
return make_handle(SEARCH_TERMS.get(self.state.persona, []))
|
||||
|
||||
def _next_handle(self) -> str:
|
||||
while self.state.handle_candidates:
|
||||
candidate = self.state.handle_candidates.pop(0)
|
||||
if candidate:
|
||||
return candidate
|
||||
return self._generate_handle()
|
||||
|
||||
async def _ensure_auth(self) -> bool:
|
||||
b = self.b
|
||||
if not self.state.username:
|
||||
self.state.username = self._generate_handle()
|
||||
if not self.state.handle_candidates:
|
||||
self.state.handle_candidates = (
|
||||
await self._generate(
|
||||
self.llm.generate_handle_candidates, self.state.persona
|
||||
)
|
||||
or []
|
||||
)
|
||||
self.state.username = self._next_handle()
|
||||
self.state.email = self.faker.email()
|
||||
self.state.password = self.faker.password(length=random.randint(12, 18))
|
||||
self.state.started_at = datetime.now().isoformat()
|
||||
@@ -299,14 +296,10 @@ class DevPlaceBot:
|
||||
self._log(f"Registered as {self.state.username}")
|
||||
return True
|
||||
if attempt >= 2:
|
||||
base = self._generate_handle()
|
||||
word = random.choice(HANDLE_SUFFIX_WORDS)
|
||||
sep = random.choice(["", "_", ".", "-"])
|
||||
self.state.username = re.sub(
|
||||
r"[^a-z0-9._-]", "", f"{base}{sep}{word}"
|
||||
)[:20]
|
||||
base = self._next_handle()
|
||||
self.state.username = f"{base}{random.randint(10, 9999)}"[:20]
|
||||
else:
|
||||
self.state.username = self._generate_handle()
|
||||
self.state.username = self._next_handle()
|
||||
self.state.email = self.faker.email()
|
||||
self._log(f"Signup failed, retrying as {self.state.username}")
|
||||
return False
|
||||
@@ -1794,15 +1787,15 @@ class DevPlaceBot:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _report_bug(self) -> bool:
|
||||
async def _report_issue(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/bugs")
|
||||
ok = await b.click("[data-modal='create-bug-modal']")
|
||||
await b.goto(f"{self.base_url}/issues")
|
||||
ok = await b.click("[data-modal='create-issue-modal']")
|
||||
if not ok:
|
||||
return False
|
||||
await b._idle(0.5, 1.5)
|
||||
|
||||
if not await b.text("#create-bug-modal"):
|
||||
if not await b.text("#create-issue-modal"):
|
||||
return False
|
||||
|
||||
topic = random.choice(
|
||||
@@ -1814,27 +1807,27 @@ class DevPlaceBot:
|
||||
"Formatting error",
|
||||
]
|
||||
)
|
||||
if self._bug_cache:
|
||||
title, desc = self._bug_cache.pop(0)
|
||||
asyncio.create_task(self._refill_cache_async("bug"))
|
||||
if self._issue_cache:
|
||||
title, desc = self._issue_cache.pop(0)
|
||||
asyncio.create_task(self._refill_cache_async("issue"))
|
||||
else:
|
||||
generated = await self._generate(self.llm.generate_bug, topic)
|
||||
generated = await self._generate(self.llm.generate_issue, topic)
|
||||
if not generated:
|
||||
self._log("Report bug: generation failed")
|
||||
self._log("Report issue: generation failed")
|
||||
return False
|
||||
title, desc = generated
|
||||
|
||||
await b.fill("#bug-title", title[:200])
|
||||
await b.fill("#issue-title", title[:200])
|
||||
await b._idle(0.2, 0.5)
|
||||
await b.fill("textarea[name='description']", desc[:2000])
|
||||
await b._idle(0.5, 1.0)
|
||||
|
||||
ok = await b.click(
|
||||
"button:has-text('Submit Report'), #create-bug-modal .btn-primary"
|
||||
"button:has-text('Submit Report'), #create-issue-modal .btn-primary"
|
||||
)
|
||||
if ok:
|
||||
self.state.bugs_filed += 1
|
||||
self._action("BUG", f"{title[:50]} [{self.state.bugs_filed}]")
|
||||
self.state.issues_filed += 1
|
||||
self._action("ISSUE", f"{title[:50]} [{self.state.issues_filed}]")
|
||||
await asyncio.sleep(2)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.services.bot.config import HANDLE_SUFFIX_WORDS
|
||||
|
||||
MIN_HANDLE_LEN = 3
|
||||
MAX_HANDLE_LEN = 20
|
||||
MIN_SEED_TOKEN_LEN = 3
|
||||
LEET_PROBABILITY = 0.35
|
||||
LEET_CHAR_PROBABILITY = 0.5
|
||||
NUMBER_SUFFIX_PROBABILITY = 0.30
|
||||
SEPARATOR_PROBABILITY = 0.30
|
||||
CAPITALIZE_PROBABILITY = 0.22
|
||||
ALLCAPS_PROBABILITY = 0.08
|
||||
ALLCAPS_MAX_LEN = 8
|
||||
PAD_NUMBER_FLOOR = 10
|
||||
PAD_NUMBER_CEIL = 9999
|
||||
|
||||
LEET_MAP = {
|
||||
"a": "4",
|
||||
"e": "3",
|
||||
"i": "1",
|
||||
"o": "0",
|
||||
"s": "5",
|
||||
"t": "7",
|
||||
"l": "1",
|
||||
"g": "9",
|
||||
"b": "8",
|
||||
}
|
||||
|
||||
TECH_NOUNS = [
|
||||
"byte", "bit", "nibble", "null", "void", "root", "kernel", "daemon",
|
||||
"socket", "buffer", "cache", "stack", "heap", "pointer", "segfault",
|
||||
"regex", "lambda", "vector", "packet", "proxy", "token", "hash", "cipher",
|
||||
"vortex", "flux", "glitch", "pixel", "neon", "cyber", "quantum", "thread",
|
||||
"mutex", "syntax", "binary", "octet", "raster", "shader", "codec",
|
||||
"vertex", "matrix", "scalar", "payload", "opcode", "runtime", "compiler",
|
||||
"parser", "lexer", "sandbox", "firmware", "bytecode", "checksum",
|
||||
"entropy", "signal", "overflow", "nibbler", "cursor", "patch",
|
||||
]
|
||||
|
||||
HANDLE_ADJECTIVES = [
|
||||
"dark", "mad", "lazy", "quantum", "cyber", "neon", "ghost", "hyper",
|
||||
"ultra", "neo", "retro", "raw", "zero", "async", "blind", "silent",
|
||||
"atomic", "fatal", "rogue", "idle", "stale", "dirty", "lucid", "prime",
|
||||
"hex", "cold", "lone", "wired", "broken", "feral",
|
||||
]
|
||||
|
||||
HANDLE_CREATURES = [
|
||||
"fox", "wolf", "raven", "owl", "octopus", "gecko", "mantis", "viper",
|
||||
"lynx", "moth", "hydra", "kraken", "phoenix", "dragon", "falcon",
|
||||
"panther", "cobra", "badger", "otter", "sloth", "hornet", "beetle",
|
||||
"lemur", "narwhal", "basilisk",
|
||||
]
|
||||
|
||||
HANDLE_PREFIXES = ["the", "mr", "dr", "lord", "captain", "uncle", "sir", "prof", "agent"]
|
||||
|
||||
NUMBER_SUFFIXES = ["42", "1337", "404", "64", "99", "007", "23", "88", "256", "512", "101", "777"]
|
||||
|
||||
SEPARATORS = ["_", "-"]
|
||||
|
||||
|
||||
def _join(rng: random.Random, parts: list[str]) -> str:
|
||||
separator = rng.choice(SEPARATORS) if rng.random() < SEPARATOR_PROBABILITY else ""
|
||||
return separator.join(parts)
|
||||
|
||||
|
||||
def _seed_tokens(interests: list[str]) -> list[str]:
|
||||
tokens: list[str] = []
|
||||
for term in interests:
|
||||
for piece in re.split(r"[^a-z0-9]+", term.lower()):
|
||||
if len(piece) >= MIN_SEED_TOKEN_LEN:
|
||||
tokens.append(piece)
|
||||
return tokens
|
||||
|
||||
|
||||
def _build_core(rng: random.Random, interests: list[str]) -> str:
|
||||
seeds = _seed_tokens(interests)
|
||||
builders = [
|
||||
lambda: rng.choice(TECH_NOUNS),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(TECH_NOUNS)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(HANDLE_PREFIXES), rng.choice(TECH_NOUNS + HANDLE_CREATURES)]),
|
||||
lambda: _join(rng, [rng.choice(TECH_NOUNS), rng.choice(HANDLE_SUFFIX_WORDS)]),
|
||||
]
|
||||
weights = [3, 4, 3, 2, 2, 2]
|
||||
if seeds:
|
||||
builders.append(
|
||||
lambda: _join(rng, [rng.choice(seeds), rng.choice(HANDLE_SUFFIX_WORDS + TECH_NOUNS)])
|
||||
)
|
||||
builders.append(lambda: _join(rng, [rng.choice(HANDLE_ADJECTIVES), rng.choice(seeds)]))
|
||||
weights += [5, 3]
|
||||
return rng.choices(builders, weights=weights)[0]()
|
||||
|
||||
|
||||
def apply_leet(text: str, rng: random.Random) -> str:
|
||||
chars: list[str] = []
|
||||
for char in text:
|
||||
replacement = LEET_MAP.get(char.lower())
|
||||
if replacement and rng.random() < LEET_CHAR_PROBABILITY:
|
||||
chars.append(replacement)
|
||||
else:
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _apply_case(text: str, rng: random.Random) -> str:
|
||||
roll = rng.random()
|
||||
if roll < ALLCAPS_PROBABILITY and len(text) <= ALLCAPS_MAX_LEN:
|
||||
return text.upper()
|
||||
if roll < ALLCAPS_PROBABILITY + CAPITALIZE_PROBABILITY:
|
||||
return text[:1].upper() + text[1:]
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_handle(raw: str) -> str:
|
||||
handle = re.sub(r"[^a-zA-Z0-9_-]", "", str(raw).strip())
|
||||
handle = re.sub(r"[_-]{2,}", lambda match: match.group(0)[0], handle).strip("_-")
|
||||
return handle[:MAX_HANDLE_LEN].rstrip("_-")
|
||||
|
||||
|
||||
def make_handle(interests: Optional[list[str]] = None, rng: Optional[random.Random] = None) -> str:
|
||||
generator = rng or random
|
||||
core = _build_core(generator, interests or [])
|
||||
if generator.random() < LEET_PROBABILITY:
|
||||
core = apply_leet(core, generator)
|
||||
if generator.random() < NUMBER_SUFFIX_PROBABILITY:
|
||||
core = f"{core}{generator.choice(NUMBER_SUFFIXES)}"
|
||||
handle = sanitize_handle(core)
|
||||
if len(handle) < MIN_HANDLE_LEN:
|
||||
handle = f"{handle}{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
handle = _apply_case(handle, generator)
|
||||
return handle or f"dev{generator.randint(PAD_NUMBER_FLOOR, PAD_NUMBER_CEIL)}"
|
||||
@@ -5,10 +5,11 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
@@ -17,6 +18,9 @@ from devplacepy.services.bot.config import (
|
||||
SEARCH_TERMS,
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
|
||||
|
||||
HANDLE_CANDIDATE_TARGET = 8
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,19 +65,15 @@ class LLMClient:
|
||||
json.dumps(system[:500]),
|
||||
json.dumps(prompt[:500]),
|
||||
)
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
self.api_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
with stealth.stealth_sync_client(timeout=30) as client:
|
||||
resp = client.post(
|
||||
self.api_url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
)
|
||||
raw = resp.content
|
||||
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
|
||||
result = json.loads(raw)
|
||||
result = resp.json()
|
||||
usage = result.get("usage", {})
|
||||
in_tokens = usage.get("prompt_tokens", 0)
|
||||
out_tokens = usage.get("completion_tokens", 0)
|
||||
@@ -86,8 +86,7 @@ class LLMClient:
|
||||
self.total_out_tokens += out_tokens
|
||||
return result["choices"][0]["message"]["content"]
|
||||
except (
|
||||
urllib.error.HTTPError,
|
||||
urllib.error.URLError,
|
||||
httpx.HTTPError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
) as e:
|
||||
@@ -126,7 +125,7 @@ class LLMClient:
|
||||
"snippet",
|
||||
"headline",
|
||||
"gist",
|
||||
"bug",
|
||||
"issue",
|
||||
)
|
||||
result = (text or "").strip().strip('"').strip("'").strip()
|
||||
changed = True
|
||||
@@ -476,6 +475,38 @@ class LLMClient:
|
||||
data = self._parse_json(self._raw_call(system, prompt, temperature=0.9))
|
||||
return self._normalize_identity(data, archetype)
|
||||
|
||||
def generate_handle_candidates(self, archetype: str) -> list[str]:
|
||||
interests = ", ".join(SEARCH_TERMS.get(archetype, []))
|
||||
system = (
|
||||
"You invent online handles for a developer signing up to a programmer "
|
||||
"community in the style of devRant or Hacker News. The handles read like a "
|
||||
"real nerd picked them, never like a person's full name. Lean on programming "
|
||||
"and hacker culture: tech nouns (null, kernel, segfault, daemon), occasional "
|
||||
"leetspeak (c0d3r, h4x), an adjective plus noun, a creature, or a short word "
|
||||
f"with a number. Lowercase mostly, {MAX_HANDLE_LEN} characters or fewer, only "
|
||||
"letters, digits, underscores and hyphens, no spaces and no dots. Return ONLY "
|
||||
f'a JSON object {{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} '
|
||||
"distinct handles, no prose, no markdown fences. No em dashes."
|
||||
)
|
||||
prompt = (
|
||||
f"Archetype: {archetype}\n"
|
||||
f"Their interests: {interests}\n"
|
||||
"Create the handles JSON:"
|
||||
)
|
||||
try:
|
||||
data = self._parse_json(
|
||||
self._raw_call(system, prompt, temperature=1.0)
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Handle candidate generation failed: %s", exc)
|
||||
return []
|
||||
handles: list[str] = []
|
||||
for raw in data.get("handles", []):
|
||||
handle = sanitize_handle(raw)
|
||||
if handle and handle.lower() not in {h.lower() for h in handles}:
|
||||
handles.append(handle)
|
||||
return handles
|
||||
|
||||
@staticmethod
|
||||
def _identity_card(identity: dict) -> str:
|
||||
lines = [
|
||||
@@ -586,13 +617,13 @@ class LLMClient:
|
||||
break
|
||||
return result
|
||||
|
||||
def generate_bug(self, topic: str) -> tuple[str, str]:
|
||||
def generate_issue(self, topic: str) -> tuple[str, str]:
|
||||
text = self._call(
|
||||
"Write a bug report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting a bug. No em dashes.",
|
||||
f"Bug: {topic}",
|
||||
"Write an issue report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting an issue. No em dashes.",
|
||||
f"Issue: {topic}",
|
||||
)
|
||||
lines = text.strip().split("\n")
|
||||
title = lines[0].strip().lstrip("#").strip()[:120] or f"Bug report: {topic}"
|
||||
title = lines[0].strip().lstrip("#").strip()[:120] or f"Issue report: {topic}"
|
||||
desc = " ".join(
|
||||
line.lstrip("-* ").strip() for line in lines[1:] if line.strip()
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from devplacepy import stealth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,11 +23,9 @@ class NewsFetcher:
|
||||
if fresh:
|
||||
return self.articles
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
self.news_api, headers={"User-Agent": "dpbot/1.0"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
articles = json.loads(resp.read()).get("articles", [])
|
||||
with stealth.stealth_sync_client(timeout=10) as client:
|
||||
resp = client.get(self.news_api)
|
||||
articles = resp.json().get("articles", [])
|
||||
if articles:
|
||||
self.articles = articles
|
||||
self.fetched_at = time.monotonic()
|
||||
|
||||
@@ -15,6 +15,7 @@ class BotState:
|
||||
password: str = ""
|
||||
uid: str = ""
|
||||
persona: str = ""
|
||||
handle_candidates: list[str] = field(default_factory=list)
|
||||
profile_filled: bool = False
|
||||
user_agent: str = ""
|
||||
viewport: dict = field(default_factory=dict)
|
||||
@@ -27,7 +28,7 @@ class BotState:
|
||||
comments_posted: int = 0
|
||||
quality_rejections: int = 0
|
||||
votes_cast: int = 0
|
||||
bugs_filed: int = 0
|
||||
issues_filed: int = 0
|
||||
projects_created: int = 0
|
||||
gists_created: int = 0
|
||||
profiles_viewed: int = 0
|
||||
|
||||
@@ -6,9 +6,7 @@ import re
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy import config, project_files, stealth
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.backend.base import (
|
||||
WORKSPACE_MOUNT,
|
||||
@@ -369,7 +367,7 @@ def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||
|
||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
||||
response = client.get(f"http://{host}:{port}/")
|
||||
return f"HTTP {response.status_code}"
|
||||
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
||||
|
||||
@@ -8,8 +8,7 @@ import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_EMBED_MODEL, INTERNAL_EMBED_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,7 +55,7 @@ async def embed_texts(
|
||||
}
|
||||
payload = {"model": INTERNAL_EMBED_MODEL, "input": texts}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=EMBED_TIMEOUT_SECONDS) as client:
|
||||
async with stealth.stealth_async_client(timeout=EMBED_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(gateway_url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"embed gateway returned {response.status_code}")
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,7 +32,7 @@ async def complete_chat(
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=CHAT_TIMEOUT_SECONDS) as client:
|
||||
async with stealth.stealth_async_client(timeout=CHAT_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(gateway_url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"chat gateway returned {response.status_code}")
|
||||
|
||||
@@ -885,10 +885,10 @@ ACTIONS: tuple[Action, ...] = (
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="list_bugs",
|
||||
name="list_issues",
|
||||
method="GET",
|
||||
path="/bugs",
|
||||
summary="List bug tickets from the Gitea tracker",
|
||||
path="/issues",
|
||||
summary="List issue tickets from the Gitea tracker",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
query("state", "Filter by state: open, closed, or all."),
|
||||
@@ -896,58 +896,58 @@ ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="create_bug",
|
||||
name="create_issue",
|
||||
method="POST",
|
||||
path="/bugs/create",
|
||||
summary="Report a bug. It is rewritten by AI and filed on the tracker.",
|
||||
path="/issues/create",
|
||||
summary="Report an issue. It is rewritten by AI and filed on the tracker.",
|
||||
description=(
|
||||
"Queues a background bug creation job and returns {uid, status_url}. Poll the "
|
||||
"status_url with bug_job_status until status is 'done', then show the user the "
|
||||
"bug_url pointing at the filed ticket."
|
||||
"Queues a background issue creation job and returns {uid, status_url}. Poll the "
|
||||
"status_url with issue_job_status until status is 'done', then show the user the "
|
||||
"issue_url pointing at the filed ticket."
|
||||
),
|
||||
params=(
|
||||
body("title", "Bug title.", required=True),
|
||||
body("description", "Bug description.", required=True),
|
||||
body("title", "Issue title.", required=True),
|
||||
body("description", "Issue description.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="bug_job_status",
|
||||
name="issue_job_status",
|
||||
method="GET",
|
||||
path="/bugs/jobs/{uid}",
|
||||
summary="Poll a bug creation job and obtain the filed bug ticket URL once finished",
|
||||
path="/issues/jobs/{uid}",
|
||||
summary="Poll an issue creation job and obtain the filed issue ticket URL once finished",
|
||||
description=(
|
||||
"Returns the job status. When status is 'done', bug_url and number are populated; "
|
||||
"Returns the job status. When status is 'done', issue_url and number are populated; "
|
||||
"while 'pending' or 'running', poll again shortly."
|
||||
),
|
||||
params=(path("uid", "Bug job uid returned by create_bug."),),
|
||||
params=(path("uid", "Issue job uid returned by create_issue."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="view_bug",
|
||||
name="view_issue",
|
||||
method="GET",
|
||||
path="/bugs/{number}",
|
||||
summary="View a bug ticket and its developer updates",
|
||||
path="/issues/{number}",
|
||||
summary="View an issue ticket and its developer updates",
|
||||
requires_auth=False,
|
||||
params=(path("number", "The bug ticket number from a /bugs listing."),),
|
||||
params=(path("number", "The issue ticket number from a /issues listing."),),
|
||||
),
|
||||
Action(
|
||||
name="comment_bug",
|
||||
name="comment_issue",
|
||||
method="POST",
|
||||
path="/bugs/{number}/comment",
|
||||
summary="Post a comment on a bug ticket, attributed to the current user",
|
||||
path="/issues/{number}/comment",
|
||||
summary="Post a comment on an issue ticket, attributed to the current user",
|
||||
params=(
|
||||
path("number", "The bug ticket number."),
|
||||
path("number", "The issue ticket number."),
|
||||
body("body", "Comment text.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="set_bug_status",
|
||||
name="set_issue_status",
|
||||
method="POST",
|
||||
path="/bugs/{number}/status",
|
||||
summary="Change a bug ticket status (admin only)",
|
||||
path="/issues/{number}/status",
|
||||
summary="Change an issue ticket status (admin only)",
|
||||
requires_admin=True,
|
||||
params=(
|
||||
path("number", "The bug ticket number."),
|
||||
path("number", "The issue ticket number."),
|
||||
body("status", "New status: open or closed.", required=True),
|
||||
),
|
||||
),
|
||||
@@ -1056,7 +1056,7 @@ ACTIONS: tuple[Action, ...] = (
|
||||
"Fetches the file at the given URL on the server (size-capped, SSRF-guarded) and stores it "
|
||||
"as an attachment exactly like an upload, returning {uid, url, mime_type, ...}. Pass the "
|
||||
"returned uid in attachment_uids when you create_post, create_project, create_gist, "
|
||||
"create_comment, create_bug, or send_message to attach it. The file type is taken from the "
|
||||
"create_comment, create_issue, or send_message to attach it. The file type is taken from the "
|
||||
"URL or its Content-Type; supply 'filename' (with an allowed extension) when the URL has no "
|
||||
"usable name. Use this to attach an image or file straight from the internet."
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ def arg(
|
||||
|
||||
|
||||
TYPES = (
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, bug."
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue."
|
||||
)
|
||||
|
||||
NOTIFICATION_ACTIONS: tuple[Action, ...] = (
|
||||
|
||||
@@ -160,10 +160,10 @@ SYSTEM_PROMPT = (
|
||||
"loopback addresses are refused.\n\n"
|
||||
"ATTACHING FILES FROM THE INTERNET\n"
|
||||
"To attach an image or file that lives at a public URL to something you create (a post, project, "
|
||||
"gist, comment, bug report, or direct message), call attach_url with that URL. It downloads the "
|
||||
"gist, comment, issue report, or direct message), call attach_url with that URL. It downloads the "
|
||||
"file on the server and stores it as a real attachment, returning a uid. Then pass that uid in the "
|
||||
"attachment_uids field when you call create_post, create_project, create_gist, create_comment, "
|
||||
"create_bug, or send_message, and the file is attached. Collect several uids to attach more than "
|
||||
"create_issue, or send_message, and the file is attached. Collect several uids to attach more than "
|
||||
"one. Do not paste the raw URL into the body when the user wants it attached - use attach_url so it "
|
||||
"becomes a proper hosted attachment with a thumbnail. The file type comes from the URL or its "
|
||||
"Content-Type; if a URL has no clear name, pass a filename with an allowed extension.\n\n"
|
||||
|
||||
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.net_guard import effective_address, is_blocked_address
|
||||
from ..config import Settings
|
||||
from ..errors import NetworkError, ToolInputError, UpstreamError
|
||||
@@ -240,7 +241,7 @@ class FetchController:
|
||||
elif content is not None:
|
||||
request_kwargs["content"] = content
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with stealth.stealth_async_client(
|
||||
headers=merged_headers,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from .errors import AuthRequiredError, NetworkError, ToolInputError, UpstreamError
|
||||
|
||||
logger = logging.getLogger("devii.http")
|
||||
@@ -28,7 +29,7 @@ class PlatformClient:
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-API-Key"] = api_key
|
||||
self._client = httpx.AsyncClient(
|
||||
self._client = stealth.stealth_async_client(
|
||||
base_url=base_url,
|
||||
timeout=timeout_seconds,
|
||||
follow_redirects=True,
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from .config import Settings
|
||||
from .cost import record_usage
|
||||
from .errors import LLMError
|
||||
@@ -17,7 +18,7 @@ logger = logging.getLogger("devii.llm")
|
||||
class LLMClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = httpx.AsyncClient(
|
||||
self._client = stealth.stealth_async_client(
|
||||
timeout=settings.timeout_seconds,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.ai_key}",
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from ..config import Settings
|
||||
from ..errors import NetworkError, ToolInputError, UpstreamError
|
||||
|
||||
@@ -47,7 +48,7 @@ class RsearchController:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(self._settings.rsearch_timeout_seconds, connect=30.0)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with stealth.stealth_async_client(
|
||||
base_url=self._settings.rsearch_url,
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,28 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.avatar import generate_avatar_svg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def background_color(username: Optional[str]) -> str:
|
||||
seed = username or "?"
|
||||
return hashlib.md5(seed.encode("utf-8")).hexdigest()[:6]
|
||||
|
||||
|
||||
def avatar_payload(username: Optional[str]) -> dict:
|
||||
seed = username or "anonymous"
|
||||
return {"b": background_color(seed), "i": f"u/{seed}.png"}
|
||||
|
||||
|
||||
def render_png(username: str, size: int = 128) -> bytes:
|
||||
import cairosvg
|
||||
|
||||
svg = generate_avatar_svg(username)
|
||||
return cairosvg.svg2png(
|
||||
bytestring=svg.encode("utf-8"), output_width=size, output_height=size
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
get_user_votes,
|
||||
get_vote_counts,
|
||||
get_comment_counts_by_post_uids,
|
||||
get_user_stars,
|
||||
text_search_clause,
|
||||
)
|
||||
from devplacepy.services.devrant.serializers import serialize_rant, serialize_comment
|
||||
|
||||
SORT_ORDER = {
|
||||
"recent": ["-created_at"],
|
||||
"top": ["-stars", "-created_at"],
|
||||
"algo": ["-stars", "-created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _score_maps(user_uids: list) -> dict:
|
||||
return {uid: get_user_stars(uid) for uid in set(user_uids)}
|
||||
|
||||
|
||||
def build_rant_list(posts: list, viewer: Optional[dict]) -> list:
|
||||
post_uids = [post["uid"] for post in posts]
|
||||
user_uids = [post["user_uid"] for post in posts]
|
||||
authors = get_users_by_uids(list(set(user_uids)))
|
||||
user_scores = _score_maps(user_uids)
|
||||
my_votes = get_user_votes(viewer["uid"], post_uids) if viewer else {}
|
||||
comment_counts = get_comment_counts_by_post_uids(post_uids)
|
||||
attachments = get_attachments_batch("post", post_uids)
|
||||
return [
|
||||
serialize_rant(
|
||||
post,
|
||||
authors=authors,
|
||||
comment_counts=comment_counts,
|
||||
user_scores=user_scores,
|
||||
my_votes=my_votes,
|
||||
attachments=attachments,
|
||||
viewer=viewer,
|
||||
)
|
||||
for post in posts
|
||||
]
|
||||
|
||||
|
||||
def list_rants(sort: str, limit: int, skip: int, viewer: Optional[dict]) -> list:
|
||||
order = SORT_ORDER.get(sort, SORT_ORDER["recent"])
|
||||
posts = list(
|
||||
get_table("posts").find(
|
||||
deleted_at=None, order_by=order, _limit=limit, _offset=skip
|
||||
)
|
||||
)
|
||||
return build_rant_list(posts, viewer)
|
||||
|
||||
|
||||
def search_rants(term: str, viewer: Optional[dict], limit: int = 50) -> list:
|
||||
posts_table = get_table("posts")
|
||||
clause = text_search_clause(posts_table, term, ("title", "content"))
|
||||
if clause is None:
|
||||
return []
|
||||
posts = list(
|
||||
posts_table.find(
|
||||
clause, deleted_at=None, order_by=["-created_at"], _limit=limit
|
||||
)
|
||||
)
|
||||
return build_rant_list(posts, viewer)
|
||||
|
||||
|
||||
def load_rant_detail(post: dict, viewer: Optional[dict]) -> dict:
|
||||
rant = build_rant_list([post], viewer)[0]
|
||||
comments = list(
|
||||
get_table("comments").find(
|
||||
target_type="post",
|
||||
target_uid=post["uid"],
|
||||
deleted_at=None,
|
||||
order_by=["created_at"],
|
||||
)
|
||||
)
|
||||
comment_uids = [comment["uid"] for comment in comments]
|
||||
user_uids = [comment["user_uid"] for comment in comments]
|
||||
authors = get_users_by_uids(list(set(user_uids)))
|
||||
user_scores = _score_maps(user_uids)
|
||||
ups, downs = get_vote_counts(comment_uids)
|
||||
score_map = {uid: ups.get(uid, 0) - downs.get(uid, 0) for uid in comment_uids}
|
||||
my_votes = get_user_votes(viewer["uid"], comment_uids) if viewer else {}
|
||||
serialized = [
|
||||
serialize_comment(
|
||||
comment,
|
||||
int(post["id"]),
|
||||
authors=authors,
|
||||
user_scores=user_scores,
|
||||
my_votes=my_votes,
|
||||
score_map=score_map,
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
return {"rant": rant, "comments": serialized}
|
||||
@@ -0,0 +1,50 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import get_table
|
||||
|
||||
|
||||
def as_int(value: object, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def to_unix(iso_value: Optional[str]) -> int:
|
||||
if not iso_value:
|
||||
return 0
|
||||
try:
|
||||
moment = datetime.fromisoformat(iso_value)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return int(moment.timestamp())
|
||||
|
||||
|
||||
def now_unix() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def post_by_id(rant_id: object) -> Optional[dict]:
|
||||
identifier = as_int(rant_id)
|
||||
if not identifier:
|
||||
return None
|
||||
return get_table("posts").find_one(id=identifier, deleted_at=None)
|
||||
|
||||
|
||||
def comment_by_id(comment_id: object) -> Optional[dict]:
|
||||
identifier = as_int(comment_id)
|
||||
if not identifier:
|
||||
return None
|
||||
return get_table("comments").find_one(id=identifier, deleted_at=None)
|
||||
|
||||
|
||||
def user_by_id(user_id: object) -> Optional[dict]:
|
||||
identifier = as_int(user_id)
|
||||
if not identifier:
|
||||
return None
|
||||
return get_table("users").find_one(id=identifier)
|
||||
@@ -0,0 +1,84 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import get_table, get_users_by_uids
|
||||
from devplacepy.services.devrant.ids import to_unix, now_unix
|
||||
|
||||
DEVRANT_TYPE_MAP = {
|
||||
"comment": "comment_discuss",
|
||||
"reply": "comment_discuss",
|
||||
"mention": "comment_mention",
|
||||
"vote": "rant_vote",
|
||||
"follow": "rant_sub",
|
||||
}
|
||||
|
||||
UNREAD_BUCKET = {
|
||||
"rant_vote": "upvotes",
|
||||
"comment_mention": "mentions",
|
||||
"comment_discuss": "comments",
|
||||
"rant_sub": "subs",
|
||||
}
|
||||
|
||||
FEED_LIMIT = 50
|
||||
|
||||
|
||||
def build_notif_feed(user: dict) -> dict:
|
||||
notifications = list(
|
||||
get_table("notifications").find(
|
||||
user_uid=user["uid"], order_by=["-created_at"], _limit=FEED_LIMIT
|
||||
)
|
||||
)
|
||||
actor_uids = list(
|
||||
{note.get("related_uid") for note in notifications if note.get("related_uid")}
|
||||
)
|
||||
actors = get_users_by_uids(actor_uids)
|
||||
username_map: dict = {}
|
||||
items = []
|
||||
unread = {
|
||||
"all": 0,
|
||||
"upvotes": 0,
|
||||
"mentions": 0,
|
||||
"comments": 0,
|
||||
"subs": 0,
|
||||
"total": 0,
|
||||
}
|
||||
for note in notifications:
|
||||
dr_type = DEVRANT_TYPE_MAP.get(note.get("type"))
|
||||
if not dr_type:
|
||||
continue
|
||||
actor = actors.get(note.get("related_uid")) or {}
|
||||
actor_id = int(actor.get("id") or 0)
|
||||
if actor_id:
|
||||
username_map[str(actor_id)] = actor.get("username") or ""
|
||||
is_read = 1 if note.get("read") else 0
|
||||
items.append(
|
||||
{
|
||||
"type": dr_type,
|
||||
"rant_id": 0,
|
||||
"comment_id": 0,
|
||||
"created_time": to_unix(note.get("created_at")),
|
||||
"read": is_read,
|
||||
"uid": actor_id,
|
||||
"username": actor.get("username") or "",
|
||||
}
|
||||
)
|
||||
if not is_read:
|
||||
bucket = UNREAD_BUCKET.get(dr_type)
|
||||
if bucket:
|
||||
unread[bucket] += 1
|
||||
unread["all"] += 1
|
||||
unread["total"] += 1
|
||||
return {
|
||||
"items": items,
|
||||
"check_time": now_unix(),
|
||||
"username_map": username_map,
|
||||
"unread": unread,
|
||||
"num_unread": unread["total"],
|
||||
}
|
||||
|
||||
|
||||
def clear_notifications(user: dict) -> None:
|
||||
notifications = get_table("notifications")
|
||||
for note in notifications.find(user_uid=user["uid"], read=False):
|
||||
notifications.update({"id": note["id"], "read": True}, ["id"])
|
||||
@@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BODY_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
|
||||
|
||||
async def merge_params(request: Request) -> dict:
|
||||
params: dict[str, Any] = dict(request.query_params)
|
||||
if request.method not in BODY_METHODS:
|
||||
return params
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
|
||||
body = None
|
||||
if isinstance(body, dict):
|
||||
params.update(body)
|
||||
return params
|
||||
try:
|
||||
form = await request.form()
|
||||
except (ValueError, RuntimeError) as error:
|
||||
logger.debug("devrant form parse failed: %s", error)
|
||||
return params
|
||||
for key in form:
|
||||
params[key] = form[key]
|
||||
return params
|
||||
@@ -0,0 +1,109 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
get_user_votes,
|
||||
get_vote_counts,
|
||||
get_user_stars,
|
||||
)
|
||||
from devplacepy.services.devrant.avatar import avatar_payload
|
||||
from devplacepy.services.devrant.feed import build_rant_list
|
||||
from devplacepy.services.devrant.ids import to_unix
|
||||
from devplacepy.services.devrant.serializers import serialize_comment
|
||||
|
||||
PROFILE_RANT_LIMIT = 50
|
||||
PROFILE_COMMENT_LIMIT = 50
|
||||
SKILLS_MAX_WORDS = 8
|
||||
|
||||
|
||||
def skills_from_bio(bio: Optional[str]) -> str:
|
||||
if not bio:
|
||||
return ""
|
||||
words = bio.replace("\n", " ").split()
|
||||
return " ".join(words[:SKILLS_MAX_WORDS])
|
||||
|
||||
|
||||
def _user_rants(user: dict, viewer: Optional[dict]) -> list:
|
||||
posts = list(
|
||||
get_table("posts").find(
|
||||
user_uid=user["uid"],
|
||||
deleted_at=None,
|
||||
order_by=["-created_at"],
|
||||
_limit=PROFILE_RANT_LIMIT,
|
||||
)
|
||||
)
|
||||
return build_rant_list(posts, viewer)
|
||||
|
||||
|
||||
def _user_comments(user: dict, viewer: Optional[dict]) -> list:
|
||||
comments = list(
|
||||
get_table("comments").find(
|
||||
user_uid=user["uid"],
|
||||
target_type="post",
|
||||
deleted_at=None,
|
||||
order_by=["-created_at"],
|
||||
_limit=PROFILE_COMMENT_LIMIT,
|
||||
)
|
||||
)
|
||||
if not comments:
|
||||
return []
|
||||
target_uids = list({comment["target_uid"] for comment in comments})
|
||||
posts_table = get_table("posts")
|
||||
rant_ids = {}
|
||||
for target_uid in target_uids:
|
||||
post = posts_table.find_one(uid=target_uid)
|
||||
if post:
|
||||
rant_ids[target_uid] = int(post["id"])
|
||||
comment_uids = [comment["uid"] for comment in comments]
|
||||
authors = get_users_by_uids([user["uid"]])
|
||||
user_scores = {user["uid"]: get_user_stars(user["uid"])}
|
||||
ups, downs = get_vote_counts(comment_uids)
|
||||
score_map = {uid: ups.get(uid, 0) - downs.get(uid, 0) for uid in comment_uids}
|
||||
my_votes = get_user_votes(viewer["uid"], comment_uids) if viewer else {}
|
||||
return [
|
||||
serialize_comment(
|
||||
comment,
|
||||
rant_ids.get(comment["target_uid"], 0),
|
||||
authors=authors,
|
||||
user_scores=user_scores,
|
||||
my_votes=my_votes,
|
||||
score_map=score_map,
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
|
||||
|
||||
def build_profile(user: dict, viewer: Optional[dict]) -> dict:
|
||||
bio = user.get("bio") or ""
|
||||
rants = _user_rants(user, viewer)
|
||||
comments = _user_comments(user, viewer)
|
||||
return {
|
||||
"username": user.get("username") or "",
|
||||
"score": get_user_stars(user["uid"]),
|
||||
"about": bio,
|
||||
"location": user.get("location") or "",
|
||||
"created_time": to_unix(user.get("created_at")),
|
||||
"skills": skills_from_bio(bio),
|
||||
"github": user.get("git_link") or "",
|
||||
"website": user.get("website") or "",
|
||||
"avatar": avatar_payload(user.get("username")),
|
||||
"content": {
|
||||
"content": {
|
||||
"rants": rants,
|
||||
"upvoted": [],
|
||||
"comments": comments,
|
||||
"favorites": [],
|
||||
"viewed": [],
|
||||
},
|
||||
"counts": {
|
||||
"rants": len(rants),
|
||||
"upvoted": 0,
|
||||
"comments": len(comments),
|
||||
"favorites": 0,
|
||||
"collabs": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.services.devrant.avatar import avatar_payload
|
||||
from devplacepy.services.devrant.ids import to_unix
|
||||
|
||||
|
||||
def decode_tags(post: dict) -> list:
|
||||
raw = post.get("tags")
|
||||
if raw:
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
if isinstance(value, list):
|
||||
return [str(tag) for tag in value]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
topic = post.get("topic")
|
||||
return [topic] if topic else []
|
||||
|
||||
|
||||
def encode_tags(tags: list) -> str:
|
||||
cleaned = [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||
return json.dumps(cleaned)
|
||||
|
||||
|
||||
def rant_text(post: dict) -> str:
|
||||
title = (post.get("title") or "").strip()
|
||||
content = post.get("content") or ""
|
||||
if title:
|
||||
return f"{title}\n\n{content}"
|
||||
return content
|
||||
|
||||
|
||||
def _image_url(post: dict, attachments: Optional[list]) -> str:
|
||||
if post.get("image"):
|
||||
return f"/static/uploads/{post['image']}"
|
||||
for attachment in attachments or []:
|
||||
if attachment.get("is_image"):
|
||||
return attachment.get("url") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def serialize_rant(
|
||||
post: dict,
|
||||
*,
|
||||
authors: dict,
|
||||
comment_counts: dict,
|
||||
user_scores: dict,
|
||||
my_votes: dict,
|
||||
attachments: Optional[dict] = None,
|
||||
viewer: Optional[dict] = None,
|
||||
) -> dict:
|
||||
author = authors.get(post["user_uid"]) or {}
|
||||
uid = post["uid"]
|
||||
username = author.get("username") or ""
|
||||
return {
|
||||
"id": int(post["id"]),
|
||||
"text": rant_text(post),
|
||||
"score": int(post.get("stars") or 0),
|
||||
"created_time": to_unix(post.get("created_at")),
|
||||
"attached_image": _image_url(post, (attachments or {}).get(uid)),
|
||||
"num_comments": int(comment_counts.get(uid, 0)),
|
||||
"tags": decode_tags(post),
|
||||
"vote_state": int(my_votes.get(uid, 0)),
|
||||
"edited": bool(post.get("updated_at")),
|
||||
"link": f"rants/{post['id']}/{post.get('slug') or ''}",
|
||||
"rt": 1,
|
||||
"rc": 0,
|
||||
"user_id": int(author.get("id") or 0),
|
||||
"user_username": username,
|
||||
"user_score": int(user_scores.get(post["user_uid"], 0)),
|
||||
"user_avatar": avatar_payload(username),
|
||||
"user_avatar_lg": avatar_payload(username),
|
||||
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
||||
}
|
||||
|
||||
|
||||
def serialize_comment(
|
||||
comment: dict,
|
||||
rant_id: int,
|
||||
*,
|
||||
authors: dict,
|
||||
user_scores: dict,
|
||||
my_votes: dict,
|
||||
score_map: dict,
|
||||
) -> dict:
|
||||
author = authors.get(comment["user_uid"]) or {}
|
||||
uid = comment["uid"]
|
||||
username = author.get("username") or ""
|
||||
return {
|
||||
"id": int(comment["id"]),
|
||||
"rant_id": int(rant_id),
|
||||
"body": comment.get("content") or "",
|
||||
"score": int(score_map.get(uid, 0)),
|
||||
"created_time": to_unix(comment.get("created_at")),
|
||||
"vote_state": int(my_votes.get(uid, 0)),
|
||||
"user_id": int(author.get("id") or 0),
|
||||
"user_username": username,
|
||||
"user_score": int(user_scores.get(comment["user_uid"], 0)),
|
||||
"user_avatar": avatar_payload(username),
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.database import get_table, get_int_setting
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy.services.devrant.ids import as_int, now_unix
|
||||
|
||||
TOKEN_KEY_BYTES = 20
|
||||
|
||||
|
||||
def issue_token(user: dict) -> dict:
|
||||
tokens = get_table("devrant_tokens")
|
||||
key = secrets.token_hex(TOKEN_KEY_BYTES)
|
||||
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
|
||||
moment = datetime.now(timezone.utc)
|
||||
expire_time = int(moment.timestamp()) + max_age
|
||||
user_id = as_int(user.get("id"))
|
||||
token_id = tokens.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"key": key,
|
||||
"user_uid": user["uid"],
|
||||
"user_id": user_id,
|
||||
"expire_time": expire_time,
|
||||
"created_at": moment.isoformat(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": int(token_id),
|
||||
"key": key,
|
||||
"expire_time": expire_time,
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
def resolve_user(params: dict) -> Optional[dict]:
|
||||
token_id = as_int(params.get("token_id"))
|
||||
token_key = (params.get("token_key") or "").strip()
|
||||
user_id = as_int(params.get("user_id"))
|
||||
if not token_id or not token_key:
|
||||
return None
|
||||
token = get_table("devrant_tokens").find_one(id=token_id, deleted_at=None)
|
||||
if not token or token.get("key") != token_key:
|
||||
return None
|
||||
if user_id and as_int(token.get("user_id")) != user_id:
|
||||
return None
|
||||
if as_int(token.get("expire_time")) < now_unix():
|
||||
return None
|
||||
user = get_table("users").find_one(uid=token.get("user_uid"))
|
||||
if not user or not user.get("is_active", True):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def revoke_all(user_uid: str) -> None:
|
||||
tokens = get_table("devrant_tokens")
|
||||
stamp = datetime.now(timezone.utc).isoformat()
|
||||
for token in tokens.find(user_uid=user_uid, deleted_at=None):
|
||||
tokens.update(
|
||||
{"id": token["id"], "deleted_at": stamp, "deleted_by": user_uid}, ["id"]
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.services.gitea.config import (
|
||||
HTTP_TIMEOUT_SECONDS,
|
||||
GiteaConfig,
|
||||
@@ -45,7 +46,7 @@ class GiteaClient:
|
||||
self._require_config()
|
||||
logger.debug("Gitea %s %s", method, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||
response = await client.request(
|
||||
method, url, headers=self._headers(), **kwargs
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ CONFIG_FIELDS: list[ConfigField] = [
|
||||
"Repository name",
|
||||
type="str",
|
||||
default=REPO_DEFAULT,
|
||||
help="Repository whose issues back the bug tracker.",
|
||||
help="Repository whose issues back the issue tracker.",
|
||||
group="Gitea",
|
||||
),
|
||||
ConfigField(
|
||||
@@ -70,7 +70,7 @@ CONFIG_FIELDS: list[ConfigField] = [
|
||||
group="Gitea",
|
||||
),
|
||||
ConfigField(
|
||||
"bug_ai_enhance",
|
||||
"issue_ai_enhance",
|
||||
"Improve tickets with AI",
|
||||
type="bool",
|
||||
default=True,
|
||||
@@ -78,7 +78,7 @@ CONFIG_FIELDS: list[ConfigField] = [
|
||||
group="AI",
|
||||
),
|
||||
ConfigField(
|
||||
"bug_ai_model",
|
||||
"issue_ai_model",
|
||||
"AI model",
|
||||
type="str",
|
||||
default=INTERNAL_MODEL,
|
||||
@@ -86,7 +86,7 @@ CONFIG_FIELDS: list[ConfigField] = [
|
||||
group="AI",
|
||||
),
|
||||
ConfigField(
|
||||
"bug_ai_key",
|
||||
"issue_ai_key",
|
||||
"AI API key",
|
||||
type="password",
|
||||
default="",
|
||||
@@ -98,14 +98,14 @@ CONFIG_FIELDS: list[ConfigField] = [
|
||||
|
||||
|
||||
def gitea_config() -> GiteaConfig:
|
||||
ai_key = get_setting("bug_ai_key", "") or internal_gateway_key()
|
||||
ai_key = get_setting("issue_ai_key", "") or internal_gateway_key()
|
||||
return GiteaConfig(
|
||||
base_url=get_setting("gitea_base_url", BASE_URL_DEFAULT),
|
||||
owner=get_setting("gitea_owner", OWNER_DEFAULT),
|
||||
repo=get_setting("gitea_repo", REPO_DEFAULT),
|
||||
token=get_setting("gitea_token", ""),
|
||||
ai_enhance=get_setting("bug_ai_enhance", "1") == "1",
|
||||
ai_model=get_setting("bug_ai_model", INTERNAL_MODEL),
|
||||
ai_enhance=get_setting("issue_ai_enhance", "1") == "1",
|
||||
ai_model=get_setting("issue_ai_model", INTERNAL_MODEL),
|
||||
ai_key=ai_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL
|
||||
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
|
||||
|
||||
@@ -18,7 +19,7 @@ BODY_MAX = 60000
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a senior triage engineer for DevPlace, a social network for software "
|
||||
"developers. You rewrite raw user bug reports into clear, consistent, high-quality "
|
||||
"developers. You rewrite raw user issue reports into clear, consistent, high-quality "
|
||||
"issue tickets for a Gitea tracker. Preserve every concrete fact the reporter gave; "
|
||||
"never invent versions, stack traces, or steps that were not provided. Keep a calm, "
|
||||
"technical, professional tone. Do not add greetings or sign-offs.\n\n"
|
||||
@@ -91,7 +92,7 @@ async def enhance_ticket(
|
||||
headers["Authorization"] = f"Bearer {config.ai_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(
|
||||
INTERNAL_GATEWAY_URL, json=payload, headers=headers
|
||||
)
|
||||
@@ -103,13 +104,13 @@ async def enhance_ticket(
|
||||
.get("content", "")
|
||||
)
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Bug ticket enhancement failed, using fallback: %s", exc)
|
||||
logger.warning("Issue ticket enhancement failed, using fallback: %s", exc)
|
||||
return _fallback(title, description)
|
||||
|
||||
parsed = _parse(content)
|
||||
if not parsed:
|
||||
logger.warning("Bug ticket enhancement returned unparseable content")
|
||||
logger.warning("Issue ticket enhancement returned unparseable content")
|
||||
return _fallback(title, description)
|
||||
new_title, new_body = parsed
|
||||
logger.info("Enhanced bug ticket '%s' -> '%s'", title[:60], new_title[:60])
|
||||
logger.info("Enhanced issue ticket '%s' -> '%s'", title[:60], new_title[:60])
|
||||
return EnhancedTicket(title=new_title, body=new_body, enhanced=True)
|
||||
|
||||
@@ -11,20 +11,20 @@ from devplacepy.utils import create_notification
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BugTrackerService(BaseService):
|
||||
interval_key = "bug_tracker_interval"
|
||||
class IssueTrackerService(BaseService):
|
||||
interval_key = "issue_tracker_interval"
|
||||
min_interval = 30
|
||||
default_enabled = False
|
||||
title = "Bug Tracker"
|
||||
title = "Issue Tracker"
|
||||
description = (
|
||||
"Holds the Gitea connection and AI settings for the bug tracker and polls every "
|
||||
"Holds the Gitea connection and AI settings for the issue tracker and polls every "
|
||||
"tracked issue for developer replies and status changes, notifying the original "
|
||||
"reporter when their ticket is updated."
|
||||
)
|
||||
config_fields = CONFIG_FIELDS
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="bug_tracker", interval_seconds=120)
|
||||
super().__init__(name="issue_tracker", interval_seconds=120)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
config = gitea_config()
|
||||
@@ -69,24 +69,24 @@ class BugTrackerService(BaseService):
|
||||
if external:
|
||||
create_notification(
|
||||
author_uid,
|
||||
"bug",
|
||||
f"New developer reply on your bug #{number}",
|
||||
"issue",
|
||||
f"New developer reply on your issue #{number}",
|
||||
str(number),
|
||||
f"/bugs/{number}",
|
||||
f"/issues/{number}",
|
||||
)
|
||||
self._audit("bug.sync.reply", author_uid, number)
|
||||
self._audit("issue.sync.reply", author_uid, number)
|
||||
notified += 1
|
||||
|
||||
if state != last_status:
|
||||
verb = "closed" if state == "closed" else "reopened"
|
||||
create_notification(
|
||||
author_uid,
|
||||
"bug",
|
||||
f"Your bug #{number} was {verb}",
|
||||
"issue",
|
||||
f"Your issue #{number} was {verb}",
|
||||
str(number),
|
||||
f"/bugs/{number}",
|
||||
f"/issues/{number}",
|
||||
)
|
||||
self._audit("bug.sync.status", author_uid, number, {"state": state})
|
||||
self._audit("issue.sync.status", author_uid, number, {"state": state})
|
||||
notified += 1
|
||||
|
||||
store.update_ticket_cache(number, state, comment_count)
|
||||
@@ -100,9 +100,9 @@ class BugTrackerService(BaseService):
|
||||
audit.record_system(
|
||||
event,
|
||||
actor_kind="service",
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
metadata={"number": number, **(metadata or {})},
|
||||
summary=f"bug #{number} update detected",
|
||||
summary=f"issue #{number} update detected",
|
||||
links=[audit.recipient(author_uid)],
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TICKETS = "bug_tickets"
|
||||
COMMENT_AUTHORS = "bug_comment_authors"
|
||||
TICKETS = "issue_tickets"
|
||||
COMMENT_AUTHORS = "issue_comment_authors"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -43,7 +43,7 @@ def record_ticket(
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
logger.info("Recorded bug ticket #%s by %s", number, author_uid)
|
||||
logger.info("Recorded issue ticket #%s by %s", number, author_uid)
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ from typing import Awaitable, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
|
||||
|
||||
from .pdf import MAX_PDF_BYTES, extract_pdf_text, is_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RSEARCH_URL = "https://rsearch.app.molodetz.nl"
|
||||
@@ -69,7 +72,7 @@ async def search_queries(queries: list[str]) -> list[dict]:
|
||||
seen: set[str] = set()
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(RSEARCH_TIMEOUT_SECONDS, connect=30.0)
|
||||
async with httpx.AsyncClient(
|
||||
async with stealth.stealth_async_client(
|
||||
base_url=RSEARCH_URL, headers=headers, follow_redirects=True, timeout=timeout
|
||||
) as client:
|
||||
for query in queries:
|
||||
@@ -130,19 +133,41 @@ async def fetch_page(url: str, depth: int) -> CrawledPage | None:
|
||||
text = ""
|
||||
status = 0
|
||||
source = "httpx"
|
||||
content_type = ""
|
||||
encoding = "utf-8"
|
||||
raw_bytes = b""
|
||||
try:
|
||||
async with guarded_async_client(
|
||||
follow_redirects=True,
|
||||
timeout=FETCH_TIMEOUT_SECONDS,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
await guard_public_url(str(response.url))
|
||||
status = response.status_code
|
||||
raw = response.text[:MAX_FETCH_BYTES]
|
||||
title, text = _strip_html(raw)
|
||||
async with client.stream("GET", url) as response:
|
||||
await guard_public_url(str(response.url))
|
||||
status = response.status_code
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
encoding = response.encoding or "utf-8"
|
||||
buffer = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) >= MAX_PDF_BYTES:
|
||||
break
|
||||
raw_bytes = bytes(buffer)
|
||||
except (httpx.HTTPError, BlockedAddressError) as exc:
|
||||
logger.info("deepsearch httpx fetch failed for %s: %s", url, exc)
|
||||
if is_pdf(content_type, url, raw_bytes[:5]):
|
||||
title, text = extract_pdf_text(raw_bytes[:MAX_PDF_BYTES])
|
||||
if len(text) < MIN_PAGE_CHARS:
|
||||
return None
|
||||
return CrawledPage(
|
||||
url=url, title=title or url, text=text, source="pdf", status=status, depth=depth
|
||||
)
|
||||
if raw_bytes:
|
||||
try:
|
||||
raw = raw_bytes[:MAX_FETCH_BYTES].decode(encoding, errors="replace")
|
||||
title, text = _strip_html(raw)
|
||||
except (LookupError, ValueError) as exc:
|
||||
logger.info("deepsearch decode failed for %s: %s", url, exc)
|
||||
if len(text) < MIN_PAGE_CHARS:
|
||||
try:
|
||||
r_title, r_text, r_status = await _render_with_playwright(url)
|
||||
|
||||
@@ -6,8 +6,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,7 +67,7 @@ async def plan_queries(query: str, api_key: str) -> list[str]:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=ENHANCE_TIMEOUT_SECONDS) as client:
|
||||
async with stealth.stealth_async_client(timeout=ENHANCE_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(
|
||||
INTERNAL_GATEWAY_URL, json=payload, headers=headers
|
||||
)
|
||||
|
||||
@@ -8,8 +8,7 @@ import re
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,7 +72,7 @@ async def _complete(messages: list[dict], api_key: str, max_tokens: int) -> str:
|
||||
"temperature": 0.2,
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=AGENT_TIMEOUT_SECONDS) as client:
|
||||
async with stealth.stealth_async_client(timeout=AGENT_TIMEOUT_SECONDS) as client:
|
||||
response = await client.post(INTERNAL_GATEWAY_URL, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"agent gateway returned {response.status_code}")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PDF_BYTES = 15_000_000
|
||||
MAX_PDF_PAGES = 50
|
||||
PDF_MAGIC = b"%PDF-"
|
||||
WHITESPACE = re.compile(r"[ \t]+")
|
||||
BLANK_LINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def is_pdf(content_type: str, url: str, head: bytes) -> bool:
|
||||
if "application/pdf" in content_type or "application/x-pdf" in content_type:
|
||||
return True
|
||||
if urlsplit(url).path.lower().endswith(".pdf"):
|
||||
return True
|
||||
return head.startswith(PDF_MAGIC)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
text = WHITESPACE.sub(" ", text)
|
||||
text = BLANK_LINES.sub("\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def extract_pdf_text(raw: bytes) -> tuple[str, str]:
|
||||
if not raw.startswith(PDF_MAGIC):
|
||||
logger.debug("deepsearch pdf payload missing magic header (%d bytes)", len(raw))
|
||||
return "", ""
|
||||
handle = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
|
||||
staged = Path(handle.name)
|
||||
try:
|
||||
handle.write(raw)
|
||||
handle.close()
|
||||
logger.info("deepsearch staged pdf at %s (%d bytes)", staged, len(raw))
|
||||
reader = PdfReader(str(staged))
|
||||
title = ""
|
||||
if reader.metadata and reader.metadata.title:
|
||||
title = str(reader.metadata.title).strip()
|
||||
parts: list[str] = []
|
||||
for index, page in enumerate(reader.pages):
|
||||
if index >= MAX_PDF_PAGES:
|
||||
logger.info("deepsearch pdf truncated at %d pages", MAX_PDF_PAGES)
|
||||
break
|
||||
extracted = page.extract_text() or ""
|
||||
if extracted.strip():
|
||||
parts.append(extracted)
|
||||
text = _normalize("\n\n".join(parts))
|
||||
logger.info("deepsearch extracted %d chars from pdf (%d pages)", len(text), len(parts))
|
||||
return title, text
|
||||
except (PdfReadError, ValueError, OSError) as exc:
|
||||
logger.info("deepsearch pdf parse failed: %s", exc)
|
||||
return "", ""
|
||||
finally:
|
||||
staged.unlink(missing_ok=True)
|
||||
+17
-17
@@ -17,18 +17,18 @@ def _attribution(body: str, username: str) -> str:
|
||||
return f"{body}\n\n---\n*Reported by **{username}** via DevPlace.*"
|
||||
|
||||
|
||||
class BugCreateService(JobService):
|
||||
kind = "bug_create"
|
||||
title = "Bug filing"
|
||||
class IssueCreateService(JobService):
|
||||
kind = "issue_create"
|
||||
title = "Issue filing"
|
||||
description = (
|
||||
"Files submitted bug reports off the request path: rewrites each report into a "
|
||||
"Files submitted issue reports off the request path: rewrites each report into a "
|
||||
"consistent, high-quality ticket with the internal AI service, posts it to the "
|
||||
"configured Gitea repository, records the local author mapping, and notifies the "
|
||||
"reporter. The Gitea issue is permanent; only the job tracking row is swept."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="bug_create", interval_seconds=2)
|
||||
super().__init__(name="issue_create", interval_seconds=2)
|
||||
|
||||
async def process(self, job: dict) -> dict:
|
||||
payload = job["payload"]
|
||||
@@ -68,43 +68,43 @@ class BugCreateService(JobService):
|
||||
|
||||
create_notification(
|
||||
author_uid,
|
||||
"bug",
|
||||
f"Your bug report was filed as #{number}: {enhanced.title}",
|
||||
"issue",
|
||||
f"Your issue report was filed as #{number}: {enhanced.title}",
|
||||
str(number),
|
||||
f"/bugs/{number}",
|
||||
f"/issues/{number}",
|
||||
)
|
||||
|
||||
audit.record_system(
|
||||
"bug.create",
|
||||
"issue.create",
|
||||
actor_kind="user",
|
||||
actor_uid=author_uid,
|
||||
actor_username=user.get("username"),
|
||||
origin="devplace",
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
target_uid=str(number),
|
||||
target_label=enhanced.title,
|
||||
metadata={"number": number, "enhanced": enhanced.enhanced, "url": html_url},
|
||||
summary=f"{user.get('username')} filed bug #{number}: {enhanced.title}",
|
||||
links=[audit.target("bug", str(number), enhanced.title), audit.job(job["uid"])],
|
||||
summary=f"{user.get('username')} filed issue #{number}: {enhanced.title}",
|
||||
links=[audit.target("issue", str(number), enhanced.title), audit.job(job["uid"])],
|
||||
)
|
||||
self.log(f"Filed bug #{number} for {user['username']}")
|
||||
self.log(f"Filed issue #{number} for {user['username']}")
|
||||
return {
|
||||
"number": number,
|
||||
"html_url": html_url,
|
||||
"bug_url": f"/bugs/{number}",
|
||||
"issue_url": f"/issues/{number}",
|
||||
"enhanced": enhanced.enhanced,
|
||||
}
|
||||
except Exception:
|
||||
audit.record_system(
|
||||
"bug.create",
|
||||
"issue.create",
|
||||
actor_kind="user",
|
||||
actor_uid=author_uid,
|
||||
actor_username=user.get("username") if user else None,
|
||||
origin="devplace",
|
||||
target_type="bug",
|
||||
target_type="issue",
|
||||
metadata={"title": title, "description": description[:200]},
|
||||
result="failure",
|
||||
summary=f"Bug filing failed for {user.get('username', 'unknown') if user else 'unknown'}: {title[:60]}",
|
||||
summary=f"Issue filing failed for {user.get('username', 'unknown') if user else 'unknown'}: {title[:60]}",
|
||||
links=[audit.job(job["uid"])],
|
||||
)
|
||||
raise
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
from devplacepy.database import get_table, get_setting, internal_gateway_key
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
@@ -129,7 +130,7 @@ class NewsService(BaseService):
|
||||
threshold = config["news_grade_threshold"]
|
||||
|
||||
self.log(f"Fetching news from {api_url}")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with stealth.stealth_async_client(timeout=30.0) as client:
|
||||
try:
|
||||
resp = await client.get(api_url)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Optional
|
||||
import httpx
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.services.openai_gateway import config
|
||||
from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send
|
||||
from devplacepy.services.openai_gateway.system_message import apply_system_directives
|
||||
@@ -118,7 +119,9 @@ class GatewayRuntime:
|
||||
limits = httpx.Limits(
|
||||
max_connections=instances, max_keepalive_connections=instances
|
||||
)
|
||||
self._client = httpx.AsyncClient(timeout=float(timeout), limits=limits)
|
||||
self._client = stealth.stealth_async_client(
|
||||
timeout=float(timeout), limits=limits
|
||||
)
|
||||
self._sem = asyncio.Semaphore(instances)
|
||||
self._instances = instances
|
||||
self._timeout = timeout
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||
from devplacepy.services.base import BaseService
|
||||
|
||||
XMLRPC_INTERVAL_SECONDS = 15
|
||||
SERVER_MODULE = "devplacepy.services.xmlrpc.server"
|
||||
TERMINATE_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
class XmlrpcService(BaseService):
|
||||
title = "XML-RPC Bridge"
|
||||
description = (
|
||||
"Forking XML-RPC server that exposes every documented REST endpoint as an "
|
||||
"XML-RPC method. Reachable at /xmlrpc through the app and nginx."
|
||||
)
|
||||
default_enabled = True
|
||||
min_interval = 5
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS)
|
||||
self._process: subprocess.Popen | None = None
|
||||
|
||||
def _alive(self) -> bool:
|
||||
return self._process is not None and self._process.poll() is None
|
||||
|
||||
def _spawn(self) -> None:
|
||||
self._process = subprocess.Popen(
|
||||
[sys.executable, "-m", SERVER_MODULE],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.log(
|
||||
f"Forking XML-RPC server started (pid {self._process.pid}) on "
|
||||
f"{XMLRPC_BIND}:{XMLRPC_PORT}"
|
||||
)
|
||||
|
||||
def _terminate(self) -> None:
|
||||
if not self._alive():
|
||||
self._process = None
|
||||
return
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=TERMINATE_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
self.log("Forking XML-RPC server stopped")
|
||||
self._process = None
|
||||
|
||||
async def on_enable(self) -> None:
|
||||
if not self._alive():
|
||||
self._spawn()
|
||||
|
||||
async def on_disable(self) -> None:
|
||||
self._terminate()
|
||||
|
||||
async def run_once(self) -> None:
|
||||
if self._alive():
|
||||
self.log(f"XML-RPC server healthy (pid {self._process.pid})")
|
||||
return
|
||||
self.log("XML-RPC server not running, starting it")
|
||||
self._spawn()
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
return {
|
||||
"running": self._alive(),
|
||||
"pid": self._process.pid if self._alive() else None,
|
||||
"bind": f"{XMLRPC_BIND}:{XMLRPC_PORT}",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["XmlrpcService"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xmlrpc.client
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_BASE_URL
|
||||
from .registry import Method
|
||||
|
||||
logger = logging.getLogger("xmlrpc.bridge")
|
||||
|
||||
FAULT_INVALID_PARAMS = -32602
|
||||
FAULT_MISSING_PARAM = -32001
|
||||
FAULT_TRANSPORT = -32300
|
||||
|
||||
WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
def _coerce_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _build_path(method: Method, params: dict[str, Any]) -> str:
|
||||
path = method.path
|
||||
for param in method.path_params:
|
||||
if param.name not in params:
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_MISSING_PARAM,
|
||||
f"Missing required path parameter '{param.name}' for {method.name}.",
|
||||
)
|
||||
path = path.replace("{" + param.name + "}", _coerce_value(params[param.name]))
|
||||
return path
|
||||
|
||||
|
||||
def _split_params(
|
||||
method: Method, params: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
declared = {p.name: p for p in method.params}
|
||||
path_names = {p.name for p in method.path_params}
|
||||
query: dict[str, str] = {}
|
||||
form: dict[str, str] = {}
|
||||
for key, value in params.items():
|
||||
if key in path_names:
|
||||
continue
|
||||
spec = declared.get(key)
|
||||
location = spec.location if spec else None
|
||||
if location == "query":
|
||||
query[key] = _coerce_value(value)
|
||||
elif location == "form":
|
||||
form[key] = _coerce_value(value)
|
||||
elif method.http_method in WRITE_METHODS:
|
||||
form[key] = _coerce_value(value)
|
||||
else:
|
||||
query[key] = _coerce_value(value)
|
||||
return query, form
|
||||
|
||||
|
||||
def _check_required(method: Method, params: dict[str, Any]) -> None:
|
||||
for spec in method.params:
|
||||
if spec.required and spec.name not in params:
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_MISSING_PARAM,
|
||||
f"Missing required parameter '{spec.name}' for {method.name}.",
|
||||
)
|
||||
|
||||
|
||||
def _decode(response: Any) -> Any:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
return response.text
|
||||
|
||||
|
||||
def call(method: Method, params: dict[str, Any], auth: dict[str, str]) -> Any:
|
||||
if not isinstance(params, dict):
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_INVALID_PARAMS,
|
||||
f"{method.name} expects a single struct of named parameters.",
|
||||
)
|
||||
params = dict(params)
|
||||
api_key = str(params.pop("api_key", "") or auth.get("api_key", ""))
|
||||
_check_required(method, params)
|
||||
path = _build_path(method, params)
|
||||
query, form = _split_params(method, params)
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "fetch",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-API-KEY"] = api_key
|
||||
elif auth.get("authorization"):
|
||||
headers["Authorization"] = auth["authorization"]
|
||||
if auth.get("real_ip"):
|
||||
headers["X-Real-IP"] = auth["real_ip"]
|
||||
if auth.get("forwarded_for"):
|
||||
headers["X-Forwarded-For"] = auth["forwarded_for"]
|
||||
|
||||
logger.debug("Bridge %s -> %s %s", method.name, method.http_method, path)
|
||||
try:
|
||||
with stealth.stealth_sync_client(
|
||||
base_url=INTERNAL_BASE_URL,
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
) as client:
|
||||
response = client.request(
|
||||
method.http_method,
|
||||
path,
|
||||
params=query or None,
|
||||
data=form or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Bridge transport error for %s: %s", method.name, exc)
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_TRANSPORT, f"Transport error calling {method.name}: {exc}"
|
||||
)
|
||||
|
||||
body = _decode(response)
|
||||
if response.status_code >= 400:
|
||||
message = body
|
||||
if isinstance(body, dict):
|
||||
error = body.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message", body)
|
||||
elif error:
|
||||
message = error
|
||||
else:
|
||||
message = body.get("detail", body)
|
||||
raise xmlrpc.client.Fault(response.status_code, _stringify(message))
|
||||
return body
|
||||
|
||||
|
||||
def _stringify(message: Any) -> str:
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
return str(message)
|
||||
@@ -0,0 +1,120 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterator
|
||||
|
||||
from devplacepy import docs_api
|
||||
|
||||
|
||||
@dataclass
|
||||
class Param:
|
||||
name: str
|
||||
location: str
|
||||
type: str
|
||||
required: bool
|
||||
description: str
|
||||
options: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Method:
|
||||
name: str
|
||||
http_method: str
|
||||
path: str
|
||||
title: str
|
||||
summary: str
|
||||
auth: str
|
||||
group: str
|
||||
destructive: bool
|
||||
params: list[Param]
|
||||
|
||||
@property
|
||||
def path_params(self) -> list[Param]:
|
||||
return [p for p in self.params if p.location == "path"]
|
||||
|
||||
@property
|
||||
def query_params(self) -> list[Param]:
|
||||
return [p for p in self.params if p.location == "query"]
|
||||
|
||||
@property
|
||||
def body_params(self) -> list[Param]:
|
||||
return [p for p in self.params if p.location == "form"]
|
||||
|
||||
|
||||
def _method_name(endpoint_id: str) -> str:
|
||||
return endpoint_id.replace("-", ".")
|
||||
|
||||
|
||||
def _to_param(spec: dict) -> Param:
|
||||
return Param(
|
||||
name=spec["name"],
|
||||
location=spec.get("location", "form"),
|
||||
type=spec.get("type", "string"),
|
||||
required=bool(spec.get("required", False)),
|
||||
description=spec.get("description", ""),
|
||||
options=list(spec.get("options", []) or []),
|
||||
)
|
||||
|
||||
|
||||
def iter_methods() -> Iterator[Method]:
|
||||
seen: set[str] = set()
|
||||
for group in docs_api.API_GROUPS:
|
||||
group_title = group.get("title", group.get("slug", ""))
|
||||
for ep in group.get("endpoints", []) or []:
|
||||
name = _method_name(ep["id"])
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
yield Method(
|
||||
name=name,
|
||||
http_method=ep["method"].upper(),
|
||||
path=ep["path"],
|
||||
title=ep.get("title", name),
|
||||
summary=ep.get("summary", ""),
|
||||
auth=ep.get("auth", "user"),
|
||||
group=group_title,
|
||||
destructive=bool(ep.get("destructive", False)),
|
||||
params=[_to_param(p) for p in ep.get("params", []) or []],
|
||||
)
|
||||
|
||||
|
||||
def build_index() -> dict[str, Method]:
|
||||
return {method.name: method for method in iter_methods()}
|
||||
|
||||
|
||||
def method_help(method: Method) -> str:
|
||||
lines = [
|
||||
f"{method.title} - {method.http_method} {method.path}",
|
||||
f"Group: {method.group} | Auth: {method.auth}",
|
||||
"",
|
||||
method.summary,
|
||||
"",
|
||||
"Call with a single struct of named parameters, for example:",
|
||||
f" proxy.{method.name}({{{_example_struct(method)}}})",
|
||||
"",
|
||||
"Authenticate by passing 'api_key' inside the struct, or with an "
|
||||
"X-API-KEY / Authorization Bearer header on the HTTP transport.",
|
||||
]
|
||||
if method.params:
|
||||
lines.append("")
|
||||
lines.append("Parameters:")
|
||||
for p in method.params:
|
||||
flag = "required" if p.required else "optional"
|
||||
extra = f" (one of: {', '.join(p.options)})" if p.options else ""
|
||||
lines.append(
|
||||
f" - {p.name} [{p.location}, {p.type}, {flag}]: {p.description}{extra}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def method_signature(method: Method) -> list[list[str]]:
|
||||
return [["struct", "struct"]]
|
||||
|
||||
|
||||
def _example_struct(method: Method) -> str:
|
||||
pieces = [f"'{p.name}': ..." for p in method.params if p.required]
|
||||
if method.auth != "public":
|
||||
pieces.append("'api_key': ...")
|
||||
return ", ".join(pieces)
|
||||
@@ -0,0 +1,115 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from socketserver import ForkingMixIn
|
||||
from xmlrpc.server import SimpleXMLRPCDispatcher, SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
|
||||
|
||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||
from . import bridge
|
||||
from .registry import Method, build_index, method_help, method_signature
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("xmlrpc.server")
|
||||
|
||||
RPC_PATHS = ("/", "/RPC2", "/xmlrpc")
|
||||
|
||||
|
||||
def _auth_from_headers(headers) -> dict[str, str]:
|
||||
auth: dict[str, str] = {}
|
||||
api_key = headers.get("X-API-KEY") or headers.get("x-api-key")
|
||||
authorization = headers.get("Authorization") or headers.get("authorization") or ""
|
||||
if not api_key and authorization.lower().startswith("bearer "):
|
||||
api_key = authorization[7:].strip()
|
||||
if authorization.lower().startswith("basic "):
|
||||
auth["authorization"] = authorization.strip()
|
||||
if api_key:
|
||||
auth["api_key"] = api_key.strip()
|
||||
real_ip = headers.get("X-Real-IP") or headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
auth["real_ip"] = real_ip
|
||||
forwarded = headers.get("X-Forwarded-For") or headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
auth["forwarded_for"] = forwarded
|
||||
return auth
|
||||
|
||||
|
||||
class _RequestHandler(SimpleXMLRPCRequestHandler):
|
||||
rpc_paths = RPC_PATHS
|
||||
|
||||
def decode_request_content(self, data):
|
||||
self.server.current_auth = _auth_from_headers(self.headers)
|
||||
return super().decode_request_content(data)
|
||||
|
||||
def log_message(self, *args, **kwargs):
|
||||
return
|
||||
|
||||
|
||||
class BridgeDispatcher(SimpleXMLRPCDispatcher):
|
||||
def system_methodHelp(self, method_name):
|
||||
target = self.index.get(method_name)
|
||||
if target is not None:
|
||||
return method_help(target)
|
||||
return super().system_methodHelp(method_name)
|
||||
|
||||
def system_methodSignature(self, method_name):
|
||||
target = self.index.get(method_name)
|
||||
if target is not None:
|
||||
return method_signature(target)
|
||||
return super().system_methodSignature(method_name)
|
||||
|
||||
|
||||
class ForkingXMLRPCServer(ForkingMixIn, BridgeDispatcher, SimpleXMLRPCServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, addr, index):
|
||||
self.index = index
|
||||
self.current_auth: dict[str, str] = {}
|
||||
SimpleXMLRPCServer.__init__(
|
||||
self,
|
||||
addr,
|
||||
requestHandler=_RequestHandler,
|
||||
allow_none=True,
|
||||
logRequests=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_handler(server: "ForkingXMLRPCServer", method: Method):
|
||||
def handler(*args):
|
||||
params = args[0] if args else {}
|
||||
return bridge.call(method, params, server.current_auth)
|
||||
|
||||
handler.__name__ = method.name
|
||||
handler.__doc__ = method_help(method)
|
||||
return handler
|
||||
|
||||
|
||||
def build_server(host: str, port: int) -> ForkingXMLRPCServer:
|
||||
index = build_index()
|
||||
server = ForkingXMLRPCServer((host, port), index)
|
||||
server.register_introspection_functions()
|
||||
server.register_multicall_functions()
|
||||
for name, method in index.items():
|
||||
server.register_function(_make_handler(server, method), name)
|
||||
logger.info("XML-RPC bridge registered %d methods", len(index))
|
||||
return server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = build_server(XMLRPC_BIND, XMLRPC_PORT)
|
||||
logger.info("Forking XML-RPC server listening on %s:%d", XMLRPC_BIND, XMLRPC_PORT)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("XML-RPC server interrupted")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -458,6 +458,30 @@ img {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes btn-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: none;
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
margin-right: 0.4em;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
vertical-align: -0.15em;
|
||||
animation: btn-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.btn.is-loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn.is-loading .btn-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -544,8 +568,9 @@ img {
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.topnav-icon.active { color: var(--accent); }
|
||||
.nav-bell { position: relative; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.nav-badge {
|
||||
position: absolute; top: 0; right: 0; z-index: 1; min-width: 16px; height: 16px;
|
||||
position: absolute; top: -6px; right: -8px; z-index: 1; min-width: 16px; height: 16px;
|
||||
padding: 0 0.25rem; border-radius: 8px; background: var(--accent); color: var(--white);
|
||||
font-size: 0.6875rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
@@ -867,8 +892,19 @@ button.comment-form-submit:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
body:has(.page-messages) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page:has(.page-messages) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.bugs-layout {
|
||||
.issues-layout {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.bugs-header {
|
||||
.issues-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bugs-header h1 {
|
||||
.issues-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bugs-filters {
|
||||
.issues-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bugs-filter {
|
||||
.issues-filter {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -28,20 +28,20 @@
|
||||
border: 1px solid var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
.bugs-filter:hover {
|
||||
.issues-filter:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.bugs-filter.active {
|
||||
.issues-filter.active {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.bugs-modal-hint {
|
||||
.issues-modal-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bug-card {
|
||||
.issue-card {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
@@ -51,29 +51,29 @@
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.bug-card:hover {
|
||||
.issue-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.bug-card-header {
|
||||
.issue-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-number {
|
||||
.issue-number {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bug-title {
|
||||
.issue-title {
|
||||
flex: 1;
|
||||
}
|
||||
.bug-title {
|
||||
.issue-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bug-status {
|
||||
.issue-status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -81,75 +81,75 @@
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bug-status.open {
|
||||
.issue-status.open {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bug-status.closed {
|
||||
.issue-status.closed {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
.bug-desc {
|
||||
.issue-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-meta {
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bug-dot {
|
||||
.issue-dot {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.bug-detail-nav {
|
||||
.issue-detail-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bug-back,
|
||||
.bug-gitea-link {
|
||||
.issue-back,
|
||||
.issue-gitea-link {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.bug-detail-card {
|
||||
.issue-detail-card {
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.bug-detail-header {
|
||||
.issue-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bug-detail-title {
|
||||
.issue-detail-title {
|
||||
flex: 1;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bug-admin-actions {
|
||||
.issue-admin-actions {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.bug-detail-body {
|
||||
.issue-detail-body {
|
||||
margin-top: 1rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.bug-comment {
|
||||
.issue-comment {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bug-comment-head {
|
||||
.issue-comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -157,7 +157,7 @@
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-comment-badge {
|
||||
.issue-comment-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -167,15 +167,15 @@
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bug-comment-body {
|
||||
.issue-comment-body {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.bug-comment-form {
|
||||
.issue-comment-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.bug-comment-form textarea {
|
||||
.issue-comment-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
@@ -185,28 +185,28 @@
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
.bug-comment-form-footer {
|
||||
.issue-comment-form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bugs-header {
|
||||
.issues-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bugs-header h1 {
|
||||
.issues-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.bug-card {
|
||||
.issue-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bug-card-header {
|
||||
.issue-card-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.page-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messages-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 320px) 1fr;
|
||||
gap: 0;
|
||||
height: calc(100dvh - var(--nav-height) - 3.5rem);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -16,6 +24,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-list-header {
|
||||
@@ -58,8 +67,16 @@
|
||||
}
|
||||
|
||||
.messages-conversations {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.messages-conversations::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
@@ -176,6 +193,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.messages-input-area input[type="text"] {
|
||||
@@ -248,8 +266,7 @@
|
||||
@media (max-width: 768px) {
|
||||
.messages-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: calc(100dvh - var(--nav-height) - 3.5rem);
|
||||
min-height: 0;
|
||||
}
|
||||
.messages-list {
|
||||
display: flex;
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
@@ -135,7 +137,8 @@ a.profile-stat-value:hover {
|
||||
.profile-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -146,16 +149,27 @@ a.profile-stat-value:hover {
|
||||
|
||||
.profile-info-label {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-info-row > .flex-center-gap {
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-info-value {
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.profile-info-edit {
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-info-edit:hover {
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { ApiTester } from "./ApiTester.js";
|
||||
import { CodeCopy } from "./CodeCopy.js";
|
||||
import { DevRantTester, DevRantLogin } from "./DevRantTester.js";
|
||||
|
||||
export class ApiDocs {
|
||||
constructor() {
|
||||
this.copy = new CodeCopy();
|
||||
const mounts = document.querySelectorAll("[data-api-tester]");
|
||||
if (!mounts.length) return;
|
||||
const ctx = window.DEVPLACE_DOCS || { loggedIn: false, username: "", apiKey: "", isAdmin: false };
|
||||
ctx.base = location.origin;
|
||||
const mounts = document.querySelectorAll("[data-api-tester]");
|
||||
this.testers = [...mounts].map((mount) => new ApiTester(mount, ctx));
|
||||
this.devrantLogins = [...document.querySelectorAll("[data-devrant-login]")].map(
|
||||
(mount) => new DevRantLogin(mount),
|
||||
);
|
||||
this.devrantTesters = [...document.querySelectorAll("[data-devrant-tester]")].map(
|
||||
(mount) => new DevRantTester(mount),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Tabs } from "./Tabs.js";
|
||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
import { ZipDownloader } from "./ZipDownloader.js";
|
||||
import { ProjectForker } from "./ProjectForker.js";
|
||||
import { BugReporter } from "./BugReporter.js";
|
||||
import { IssueReporter } from "./IssueReporter.js";
|
||||
import { MediaGallery } from "./MediaGallery.js";
|
||||
import WindowManager from "./components/WindowManager.js";
|
||||
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
||||
@@ -65,7 +65,7 @@ class Application {
|
||||
this.devii = new DeviiTerminal();
|
||||
this.zipDownloader = new ZipDownloader();
|
||||
this.projectForker = new ProjectForker();
|
||||
this.bugReporter = new BugReporter();
|
||||
this.issueReporter = new IssueReporter();
|
||||
this.mediaGallery = new MediaGallery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const STORAGE_KEY = "devrant-docs-auth";
|
||||
|
||||
class DevRantSession {
|
||||
constructor() {
|
||||
const docs = window.DEVPLACE_DOCS || {};
|
||||
this.base = docs.base || location.origin;
|
||||
this.auth = null;
|
||||
this.username = "";
|
||||
this.listeners = new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
this.auth = parsed.auth || null;
|
||||
this.username = parsed.username || "";
|
||||
}
|
||||
} catch {
|
||||
this.auth = null;
|
||||
}
|
||||
}
|
||||
|
||||
isLoggedIn() {
|
||||
return Boolean(this.auth && this.auth.token_key);
|
||||
}
|
||||
|
||||
triple() {
|
||||
return this.isLoggedIn() ? { ...this.auth } : {};
|
||||
}
|
||||
|
||||
onChange(callback) {
|
||||
this.listeners.add(callback);
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
_emit() {
|
||||
for (const callback of this.listeners) callback();
|
||||
}
|
||||
|
||||
_persist() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ auth: this.auth, username: this.username }),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFromToken(token, username) {
|
||||
this.auth = {
|
||||
user_id: token.user_id,
|
||||
token_id: token.id,
|
||||
token_key: token.key,
|
||||
};
|
||||
if (username) this.username = username;
|
||||
this._persist();
|
||||
this._emit();
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
const response = await fetch(`${this.base}/api/users/auth-token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({ username, password }).toString(),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.error || "login failed");
|
||||
this.setFromToken(data.auth_token, username);
|
||||
return this.auth;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.auth = null;
|
||||
this.username = "";
|
||||
this._persist();
|
||||
this._emit();
|
||||
}
|
||||
}
|
||||
|
||||
export const devrantSession = new DevRantSession();
|
||||
@@ -0,0 +1,315 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { CodeBlock } from "./CodeBlock.js";
|
||||
import { devrantSession } from "./DevRantSession.js";
|
||||
|
||||
function el(tag, props = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (key === "class") node.className = value;
|
||||
else if (key === "text") node.textContent = value;
|
||||
else if (key === "html") node.innerHTML = value;
|
||||
else if (value === true) node.setAttribute(key, "");
|
||||
else if (value !== false && value !== null) node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children) {
|
||||
if (child) node.appendChild(child);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export class DevRantLogin {
|
||||
constructor(mount) {
|
||||
this.mount = mount;
|
||||
this.render();
|
||||
devrantSession.onChange(() => this.render());
|
||||
}
|
||||
|
||||
render() {
|
||||
this.mount.innerHTML = "";
|
||||
const bar = el("div", { class: "api-tester devrant-login" });
|
||||
if (devrantSession.isLoggedIn()) {
|
||||
bar.appendChild(
|
||||
el("span", {
|
||||
class: "try-note",
|
||||
text: `Authenticated as ${devrantSession.username || "user " + devrantSession.auth.user_id} (token #${devrantSession.auth.token_id}).`,
|
||||
}),
|
||||
);
|
||||
const out = el("button", { type: "button", class: "btn try-send", text: "Log out" });
|
||||
out.addEventListener("click", () => devrantSession.logout());
|
||||
bar.appendChild(out);
|
||||
this.mount.appendChild(bar);
|
||||
return;
|
||||
}
|
||||
const user = el("input", { class: "param-input", type: "text", placeholder: "username or email" });
|
||||
const pass = el("input", { class: "param-input", type: "password", placeholder: "password" });
|
||||
const docs = window.DEVPLACE_DOCS || {};
|
||||
if (docs.username) user.value = docs.username;
|
||||
const send = el("button", { type: "button", class: "btn btn-primary try-send", text: "Log in" });
|
||||
const note = el("span", { class: "try-note", text: "Log in once to enable the authenticated widgets on every devRant page." });
|
||||
send.addEventListener("click", async () => {
|
||||
send.disabled = true;
|
||||
note.textContent = "Logging in...";
|
||||
try {
|
||||
await devrantSession.login(user.value.trim(), pass.value);
|
||||
} catch (error) {
|
||||
note.textContent = error.message || "Login failed";
|
||||
send.disabled = false;
|
||||
}
|
||||
});
|
||||
bar.appendChild(el("div", { class: "auth-field" }, [el("label", { text: "Username" }), user]));
|
||||
bar.appendChild(el("div", { class: "auth-field" }, [el("label", { text: "Password" }), pass]));
|
||||
bar.appendChild(send);
|
||||
bar.appendChild(note);
|
||||
this.mount.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
export class DevRantTester {
|
||||
constructor(mount) {
|
||||
this.mount = mount;
|
||||
try {
|
||||
this.config = JSON.parse(mount.dataset.config || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.base = (window.DEVPLACE_DOCS || {}).base || location.origin;
|
||||
this.inputs = [];
|
||||
this.build();
|
||||
this.renderExpected();
|
||||
if (this.requiresAuth()) {
|
||||
devrantSession.onChange(() => this.syncAuth());
|
||||
this.syncAuth();
|
||||
}
|
||||
}
|
||||
|
||||
requiresAuth() {
|
||||
return this.config.auth === "user";
|
||||
}
|
||||
|
||||
build() {
|
||||
const root = el("div", { class: "api-tester" });
|
||||
if (this.config.params.length) root.appendChild(this.buildParams());
|
||||
if (this.requiresAuth()) {
|
||||
this.authNote = el("div", { class: "param-desc devrant-auth-note" });
|
||||
root.appendChild(this.authNote);
|
||||
}
|
||||
root.appendChild(this.buildActions());
|
||||
root.appendChild(this.buildResponse());
|
||||
this.mount.appendChild(root);
|
||||
}
|
||||
|
||||
buildParams() {
|
||||
const table = el("div", { class: "param-table" });
|
||||
for (const param of this.config.params) {
|
||||
const control = this.buildControl(param);
|
||||
this.inputs.push({ param, control });
|
||||
const label = el("div", { class: "param-label" }, [
|
||||
el("span", { class: "param-name", text: param.name }),
|
||||
param.required ? el("span", { class: "param-required", text: "*" }) : null,
|
||||
el("span", { class: "param-loc param-loc-" + param.location, text: param.location }),
|
||||
]);
|
||||
const meta = el("div", { class: "param-meta" }, [
|
||||
control,
|
||||
param.description ? el("div", { class: "param-desc", text: param.description }) : null,
|
||||
]);
|
||||
table.appendChild(el("div", { class: "param-row" }, [label, meta]));
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
buildControl(param) {
|
||||
if (param.type === "enum") {
|
||||
const select = el("select", { class: "param-input" });
|
||||
for (const option of param.options || []) {
|
||||
const opt = el("option", { value: option, text: option });
|
||||
if (option === param.example) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
if (param.type === "textarea") {
|
||||
const area = el("textarea", { class: "param-input", rows: "3" });
|
||||
area.value = param.example || "";
|
||||
return area;
|
||||
}
|
||||
const input = el("input", {
|
||||
class: "param-input",
|
||||
type: param.type === "int" ? "number" : param.type === "password" ? "password" : "text",
|
||||
placeholder: param.example || "",
|
||||
});
|
||||
if (param.type !== "password") input.value = param.example || "";
|
||||
return input;
|
||||
}
|
||||
|
||||
buildActions() {
|
||||
const bar = el("div", { class: "try-panel" });
|
||||
this.sendBtn = el("button", { type: "button", class: "btn btn-primary try-send", text: "Send request" });
|
||||
this.sendBtn.addEventListener("click", () => this.send());
|
||||
bar.appendChild(this.sendBtn);
|
||||
return bar;
|
||||
}
|
||||
|
||||
syncAuth() {
|
||||
if (!this.authNote) return;
|
||||
if (devrantSession.isLoggedIn()) {
|
||||
this.authNote.textContent = `Authenticated as ${devrantSession.username || "user " + devrantSession.auth.user_id}.`;
|
||||
this.sendBtn.disabled = false;
|
||||
} else {
|
||||
this.authNote.textContent = "Requires login - use the Log in widget at the top of the page.";
|
||||
this.sendBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
buildResponse() {
|
||||
const wrap = el("div", { class: "response-viewer" });
|
||||
const tabs = el("div", { class: "response-tabs" });
|
||||
this.responseTabs = {};
|
||||
for (const tab of [{ id: "expected", label: "Expected" }, { id: "live", label: "Live response" }]) {
|
||||
const btn = el("button", { type: "button", class: "code-tab", text: tab.label });
|
||||
btn.addEventListener("click", () => this.selectTab(tab.id));
|
||||
this.responseTabs[tab.id] = btn;
|
||||
tabs.appendChild(btn);
|
||||
}
|
||||
this.expectedEl = el("div", { class: "response-pane" });
|
||||
this.liveEl = el("div", { class: "response-pane" });
|
||||
this.liveEl.appendChild(el("p", { class: "response-placeholder", text: "Run the request to see the live response." }));
|
||||
wrap.appendChild(tabs);
|
||||
wrap.appendChild(this.expectedEl);
|
||||
wrap.appendChild(this.liveEl);
|
||||
this.selectTab("expected");
|
||||
return wrap;
|
||||
}
|
||||
|
||||
selectTab(id) {
|
||||
for (const [name, btn] of Object.entries(this.responseTabs)) {
|
||||
btn.classList.toggle("active", name === id);
|
||||
}
|
||||
this.expectedEl.classList.toggle("active", id === "expected");
|
||||
this.liveEl.classList.toggle("active", id === "live");
|
||||
}
|
||||
|
||||
renderExpected() {
|
||||
const sample = this.config.sample_response;
|
||||
if (sample === null || sample === undefined) {
|
||||
this.expectedEl.appendChild(el("p", { class: "response-note", text: "No documented response body." }));
|
||||
return;
|
||||
}
|
||||
this.expectedEl.appendChild(el("p", { class: "response-note", text: "Modeled JSON response:" }));
|
||||
const pre = el("pre", { class: "response-body" });
|
||||
pre.appendChild(el("code", { class: "language-json", text: JSON.stringify(sample, null, 2) }));
|
||||
this.expectedEl.appendChild(pre);
|
||||
CodeBlock.enhance(pre, { lineNumbers: false });
|
||||
}
|
||||
|
||||
values() {
|
||||
const out = { path: {}, query: {}, body: {} };
|
||||
for (const { param, control } of this.inputs) {
|
||||
const value = control.value;
|
||||
if (value === "" && !param.required) continue;
|
||||
out[param.location][param.name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
buildUrl(values) {
|
||||
let path = this.config.path;
|
||||
for (const [name, value] of Object.entries(values.path)) {
|
||||
path = path.replace("{" + name + "}", encodeURIComponent(value));
|
||||
}
|
||||
const query = { ...values.query };
|
||||
const usesQuery = this.config.method === "GET" || this.config.method === "DELETE";
|
||||
if (usesQuery && this.requiresAuth()) Object.assign(query, devrantSession.triple());
|
||||
const entries = Object.entries(query);
|
||||
const qs = entries.length
|
||||
? "?" + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&")
|
||||
: "";
|
||||
return this.base + path + qs;
|
||||
}
|
||||
|
||||
async confirmDestructive() {
|
||||
if (!(this.config.destructive || this.config.method === "DELETE")) return true;
|
||||
if (window.app && window.app.dialog) {
|
||||
return window.app.dialog.confirm({
|
||||
title: "Run request",
|
||||
message: "This performs a real action on your account. Continue?",
|
||||
confirmLabel: "Run",
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
return window.confirm("This performs a real action on your account. Continue?");
|
||||
}
|
||||
|
||||
async send() {
|
||||
if (this.requiresAuth() && !devrantSession.isLoggedIn()) {
|
||||
this.showError("Log in first using the widget at the top of the page.");
|
||||
return;
|
||||
}
|
||||
if (!(await this.confirmDestructive())) return;
|
||||
const values = this.values();
|
||||
const url = this.buildUrl(values);
|
||||
const headers = { Accept: "application/json" };
|
||||
const options = { method: this.config.method, headers };
|
||||
if (this.config.encoding === "form") {
|
||||
const body = { ...values.body };
|
||||
if (this.requiresAuth()) Object.assign(body, devrantSession.triple());
|
||||
options.body = new URLSearchParams(body).toString();
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
}
|
||||
this.sendBtn.disabled = true;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const ms = Math.round(performance.now() - started);
|
||||
await this.renderResponse(response, ms, values);
|
||||
} catch (error) {
|
||||
this.showError(error.message || "Network error");
|
||||
} finally {
|
||||
this.sendBtn.disabled = false;
|
||||
if (this.requiresAuth()) this.syncAuth();
|
||||
}
|
||||
}
|
||||
|
||||
async renderResponse(response, ms, values) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
this.liveEl.innerHTML = "";
|
||||
this.selectTab("live");
|
||||
const cls = "response-" + Math.floor(response.status / 100) + "xx";
|
||||
const meta = el("div", { class: "response-meta" }, [
|
||||
el("span", { class: "response-status " + cls, text: `${response.status} ${response.statusText}` }),
|
||||
el("span", { text: `${ms} ms` }),
|
||||
el("span", { text: contentType.split(";")[0] || "unknown" }),
|
||||
]);
|
||||
this.liveEl.appendChild(meta);
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = await response.json();
|
||||
this.maybeCaptureLogin(data, values);
|
||||
const pre = el("pre", { class: "response-body" });
|
||||
pre.appendChild(el("code", { class: "language-json", text: JSON.stringify(data, null, 2) }));
|
||||
this.liveEl.appendChild(pre);
|
||||
CodeBlock.enhance(pre, { lineNumbers: false });
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
const pre = el("pre", { class: "response-body" });
|
||||
pre.appendChild(el("code", { text: text.slice(0, 2000) }));
|
||||
this.liveEl.appendChild(pre);
|
||||
CodeBlock.enhance(pre, { highlight: false, lineNumbers: false });
|
||||
}
|
||||
|
||||
maybeCaptureLogin(data, values) {
|
||||
const loginIds = ["devrant-login", "devrant-register"];
|
||||
if (loginIds.includes(this.config.id) && data && data.success && data.auth_token) {
|
||||
devrantSession.setFromToken(data.auth_token, values.body.username || "");
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.liveEl.innerHTML = "";
|
||||
this.selectTab("live");
|
||||
this.liveEl.appendChild(el("div", { class: "response-meta" }, [
|
||||
el("span", { class: "response-status response-5xx", text: "Error" }),
|
||||
]));
|
||||
this.liveEl.appendChild(el("p", { class: "response-redirect", text: message }));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { TextInput } from "./TextInput.js";
|
||||
import { DomUtils } from "./DomUtils.js";
|
||||
import { assetUrl } from "./assetVersion.js";
|
||||
|
||||
const EMOJI_DATA_SOURCE = assetUrl("/static/vendor/emoji-picker-element/data.json");
|
||||
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
@@ -21,6 +24,7 @@ export class EmojiPicker {
|
||||
this.wrapper.style.zIndex = "2000";
|
||||
|
||||
const picker = document.createElement("emoji-picker");
|
||||
picker.setAttribute("data-source", EMOJI_DATA_SOURCE);
|
||||
picker.style.width = "320px";
|
||||
picker.style.height = "350px";
|
||||
this.wrapper.appendChild(picker);
|
||||
|
||||
@@ -57,6 +57,7 @@ export class FormManager {
|
||||
const btn = form.querySelector("button[type='submit']");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("is-loading");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { Http } from "./Http.js";
|
||||
import { JobPoller } from "./JobPoller.js";
|
||||
|
||||
export class BugReporter {
|
||||
export class IssueReporter {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("form[data-bug-create]");
|
||||
const form = event.target.closest("form[data-issue-create]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
this.submit(form);
|
||||
@@ -24,7 +24,10 @@ export class BugReporter {
|
||||
}
|
||||
this.active = true;
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (button) button.disabled = true;
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.classList.add("is-loading");
|
||||
}
|
||||
window.app.toast.show("Filing your report and improving it with AI...", {
|
||||
type: "info",
|
||||
ms: 5000,
|
||||
@@ -36,15 +39,18 @@ export class BugReporter {
|
||||
window.app.toast.show("Could not submit the report", { type: "error" });
|
||||
} finally {
|
||||
this.active = false;
|
||||
if (button) button.disabled = false;
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-loading");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll(statusUrl) {
|
||||
return JobPoller.run(statusUrl, {
|
||||
onDone: (status) => {
|
||||
window.app.toast.show("Bug report filed", { type: "success" });
|
||||
window.location.href = status.bug_url || "/bugs";
|
||||
window.app.toast.show("Issue report filed", { type: "success" });
|
||||
window.location.href = status.issue_url || "/issues";
|
||||
},
|
||||
onFailed: (status) => {
|
||||
window.app.toast.show(
|
||||
@@ -8,13 +8,6 @@ export class MessagesLayout {
|
||||
}
|
||||
this.thread = document.querySelector(".messages-thread");
|
||||
this.input = document.querySelector('.messages-input-area input[name="content"]');
|
||||
this.stackBreakpoint = 768;
|
||||
this.minHeight = 320;
|
||||
this.resize = this.resize.bind(this);
|
||||
|
||||
this.resize();
|
||||
window.addEventListener("resize", this.resize);
|
||||
window.addEventListener("orientationchange", this.resize);
|
||||
|
||||
this.scrollThreadToEnd();
|
||||
if (this.input) {
|
||||
@@ -22,18 +15,6 @@ export class MessagesLayout {
|
||||
}
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (window.innerWidth <= this.stackBreakpoint) {
|
||||
this.layout.style.height = "";
|
||||
return;
|
||||
}
|
||||
const top = this.layout.getBoundingClientRect().top;
|
||||
const page = this.layout.closest(".page");
|
||||
const bottomPadding = page ? parseFloat(getComputedStyle(page).paddingBottom) || 0 : 0;
|
||||
const available = window.innerHeight - top - bottomPadding;
|
||||
this.layout.style.height = `${Math.max(available, this.minHeight)}px`;
|
||||
}
|
||||
|
||||
scrollThreadToEnd() {
|
||||
if (this.thread) {
|
||||
this.thread.scrollTop = this.thread.scrollHeight;
|
||||
|
||||
@@ -15,4 +15,18 @@ export class OptimisticAction {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async submitOptimistic(url, params, errorTarget, apply, revert, reconcile) {
|
||||
apply();
|
||||
try {
|
||||
const result = await Http.sendForm(url, params);
|
||||
if (reconcile) reconcile(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("optimistic action failed", error);
|
||||
revert();
|
||||
if (errorTarget) Toast.flash(errorTarget, "Error", 1500);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,33 @@ export class VoteManager extends OptimisticAction {
|
||||
|
||||
cast(form, button) {
|
||||
const action = form.getAttribute("action");
|
||||
const value = form.querySelector('input[name="value"]').value;
|
||||
return this.submit(action, { value }, button, (result) => this.render(action, result));
|
||||
const clickedValue = parseInt(form.querySelector('input[name="value"]').value, 10);
|
||||
const targetUid = action.split("/").pop();
|
||||
const prevValue = this.currentValue(action);
|
||||
const prevNet = this.currentNet(targetUid);
|
||||
const newValue = prevValue === clickedValue ? 0 : clickedValue;
|
||||
const predictedNet = prevNet + (newValue - prevValue);
|
||||
return this.submitOptimistic(
|
||||
action,
|
||||
{ value: clickedValue },
|
||||
button,
|
||||
() => this.render(action, { net: predictedNet, value: newValue }),
|
||||
() => this.render(action, { net: prevNet, value: prevValue }),
|
||||
(result) => this.render(action, result),
|
||||
);
|
||||
}
|
||||
|
||||
currentValue(action) {
|
||||
const voted = document.querySelector(`form[action="${action}"] button.voted`);
|
||||
if (!voted) return 0;
|
||||
return parseInt(voted.closest("form").querySelector('input[name="value"]').value, 10);
|
||||
}
|
||||
|
||||
currentNet(targetUid) {
|
||||
const counter = document.querySelector(`[data-vote-count="${targetUid}"]`);
|
||||
if (!counter) return 0;
|
||||
const parsed = parseInt(counter.textContent, 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
render(action, result) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@ const MODE_READWRITE = 'readwrite';
|
||||
const INDEX_SKIN_UNICODE = 'skinUnicodes';
|
||||
const FIELD_SKIN_UNICODE = 'skinUnicodes';
|
||||
|
||||
const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
|
||||
const DEFAULT_DATA_SOURCE = '/static/vendor/emoji-picker-element/data.json';
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
|
||||
// like lodash's uniqBy but much smaller
|
||||
|
||||
+1
-1
@@ -1646,7 +1646,7 @@ function createRoot (shadowRoot, props) {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json';
|
||||
const DEFAULT_DATA_SOURCE = '/static/vendor/emoji-picker-element/data.json';
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
|
||||
var enI18n = {
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
"""
|
||||
Stealth HTTP client built on top of httpx that reproduces the network
|
||||
behaviour of a modern Google Chrome desktop browser as faithfully as a
|
||||
pure-Python stack permits.
|
||||
|
||||
WHY THIS IS THE BEST PRACTICAL STEALTH CLIENT
|
||||
---------------------------------------------
|
||||
Anti-bot platforms (Cloudflare, Akamai, DataDome, PerimeterX, Imperva and
|
||||
the like) classify clients on three independent layers. A request only
|
||||
looks "human" when all three agree with one another. Most scraping code
|
||||
fails because it spoofs a single layer (usually just the User-Agent string)
|
||||
while leaving the others screaming "automated tool". This client aligns
|
||||
every layer that the standard library exposes:
|
||||
|
||||
1. TLS layer (JA3 / cipher fingerprint)
|
||||
The SSL context is configured with a Chrome-aligned TLS 1.2 cipher list,
|
||||
TLS 1.2 as the floor, TLS 1.3 negotiation, and an ALPN advertisement of
|
||||
``h2`` before ``http/1.1`` exactly like Chrome. This moves the JA3 hash
|
||||
away from the default Python/OpenSSL fingerprint that detection vendors
|
||||
blocklist on sight. Note that OpenSSL does not let Python order the TLS 1.3
|
||||
ciphersuites, nor rewrite the supported-groups / signature-algorithm lists,
|
||||
so this is Chrome-*aligned* rather than byte-identical (see HONEST LIMITATIONS).
|
||||
|
||||
2. HTTP/2 layer (frame and header behaviour)
|
||||
Real Chrome speaks HTTP/2 to virtually every modern host. This client
|
||||
enables HTTP/2 by default, sends lowercase header names, and preserves a
|
||||
Chrome-accurate header *order* - the order itself is a fingerprint that
|
||||
naive clients get wrong even when the header values are correct.
|
||||
|
||||
3. Application layer (headers and client hints)
|
||||
A complete, correctly ordered set of Chrome headers is emitted, including
|
||||
the modern User-Agent Client Hints (``sec-ch-ua``, ``sec-ch-ua-mobile``,
|
||||
``sec-ch-ua-platform``) and the request-context ``Sec-Fetch-*`` family.
|
||||
The ``Sec-Fetch-*`` values are recomputed per request type so a document
|
||||
navigation, a sub-resource fetch and a file download each carry the
|
||||
metadata Chrome would actually attach in that situation.
|
||||
|
||||
WHAT YOU CAN EXPECT
|
||||
-------------------
|
||||
- Requests that pass the overwhelming majority of header- and TLS-based
|
||||
bot heuristics, including Cloudflare's "I'm Under Attack" passive checks
|
||||
for endpoints that do not mandate a JavaScript challenge.
|
||||
- Transparent HTTP/2, gzip/deflate/br/zstd decompression, cookie
|
||||
persistence across a session, and connection reuse.
|
||||
- Single and bulk asynchronous file downloads with bounded concurrency,
|
||||
streaming to disk (constant memory regardless of file size), filename
|
||||
slugification and path-traversal protection.
|
||||
|
||||
HONEST LIMITATIONS
|
||||
------------------
|
||||
The Python standard ``ssl`` module cannot rewrite the TLS extension order,
|
||||
the supported-groups list or the signature-algorithm list, so the JA3 hash
|
||||
produced here is *Chrome-like* rather than *byte-identical* to Chrome. For
|
||||
targets that fingerprint those low-level extension fields (a small minority,
|
||||
but a growing one) a native TLS stack such as ``curl_cffi`` or BoringSSL is
|
||||
required. This client is engineered to be the strongest stealth achievable
|
||||
without leaving the httpx ecosystem, and it degrades gracefully: every layer
|
||||
it can control is made indistinguishable from Chrome.
|
||||
|
||||
USE CASES
|
||||
---------
|
||||
- Resilient web scraping and data collection against header/TLS gating.
|
||||
- Monitoring, price tracking and availability checks on protected sites.
|
||||
- Mirroring and bulk asset downloading (images, documents, archives).
|
||||
- Integration testing of CDN and WAF rules from a realistic client.
|
||||
|
||||
EXAMPLE
|
||||
-------
|
||||
import asyncio
|
||||
from devplacepy.stealth import ChromeStealthClient
|
||||
|
||||
async def main() -> None:
|
||||
async with ChromeStealthClient() as client:
|
||||
response = await client.get("https://example.com")
|
||||
print(response.status_code, len(response.text))
|
||||
|
||||
results = await client.download_files(
|
||||
[
|
||||
"https://example.com/a.jpg",
|
||||
"https://example.com/b.pdf",
|
||||
],
|
||||
destination="downloads",
|
||||
concurrency=4,
|
||||
)
|
||||
for result in results:
|
||||
print(result.url, result.ok, result.path)
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("stealth")
|
||||
|
||||
|
||||
def _supported_accept_encoding() -> str:
|
||||
encodings = ["gzip", "deflate"]
|
||||
try:
|
||||
import brotli # noqa: F401
|
||||
encodings.append("br")
|
||||
except ImportError:
|
||||
logger.debug("brotli not installed; not advertising 'br'")
|
||||
try:
|
||||
import zstandard # noqa: F401
|
||||
encodings.append("zstd")
|
||||
except ImportError:
|
||||
logger.debug("zstandard not installed; not advertising 'zstd'")
|
||||
return ", ".join(encodings)
|
||||
|
||||
|
||||
ACCEPT_ENCODING: str = _supported_accept_encoding()
|
||||
|
||||
|
||||
CHROME_CIPHER_SUITES: str = ":".join(
|
||||
[
|
||||
"TLS_AES_128_GCM_SHA256",
|
||||
"TLS_AES_256_GCM_SHA384",
|
||||
"TLS_CHACHA20_POLY1305_SHA256",
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
"ECDHE-RSA-AES128-GCM-SHA256",
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
"ECDHE-RSA-AES256-GCM-SHA384",
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
"ECDHE-RSA-CHACHA20-POLY1305",
|
||||
"ECDHE-RSA-AES128-SHA",
|
||||
"ECDHE-RSA-AES256-SHA",
|
||||
"AES128-GCM-SHA256",
|
||||
"AES256-GCM-SHA384",
|
||||
"AES128-SHA",
|
||||
"AES256-SHA",
|
||||
]
|
||||
)
|
||||
|
||||
ALPN_PROTOCOLS: tuple[str, ...] = ("h2", "http/1.1")
|
||||
|
||||
DEFAULT_CHROME_VERSION: str = "149"
|
||||
|
||||
DEFAULT_CHUNK_SIZE: int = 65536
|
||||
|
||||
IN_MEMORY_DOWNLOAD_CAP: int = 100 * 1024 * 1024
|
||||
|
||||
IMAGE_EXTENSIONS: tuple[str, ...] = (
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".bmp",
|
||||
".svg",
|
||||
".ico",
|
||||
".tiff",
|
||||
)
|
||||
|
||||
MAX_FILENAME_LENGTH: int = 200
|
||||
|
||||
MAX_CONCURRENT_DOWNLOADS: int = 64
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChromeProfile:
|
||||
version: str = DEFAULT_CHROME_VERSION
|
||||
platform: str = "Windows"
|
||||
platform_token: str = "Windows NT 10.0; Win64; x64"
|
||||
accept_language: str = "en-US,en;q=0.9"
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
return (
|
||||
f"Mozilla/5.0 ({self.platform_token}) AppleWebKit/537.36 "
|
||||
f"(KHTML, like Gecko) Chrome/{self.version}.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
@property
|
||||
def sec_ch_ua(self) -> str:
|
||||
return (
|
||||
f'"Google Chrome";v="{self.version}", '
|
||||
f'"Chromium";v="{self.version}", '
|
||||
f'"Not)A;Brand";v="24"'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def windows(cls, version: str = DEFAULT_CHROME_VERSION) -> "ChromeProfile":
|
||||
return cls(version=version, platform="Windows", platform_token="Windows NT 10.0; Win64; x64")
|
||||
|
||||
@classmethod
|
||||
def macos(cls, version: str = DEFAULT_CHROME_VERSION) -> "ChromeProfile":
|
||||
return cls(
|
||||
version=version,
|
||||
platform="macOS",
|
||||
platform_token="Macintosh; Intel Mac OS X 10_15_7",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def linux(cls, version: str = DEFAULT_CHROME_VERSION) -> "ChromeProfile":
|
||||
return cls(version=version, platform="Linux", platform_token="X11; Linux x86_64")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
url: str
|
||||
path: Path | None
|
||||
status_code: int | None
|
||||
bytes_written: int
|
||||
ok: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BytesResult:
|
||||
url: str
|
||||
final_url: str
|
||||
status_code: int
|
||||
content_type: str
|
||||
data: bytes
|
||||
truncated: bool
|
||||
error: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"url": self.url,
|
||||
"final_url": self.final_url,
|
||||
"status_code": self.status_code,
|
||||
"content_type": self.content_type,
|
||||
"data": self.data,
|
||||
"truncated": self.truncated,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
def resource_kind_for_url(url: str) -> str:
|
||||
path = urlsplit(url).path.lower()
|
||||
return "image" if path.endswith(IMAGE_EXTENSIONS) else "empty"
|
||||
|
||||
|
||||
def build_chrome_ssl_context() -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = True
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
||||
context.set_ciphers(CHROME_CIPHER_SUITES)
|
||||
context.set_alpn_protocols(list(ALPN_PROTOCOLS))
|
||||
context.options |= ssl.OP_NO_COMPRESSION
|
||||
# Do NOT pin a single ECDH curve: Chrome advertises several groups
|
||||
# (X25519, P-256, P-384, plus an MLKEM hybrid), and pinning to one both
|
||||
# narrows the supported-groups fingerprint and breaks the handshake with
|
||||
# servers that lack that curve. Let OpenSSL advertise its default group set.
|
||||
logger.debug("Built Chrome-aligned SSL context with %d ciphers", CHROME_CIPHER_SUITES.count(":") + 1)
|
||||
return context
|
||||
|
||||
|
||||
_SHARED_SSL_CONTEXT: ssl.SSLContext | None = None
|
||||
|
||||
DEFAULT_PROFILE: ChromeProfile = ChromeProfile.windows()
|
||||
|
||||
try:
|
||||
from devplacepy.curl_transport import CurlTransport, IMPERSONATE_TARGET
|
||||
|
||||
CURL_AVAILABLE: bool = True
|
||||
logger.info("Stealth transport: curl_cffi impersonation active (%s)", IMPERSONATE_TARGET)
|
||||
except ImportError as error:
|
||||
CurlTransport = None
|
||||
IMPERSONATE_TARGET = ""
|
||||
CURL_AVAILABLE = False
|
||||
logger.warning("curl_cffi unavailable (%s); falling back to httpx stealth transport", error)
|
||||
|
||||
|
||||
def curl_active() -> bool:
|
||||
return CURL_AVAILABLE
|
||||
|
||||
|
||||
def chrome_ssl_context() -> ssl.SSLContext:
|
||||
global _SHARED_SSL_CONTEXT
|
||||
if _SHARED_SSL_CONTEXT is None:
|
||||
_SHARED_SSL_CONTEXT = build_chrome_ssl_context()
|
||||
return _SHARED_SSL_CONTEXT
|
||||
|
||||
|
||||
def chrome_base_headers(profile: ChromeProfile | None = None) -> dict[str, str]:
|
||||
active = profile or DEFAULT_PROFILE
|
||||
return {
|
||||
"sec-ch-ua": active.sec_ch_ua,
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": f'"{active.platform}"',
|
||||
"user-agent": active.user_agent,
|
||||
"accept-encoding": ACCEPT_ENCODING,
|
||||
"accept-language": active.accept_language,
|
||||
}
|
||||
|
||||
|
||||
def stealth_transport(
|
||||
profile: ChromeProfile | None = None,
|
||||
**kwargs: object,
|
||||
) -> httpx.AsyncBaseTransport:
|
||||
if CURL_AVAILABLE:
|
||||
return CurlTransport()
|
||||
return httpx.AsyncHTTPTransport(verify=chrome_ssl_context(), http2=True, **kwargs)
|
||||
|
||||
|
||||
def stealth_async_client(
|
||||
*,
|
||||
profile: ChromeProfile | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
**kwargs: object,
|
||||
) -> httpx.AsyncClient:
|
||||
merged_headers: dict[str, str] = {} if CURL_AVAILABLE else chrome_base_headers(profile)
|
||||
if headers:
|
||||
merged_headers.update({key.lower(): value for key, value in headers.items()})
|
||||
if transport is None:
|
||||
transport = stealth_transport(profile)
|
||||
return httpx.AsyncClient(
|
||||
transport=transport,
|
||||
trust_env=True,
|
||||
headers=merged_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def stealth_sync_client(
|
||||
*,
|
||||
profile: ChromeProfile | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
**kwargs: object,
|
||||
) -> httpx.Client:
|
||||
merged_headers = chrome_base_headers(profile)
|
||||
if headers:
|
||||
merged_headers.update({key.lower(): value for key, value in headers.items()})
|
||||
return httpx.Client(
|
||||
http2=True,
|
||||
verify=chrome_ssl_context(),
|
||||
trust_env=True,
|
||||
headers=merged_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def slugify_filename(name: str) -> str:
|
||||
name = unquote(name).strip()
|
||||
name = name.replace("\\", "/").split("/")[-1]
|
||||
name = name.split("?")[0].split("#")[0]
|
||||
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-._")
|
||||
if not name:
|
||||
name = "download"
|
||||
if len(name) > MAX_FILENAME_LENGTH:
|
||||
stem, _, suffix = name.rpartition(".")
|
||||
if stem and len(suffix) <= 16:
|
||||
keep = MAX_FILENAME_LENGTH - len(suffix) - 1
|
||||
name = f"{stem[:keep]}.{suffix}"
|
||||
else:
|
||||
name = name[:MAX_FILENAME_LENGTH]
|
||||
return name
|
||||
|
||||
|
||||
def resolve_destination(directory: Path, url: str, override_name: str | None) -> Path:
|
||||
directory = directory.resolve()
|
||||
raw_name = override_name if override_name else Path(urlsplit(url).path).name
|
||||
safe_name = slugify_filename(raw_name)
|
||||
candidate = (directory / safe_name).resolve()
|
||||
if directory != candidate.parent:
|
||||
raise ValueError(f"Refusing path traversal for {url!r} -> {candidate}")
|
||||
return candidate
|
||||
|
||||
|
||||
class ChromeStealthClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile: ChromeProfile | None = None,
|
||||
*,
|
||||
http2: bool = True,
|
||||
timeout: float = 30.0,
|
||||
follow_redirects: bool = True,
|
||||
proxy: str | None = None,
|
||||
trust_env: bool = True,
|
||||
max_connections: int = 100,
|
||||
max_keepalive_connections: int = 20,
|
||||
) -> None:
|
||||
self.profile = profile or ChromeProfile.windows()
|
||||
self._ssl_context = build_chrome_ssl_context()
|
||||
limits = httpx.Limits(
|
||||
max_connections=max_connections,
|
||||
max_keepalive_connections=max_keepalive_connections,
|
||||
)
|
||||
self._client = httpx.AsyncClient(
|
||||
http2=http2,
|
||||
verify=self._ssl_context,
|
||||
timeout=httpx.Timeout(timeout),
|
||||
follow_redirects=follow_redirects,
|
||||
limits=limits,
|
||||
proxy=proxy,
|
||||
trust_env=trust_env,
|
||||
headers=self._base_headers(),
|
||||
)
|
||||
logger.info(
|
||||
"ChromeStealthClient ready: Chrome %s on %s, http2=%s, proxy=%s",
|
||||
self.profile.version,
|
||||
self.profile.platform,
|
||||
http2,
|
||||
bool(proxy),
|
||||
)
|
||||
|
||||
def _base_headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"sec-ch-ua": self.profile.sec_ch_ua,
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": f'"{self.profile.platform}"',
|
||||
"user-agent": self.profile.user_agent,
|
||||
"accept-encoding": ACCEPT_ENCODING,
|
||||
"accept-language": self.profile.accept_language,
|
||||
}
|
||||
|
||||
def _navigation_headers(self, referer: str | None) -> dict[str, str]:
|
||||
headers = {
|
||||
"upgrade-insecure-requests": "1",
|
||||
"accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8,"
|
||||
"application/signed-exchange;v=b3;q=0.7"
|
||||
),
|
||||
"sec-fetch-site": "same-origin" if referer else "none",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-user": "?1",
|
||||
"sec-fetch-dest": "document",
|
||||
}
|
||||
if referer:
|
||||
headers["referer"] = referer
|
||||
return headers
|
||||
|
||||
def _resource_headers(self, dest: str, referer: str | None) -> dict[str, str]:
|
||||
accept = {
|
||||
"image": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"empty": "*/*",
|
||||
}.get(dest, "*/*")
|
||||
headers = {
|
||||
"accept": accept,
|
||||
"sec-fetch-site": "same-origin" if referer else "cross-site",
|
||||
"sec-fetch-mode": "no-cors" if dest == "image" else "cors",
|
||||
"sec-fetch-dest": dest,
|
||||
}
|
||||
if referer:
|
||||
headers["referer"] = referer
|
||||
return headers
|
||||
|
||||
def _api_headers(self, referer: str | None) -> dict[str, str]:
|
||||
headers = {
|
||||
"accept": "application/json, text/plain, */*",
|
||||
"sec-fetch-site": "same-origin" if referer else "cross-site",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-dest": "empty",
|
||||
}
|
||||
if referer:
|
||||
headers["referer"] = referer
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _json_or_error(response: httpx.Response) -> dict[str, object]:
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"error": f"HTTP {response.status_code}",
|
||||
"status_code": response.status_code,
|
||||
"body": response.text[:5000],
|
||||
}
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"error": "Invalid JSON response",
|
||||
"status_code": response.status_code,
|
||||
"body": response.text[:5000],
|
||||
}
|
||||
|
||||
async def __aenter__(self) -> "ChromeStealthClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
logger.debug("ChromeStealthClient closed")
|
||||
|
||||
async def _send(self, method: str, url: str, headers: dict[str, str], **kwargs: object) -> httpx.Response:
|
||||
try:
|
||||
return await self._client.request(method, url, headers=headers, **kwargs)
|
||||
except httpx.DecodingError:
|
||||
# Some CDNs emit a br/zstd stream the decoder rejects. Recover the
|
||||
# content by retrying once with only the universally-safe encodings,
|
||||
# rather than failing a site that actually serves a valid body.
|
||||
logger.warning("content decode failed for %s; retrying without br/zstd", url)
|
||||
retry_headers = dict(headers)
|
||||
retry_headers["accept-encoding"] = "gzip, deflate"
|
||||
return await self._client.request(method, url, headers=retry_headers, **kwargs)
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
referer: str | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
**kwargs: object,
|
||||
) -> httpx.Response:
|
||||
merged = self._navigation_headers(referer)
|
||||
if headers:
|
||||
merged.update({key.lower(): value for key, value in headers.items()})
|
||||
logger.info("%s %s", method.upper(), url)
|
||||
response = await self._send(method, url, merged, **kwargs)
|
||||
logger.debug("%s %s -> %s (%s)", method.upper(), url, response.status_code, response.http_version)
|
||||
return response
|
||||
|
||||
async def get(self, url: str, *, referer: str | None = None, **kwargs: object) -> httpx.Response:
|
||||
return await self.request("GET", url, referer=referer, **kwargs)
|
||||
|
||||
async def post(self, url: str, *, referer: str | None = None, **kwargs: object) -> httpx.Response:
|
||||
return await self.request("POST", url, referer=referer, **kwargs)
|
||||
|
||||
def stream(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
referer: str | None = None,
|
||||
dest: str = "empty",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
**kwargs: object,
|
||||
):
|
||||
merged = self._resource_headers(dest, referer)
|
||||
if headers:
|
||||
merged.update({key.lower(): value for key, value in headers.items()})
|
||||
return self._client.stream(method, url, headers=merged, **kwargs)
|
||||
|
||||
async def download_file(
|
||||
self,
|
||||
url: str,
|
||||
destination: str | Path,
|
||||
*,
|
||||
filename: str | None = None,
|
||||
referer: str | None = None,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
dest_type: str = "empty",
|
||||
) -> DownloadResult:
|
||||
directory = Path(destination)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
target = resolve_destination(directory, url, filename)
|
||||
except ValueError as error:
|
||||
logger.error("Destination rejected for %s: %s", url, error)
|
||||
return DownloadResult(url=url, path=None, status_code=None, bytes_written=0, ok=False, error=str(error))
|
||||
|
||||
written = 0
|
||||
try:
|
||||
async with self.stream("GET", url, referer=referer, dest=dest_type) as response:
|
||||
response.raise_for_status()
|
||||
with target.open("wb") as handle:
|
||||
async for chunk in response.aiter_bytes(chunk_size):
|
||||
handle.write(chunk)
|
||||
written += len(chunk)
|
||||
logger.info("Downloaded %s -> %s (%d bytes)", url, target, written)
|
||||
return DownloadResult(
|
||||
url=url,
|
||||
path=target,
|
||||
status_code=response.status_code,
|
||||
bytes_written=written,
|
||||
ok=True,
|
||||
)
|
||||
except httpx.HTTPStatusError as error:
|
||||
logger.error("HTTP %s downloading %s", error.response.status_code, url)
|
||||
return DownloadResult(
|
||||
url=url,
|
||||
path=None,
|
||||
status_code=error.response.status_code,
|
||||
bytes_written=written,
|
||||
ok=False,
|
||||
error=f"HTTP {error.response.status_code}",
|
||||
)
|
||||
except httpx.HTTPError as error:
|
||||
logger.error("Transport error downloading %s: %s", url, error)
|
||||
return DownloadResult(url=url, path=None, status_code=None, bytes_written=written, ok=False, error=str(error))
|
||||
|
||||
async def download_files(
|
||||
self,
|
||||
urls: Iterable[str] | Mapping[str, str],
|
||||
destination: str | Path,
|
||||
*,
|
||||
concurrency: int = 8,
|
||||
referer: str | None = None,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
dest_type: str = "empty",
|
||||
) -> list[DownloadResult]:
|
||||
if isinstance(urls, Mapping):
|
||||
jobs: Sequence[tuple[str, str | None]] = [(url, name) for url, name in urls.items()]
|
||||
else:
|
||||
jobs = [(url, None) for url in urls]
|
||||
|
||||
bounded = max(1, min(concurrency, MAX_CONCURRENT_DOWNLOADS))
|
||||
semaphore = asyncio.Semaphore(bounded)
|
||||
logger.info("Starting bulk download of %d files with concurrency %d", len(jobs), bounded)
|
||||
|
||||
async def worker(url: str, name: str | None) -> DownloadResult:
|
||||
async with semaphore:
|
||||
return await self.download_file(
|
||||
url,
|
||||
destination,
|
||||
filename=name,
|
||||
referer=referer,
|
||||
chunk_size=chunk_size,
|
||||
dest_type=dest_type,
|
||||
)
|
||||
|
||||
results = await asyncio.gather(*(worker(url, name) for url, name in jobs))
|
||||
succeeded = sum(1 for result in results if result.ok)
|
||||
logger.info("Bulk download finished: %d succeeded, %d failed", succeeded, len(results) - succeeded)
|
||||
return list(results)
|
||||
|
||||
async def download_bytes(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
referer: str | None = None,
|
||||
max_bytes: int = IN_MEMORY_DOWNLOAD_CAP,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
dest_type: str | None = None,
|
||||
) -> BytesResult:
|
||||
kind = dest_type if dest_type else resource_kind_for_url(url)
|
||||
buffer = bytearray()
|
||||
try:
|
||||
async with self.stream("GET", url, referer=referer, dest=kind) as response:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
async for chunk in response.aiter_bytes(chunk_size):
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
break
|
||||
final_url = str(response.url)
|
||||
truncated = len(buffer) > max_bytes
|
||||
data = bytes(buffer[:max_bytes])
|
||||
logger.debug("download_bytes %s -> %d bytes (status %s)", url, len(data), response.status_code)
|
||||
return BytesResult(
|
||||
url=url,
|
||||
final_url=final_url,
|
||||
status_code=response.status_code,
|
||||
content_type=content_type,
|
||||
data=data,
|
||||
truncated=truncated,
|
||||
)
|
||||
except httpx.HTTPError as error:
|
||||
logger.error("download_bytes failed for %s: %s", url, error)
|
||||
return BytesResult(
|
||||
url=url,
|
||||
final_url=url,
|
||||
status_code=0,
|
||||
content_type="",
|
||||
data=b"",
|
||||
truncated=False,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
async def post_json(
|
||||
self,
|
||||
url: str,
|
||||
payload: object,
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
referer: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, object]:
|
||||
request_headers = self._api_headers(referer)
|
||||
if headers:
|
||||
request_headers.update({key.lower(): value for key, value in headers.items()})
|
||||
kwargs: dict[str, object] = {"json": payload}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = httpx.Timeout(timeout)
|
||||
logger.info("POST(json) %s", url)
|
||||
try:
|
||||
response = await self._send("POST", url, request_headers, **kwargs)
|
||||
except httpx.HTTPError as error:
|
||||
logger.error("post_json transport error for %s: %s", url, error)
|
||||
return {"error": f"{type(error).__name__}: {error}"}
|
||||
return self._json_or_error(response)
|
||||
|
||||
async def get_json(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
referer: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> dict[str, object]:
|
||||
kwargs: dict[str, object] = {}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = httpx.Timeout(timeout)
|
||||
logger.info("GET(json) %s", url)
|
||||
try:
|
||||
response = await self._send("GET", url, self._api_headers(referer), **kwargs)
|
||||
except httpx.HTTPError as error:
|
||||
logger.error("get_json transport error for %s: %s", url, error)
|
||||
return {"error": f"{type(error).__name__}: {error}"}
|
||||
return self._json_or_error(response)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChromeStealthClient",
|
||||
"ChromeProfile",
|
||||
"DownloadResult",
|
||||
"BytesResult",
|
||||
"build_chrome_ssl_context",
|
||||
"chrome_ssl_context",
|
||||
"chrome_base_headers",
|
||||
"stealth_transport",
|
||||
"stealth_async_client",
|
||||
"stealth_sync_client",
|
||||
"curl_active",
|
||||
"resource_kind_for_url",
|
||||
"slugify_filename",
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
<nav class="footer-links">
|
||||
<a href="/docs/index.html"><span class="icon">📚</span> Docs</a>
|
||||
<a href="/swagger"><span class="icon">🧪</span> Swagger</a>
|
||||
<a href="/redoc"><span class="icon">📕</span> ReDoc</a>
|
||||
<a href="/openapi.json"><span class="icon">🧩</span> OpenAPI</a>
|
||||
<a href="/bugs"><span class="icon">🐛</span> Bug Report</a>
|
||||
<a href="/issues"><span class="icon">🐛</span> Issue Report</a>
|
||||
</nav>
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
<div class="topnav-links">
|
||||
<a href="/" class="topnav-link {% if request.url.path == '/' %}active{% endif %}"><span class="icon">🏠</span> Home</a>
|
||||
<a href="/feed" class="topnav-link {% if request.url.path == '/feed' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/news" class="topnav-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-link topnav-link-feature {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
<a href="/news" class="topnav-link {% if request.url.path == '/news' or request.url.path.startswith('/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-link {% if request.url.path == '/gists' or request.url.path.startswith('/gists/') %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-link topnav-link-feature {% if request.url.path == '/projects' or request.url.path.startswith('/projects/') %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-link {% if request.url.path == '/leaderboard' or request.url.path.startswith('/leaderboard/') %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
<div class="topnav-tools-dropdown {% if request.url.path.startswith('/tools') %}active{% endif %}">
|
||||
<button type="button" class="topnav-link topnav-tools-toggle" aria-haspopup="true" aria-expanded="false"><span class="icon">🧰</span> Tools <span class="topnav-caret">▾</span></button>
|
||||
<div class="dropdown-menu">
|
||||
@@ -73,21 +73,19 @@
|
||||
</div>
|
||||
<div class="topnav-right">
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-icon {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages" title="Messages" aria-label="Messages">
|
||||
<span class="nav-bell">✉️</span>
|
||||
{% set msg_unread = get_unread_messages(user["uid"]) %}
|
||||
<span class="nav-badge" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span>
|
||||
{% set msg_unread = get_unread_messages(user["uid"]) %}
|
||||
<a href="/messages" class="topnav-icon {% if request.url.path == '/messages' or request.url.path.startswith('/messages/') %}active{% endif %}" data-counter="messages" title="Messages" aria-label="Messages">
|
||||
<span class="nav-bell">✉️<span class="nav-badge" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></span>
|
||||
</a>
|
||||
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
|
||||
<span class="nav-bell">🔕</span>
|
||||
</button>
|
||||
{% set unread_count = get_unread_count(user["uid"]) %}
|
||||
<a href="/notifications" class="topnav-icon" data-counter="notifications" title="Notifications" aria-label="Notifications">
|
||||
<span class="nav-bell">🔔</span>
|
||||
{% set unread_count = get_unread_count(user["uid"]) %}
|
||||
<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span>
|
||||
<span class="nav-bell">🔔<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span></span>
|
||||
</a>
|
||||
{% if is_admin(user) %}
|
||||
<a href="/admin" class="topnav-icon {% if 'admin' in request.url.path %}active{% endif %}" title="Admin" aria-label="Admin">
|
||||
<a href="/admin" class="topnav-icon {% if request.url.path == '/admin' or request.url.path.startswith('/admin/') %}active{% endif %}" title="Admin" aria-label="Admin">
|
||||
<span class="nav-bell">⚙️</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -120,24 +118,24 @@
|
||||
<nav class="topnav-mobile-links">
|
||||
<a href="/" class="topnav-mobile-link {% if request.url.path == '/' %}active{% endif %}"><span class="icon">🏠</span> Home</a>
|
||||
<a href="/feed" class="topnav-mobile-link {% if request.url.path == '/feed' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/news" class="topnav-mobile-link {% if 'news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-mobile-link {% if 'gists' in request.url.path %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
<a href="/news" class="topnav-mobile-link {% if request.url.path == '/news' or request.url.path.startswith('/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/gists" class="topnav-mobile-link {% if request.url.path == '/gists' or request.url.path.startswith('/gists/') %}active{% endif %}"><span class="icon">📄</span> Gists</a>
|
||||
<a href="/projects" class="topnav-mobile-link {% if request.url.path == '/projects' or request.url.path.startswith('/projects/') %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/leaderboard" class="topnav-mobile-link {% if request.url.path == '/leaderboard' or request.url.path.startswith('/leaderboard/') %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
<div class="topnav-mobile-section-label">Tools</div>
|
||||
<a href="/tools/seo" class="topnav-mobile-link {% if 'tools/seo' in request.url.path %}active{% endif %}"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
<a href="/tools/deepsearch" class="topnav-mobile-link {% if 'tools/deepsearch' in request.url.path %}active{% endif %}"><span class="icon">🧠</span> DeepSearch</a>
|
||||
<a href="/tools/seo" class="topnav-mobile-link {% if request.url.path == '/tools/seo' or request.url.path.startswith('/tools/seo/') %}active{% endif %}"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
<a href="/tools/deepsearch" class="topnav-mobile-link {% if request.url.path == '/tools/deepsearch' or request.url.path.startswith('/tools/deepsearch/') %}active{% endif %}"><span class="icon">🧠</span> DeepSearch</a>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_messages(user["uid"]) }}</span></a>
|
||||
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}" data-counter="notifications"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_count(user["uid"]) }}</span></a>
|
||||
<a href="/messages" class="topnav-mobile-link {% if request.url.path == '/messages' or request.url.path.startswith('/messages/') %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_messages(user["uid"]) }}</span></a>
|
||||
<a href="/notifications" class="topnav-mobile-link {% if request.url.path == '/notifications' or request.url.path.startswith('/notifications/') %}active{% endif %}" data-counter="notifications"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_count(user["uid"]) }}</span></a>
|
||||
{% if is_admin(user) %}
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
<div class="topnav-mobile-section-label">Admin</div>
|
||||
<a href="/admin/users" class="topnav-mobile-link {% if 'admin/users' in request.url.path %}active{% endif %}"><span class="icon">👥</span> Users</a>
|
||||
<a href="/admin/news" class="topnav-mobile-link {% if 'admin/news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/admin/services" class="topnav-mobile-link {% if 'admin/services' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Services</a>
|
||||
<a href="/admin/settings" class="topnav-mobile-link {% if 'admin/settings' in request.url.path %}active{% endif %}"><span class="icon">🔧</span> Settings</a>
|
||||
<a href="/admin/users" class="topnav-mobile-link {% if request.url.path == '/admin/users' or request.url.path.startswith('/admin/users/') %}active{% endif %}"><span class="icon">👥</span> Users</a>
|
||||
<a href="/admin/news" class="topnav-mobile-link {% if request.url.path == '/admin/news' or request.url.path.startswith('/admin/news/') %}active{% endif %}"><span class="icon">📰</span> News</a>
|
||||
<a href="/admin/services" class="topnav-mobile-link {% if request.url.path == '/admin/services' or request.url.path.startswith('/admin/services/') %}active{% endif %}"><span class="icon">⚙️</span> Services</a>
|
||||
<a href="/admin/settings" class="topnav-mobile-link {% if request.url.path == '/admin/settings' or request.url.path.startswith('/admin/settings/') %}active{% endif %}"><span class="icon">🔧</span> Settings</a>
|
||||
{% endif %}
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
<div class="topnav-mobile-user">
|
||||
@@ -149,7 +147,7 @@
|
||||
</div>
|
||||
<a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">👤</span> Profile</a>
|
||||
<a href="/devii/" class="topnav-mobile-link" data-devii-open><span class="icon">🤖</span> Devii</a>
|
||||
<a href="/bookmarks/saved" class="topnav-mobile-link {% if 'bookmarks' in request.url.path %}active{% endif %}"><span class="icon">🔖</span> Saved</a>
|
||||
<a href="/bookmarks/saved" class="topnav-mobile-link {% if request.url.path == '/bookmarks/saved' or request.url.path.startswith('/bookmarks/') %}active{% endif %}"><span class="icon">🔖</span> Saved</a>
|
||||
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">🚪</span> Logout</a>
|
||||
{% else %}
|
||||
<div class="topnav-mobile-divider"></div>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/bugs.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout bug-detail">
|
||||
<div class="bug-detail-nav">
|
||||
<a href="/bugs" class="bug-back">← All bugs</a>
|
||||
{% if bug.html_url %}<a href="{{ bug.html_url }}" class="bug-gitea-link" target="_blank" rel="noopener noreferrer">View on Gitea</a>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card bug-detail-card">
|
||||
<div class="bug-detail-header">
|
||||
<span class="bug-number">#{{ bug.number }}</span>
|
||||
<h1 class="bug-detail-title">{{ bug.title }}</h1>
|
||||
<span class="bug-status {{ bug.state }}">{{ bug.state }}</span>
|
||||
</div>
|
||||
<div class="bug-meta">
|
||||
{% if bug.is_local_author %}
|
||||
{% set _user = {'username': bug.author_username} %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% endif %}
|
||||
<span class="bug-author">{{ bug.author_username }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-date">{{ format_date(bug.created_at, true) }}</span>
|
||||
</div>
|
||||
|
||||
{% if viewer_is_admin %}
|
||||
<div class="bug-admin-actions">
|
||||
{% if bug.state == 'open' %}
|
||||
<form method="POST" action="/bugs/{{ bug.number }}/status">
|
||||
<input type="hidden" name="status" value="closed">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Close ticket</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/bugs/{{ bug.number }}/status">
|
||||
<input type="hidden" name="status" value="open">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Reopen ticket</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bug-detail-body rendered-content" data-render>{{ body }}</div>
|
||||
</div>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>Updates & comments</h3>
|
||||
{% for comment in comments %}
|
||||
<div class="bug-comment">
|
||||
<div class="bug-comment-head">
|
||||
{% if comment.is_local_author %}
|
||||
{% set _user = {'username': comment.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% else %}
|
||||
<span class="bug-comment-badge">dev</span>
|
||||
{% endif %}
|
||||
<span class="bug-author">{{ comment.author_username }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-date">{{ format_date(comment.created_at, true) }}</span>
|
||||
</div>
|
||||
<div class="bug-comment-body rendered-content" data-render>{{ comment.body }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-comments-msg">No updates yet.</p>
|
||||
{% endfor %}
|
||||
|
||||
{% if can_comment %}
|
||||
<form method="POST" action="/bugs/{{ bug.number }}/comment" class="comment-form bug-comment-form">
|
||||
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120"></textarea>
|
||||
<div class="bug-comment-form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="no-comments-msg"><a href="/auth/login">Log in</a> to comment.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,77 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/bugs.css') }}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout">
|
||||
<div class="bugs-header">
|
||||
<h1>Bug Reports</h1>
|
||||
{% if user %}
|
||||
<button type="button" class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">🐛</span> Report Bug</button>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn btn-primary btn-sm login-required" title="Log in to participate"><span class="icon">🐛</span> Report Bug</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bugs-filters">
|
||||
<a href="?state=open" class="bugs-filter {% if state == 'open' %}active{% endif %}">Open</a>
|
||||
<a href="?state=closed" class="bugs-filter {% if state == 'closed' %}active{% endif %}">Closed</a>
|
||||
<a href="?state=all" class="bugs-filter {% if state == 'all' %}active{% endif %}">All</a>
|
||||
</div>
|
||||
|
||||
{% if not configured or error_message %}
|
||||
<div class="empty-state">{{ error_message }}</div>
|
||||
{% else %}
|
||||
<div class="bugs-list">
|
||||
{% for bug in bugs %}
|
||||
<div class="bug-card card-link-host">
|
||||
{% set _href = "/bugs/" ~ bug.number %}
|
||||
{% set _label = bug.title %}
|
||||
{% include "_card_link.html" %}
|
||||
<div class="bug-card-header">
|
||||
<span class="bug-number">#{{ bug.number }}</span>
|
||||
<span class="bug-title">{{ bug.title }}</span>
|
||||
<span class="bug-status {{ bug.state }}">{{ bug.state }}</span>
|
||||
</div>
|
||||
<div class="bug-meta">
|
||||
{% if bug.is_local_author %}
|
||||
{% set _user = {'username': bug.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% endif %}
|
||||
<span class="bug-author">{{ bug.author_username }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-date">{{ format_date(bug.created_at) }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-comments">{{ bug.comments_count }} comment{{ '' if bug.comments_count == 1 else 's' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No bug reports yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% set pagination_query = 'state=' ~ state ~ '&' %}
|
||||
{% include "_pagination.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
{% call modal('create-bug-modal', 'Report a Bug') %}
|
||||
<form method="POST" action="/bugs/create" data-bug-create>
|
||||
<p class="bugs-modal-hint">Your report is improved by AI into a consistent ticket and filed on the tracker.</p>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="bug-title">Title</label>
|
||||
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="bug-description">Description</label>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">📤</span> Submit Report</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="devrant-login-bar" data-devrant-login></div>
|
||||
{% for ep in devrant_endpoints(devrant_slug) %}
|
||||
<section class="endpoint-card" id="{{ ep.id }}">
|
||||
<header class="endpoint-head">
|
||||
<span class="method-badge method-{{ ep.method|lower }}">{{ ep.method }}</span>
|
||||
<code class="endpoint-path">{{ ep.path }}</code>
|
||||
<span class="auth-badge auth-{{ ep.auth }}" title="Minimal role required to call this endpoint">Minimal role: {{ ep.min_role }}</span>
|
||||
</header>
|
||||
<h3 class="endpoint-title">{{ ep.title }}</h3>
|
||||
<p class="endpoint-summary">{{ ep.summary }}</p>
|
||||
<div data-devrant-tester data-config='{{ ep | tojson }}'></div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
@@ -27,11 +27,11 @@ Routers in `routers/` form a **directory tree that mirrors the endpoint (URL) pa
|
||||
```
|
||||
/auth /feed /posts /comments /projects /profile
|
||||
/messages /notifications /votes /reactions /bookmarks
|
||||
/polls /avatar /follow /leaderboard /admin /bugs
|
||||
/polls /avatar /follow /leaderboard /admin /issues
|
||||
/gists /news /uploads /openai /devii
|
||||
```
|
||||
|
||||
`auth/`, `profile/`, `bugs/`, `docs/`, `admin/`, and `projects/` are packages; the rest are flat files. The whole `/projects` tree (project CRUD + `files.py` + the `containers/` subpackage) and the whole `/admin` tree (every admin sub-resource plus the folded-in `services` and `containers`) each mount from a single package. `seo.py`, `push.py`, and the `docs/` package mount without a prefix.
|
||||
`auth/`, `profile/`, `issues/`, `docs/`, `admin/`, and `projects/` are packages; the rest are flat files. The whole `/projects` tree (project CRUD + `files.py` + the `containers/` subpackage) and the whole `/admin` tree (every admin sub-resource plus the folded-in `services` and `containers`) each mount from a single package. `seo.py`, `push.py`, and the `docs/` package mount without a prefix.
|
||||
|
||||
**Aggregation rules.** A leaf that owns the domain's collection-root (`""`) route is the package's base router (FastAPI rejects an empty path included under an empty prefix), so the package `__init__` imports that leaf's `router` and includes the rest onto it. A router folded in from a deeper mount keeps its own paths and is included with a sub-prefix (`admin/__init__` includes `services.router` with `prefix="/services"`). Package-private helpers shared by leaves live in `_shared.py`. Within any router, **specific paths must be declared before catch-alls**: `/{username}/followers` before `/{username}`.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ To keep the fleet from waking all at once, each bot waits a random `bot_startup_
|
||||
|
||||
1. Pick a session type (mood), which sets the action budget and the post-session break range.
|
||||
2. Launch the browser and authenticate (register or log in).
|
||||
3. Warm the content cache in the background (pre-generate one post, project, and bug so the first creation is instant).
|
||||
3. Warm the content cache in the background (pre-generate one post, project, and issue so the first creation is instant).
|
||||
4. For `normal` and `deep` sessions only: check messages, check notifications, run the community engagement pass, then go to the feed.
|
||||
5. Run action cycles until the action budget is reached or fatigue ends the session early, saving state and pausing briefly between cycles.
|
||||
6. Close the browser and sleep for the mood's break (scaled by `break_scale`).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user