Compare commits

..
Author SHA1 Message Date
Typosaurus 79cd5e2920 ticket #152 attempt 1
DevPlace CI / test (pull_request) Failing after 1h10m33s
2026-07-28 20:02:41 +00:00
14 changed files with 72 additions and 200 deletions
-19
View File
@@ -60,21 +60,6 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
logger = logging.getLogger(__name__)
def get_project_by_uid(project_uid: str | None) -> dict | None:
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
@@ -527,7 +512,6 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"project_link": detail.get("project_link"),
}
if extra:
context.update(extra)
@@ -707,7 +691,6 @@ def load_detail(
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
}
@@ -735,8 +718,6 @@ def enrich_items(
entry[name] = (
source(item) if callable(source) else source.get(item["uid"], 0)
)
if key == "post" and item.get("project_uid"):
entry["project_link"] = get_project_by_uid(item["project_uid"])
enriched.append(entry)
return enriched
-3
View File
@@ -147,9 +147,6 @@ def init_db():
("is_private", 0),
("read_only", 0),
("updated_at", ""),
("title", ""),
("description", ""),
("status", ""),
):
if not projects.has_column(column):
projects.create_column_by_example(column, example)
+11 -5
View File
@@ -79,6 +79,7 @@ _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
_EMAIL_KEEP_DOMAIN = "molodetz.nl"
_MEDIA_SKIP_TAGS = {"a", "code", "pre"}
_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}\"']+$")
_TITLE_INLINE_TAGS = {
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
}
@@ -139,6 +140,11 @@ def _alt_from_url(url: str) -> str:
def _embed_url(url: str) -> str:
trail = ""
punct_match = _TRAILING_PUNCT_RE.search(url)
if punct_match:
trail = punct_match.group()
url = url[: punct_match.start()]
youtube = _YOUTUBE_RE.search(url)
if youtube:
video_id = youtube.group(1)
@@ -146,19 +152,19 @@ def _embed_url(url: str) -> str:
f'<div class="embed-youtube"><iframe '
f'src="https://www.youtube.com/embed/{video_id}" '
f'frameborder="0" allowfullscreen allow="{_YOUTUBE_ALLOW}">'
f"</iframe></div>"
f"</iframe></div>{trail}"
)
escaped = html.escape(url, quote=True)
if _IMAGE_RE.search(url):
alt = html.escape(_alt_from_url(url), quote=True)
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>'
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>{trail}'
if _VIDEO_RE.search(url):
return f'<video src="{escaped}" controls preload="metadata"></video>'
return f'<video src="{escaped}" controls preload="metadata"></video>{trail}'
if _AUDIO_RE.search(url):
return f'<audio src="{escaped}" controls preload="metadata"></audio>'
return f'<audio src="{escaped}" controls preload="metadata"></audio>{trail}'
return (
f'<a href="{escaped}" target="_blank" rel="noopener noreferrer">'
f"{html.escape(url)}</a>"
f"{html.escape(url)}</a>{trail}"
)
+1 -2
View File
@@ -87,7 +87,6 @@ async def create_rant(request: Request):
if len(text) > 125000:
return dr_error("Your rant is too long.")
tags = _parse_tags(params.get("tags"))
project_uid = params.get("project_uid") or None
uid, slug = create_content_item(
"posts",
"post",
@@ -96,7 +95,7 @@ async def create_rant(request: Request):
"title": None,
"content": text,
"topic": "rant",
"project_uid": project_uid,
"project_uid": None,
"image": None,
"tags": encode_tags(tags),
},
-8
View File
@@ -72,13 +72,6 @@ class BadgeOut(_Out):
model_config = ConfigDict(populate_by_name=True)
class ProjectLinkOut(_Out):
uid: str = ""
name: Optional[str] = None
slug: Optional[str] = None
url: Optional[str] = None
class PostOut(_Out):
uid: str = ""
slug: Optional[str] = None
@@ -88,7 +81,6 @@ class PostOut(_Out):
topic: Optional[str] = None
stars: Optional[int] = None
image: Optional[str] = None
project_uid: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
-3
View File
@@ -14,7 +14,6 @@ from devplacepy.schemas.content import (
NotificationOut,
PollOut,
PostOut,
ProjectLinkOut,
ProjectOut,
ReactionsOut,
UserOut,
@@ -32,7 +31,6 @@ class FeedItemOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
project_link: Optional[ProjectLinkOut] = None
class GistItemOut(_Out):
@@ -134,7 +132,6 @@ class PostDetailOut(_Out):
comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = []
topics: list[str] = []
project_link: Optional[ProjectLinkOut] = None
class ProjectsOut(_Out):
@@ -4,7 +4,6 @@ import json
from typing import Optional
from devplacepy.avatar import avatar_seed
from devplacepy.database import get_table
from devplacepy.services.devrant.avatar import avatar_payload
from devplacepy.services.devrant.ids import to_unix
@@ -27,22 +26,6 @@ def encode_tags(tags: list) -> str:
return json.dumps(cleaned)
def _rant_project(post: dict) -> dict | None:
project_uid = post.get("project_uid")
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def rant_text(post: dict) -> str:
title = (post.get("title") or "").strip()
content = post.get("content") or ""
@@ -73,7 +56,6 @@ def serialize_rant(
author = authors.get(post["user_uid"]) or {}
uid = post["uid"]
username = author.get("username") or ""
project = _rant_project(post)
return {
"id": int(post["id"]),
"text": rant_text(post),
@@ -93,8 +75,6 @@ def serialize_rant(
"user_avatar": avatar_payload(avatar_seed(author)),
"user_avatar_lg": avatar_payload(avatar_seed(author)),
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
"project_uid": post.get("project_uid"),
"project": project,
}
-16
View File
@@ -55,22 +55,6 @@
margin-bottom: 1rem;
}
.project-link {
display: inline-block;
font-size: 0.875rem;
color: var(--accent);
font-weight: 600;
padding: 0.375rem 0.75rem;
margin-bottom: 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius);
text-decoration: none;
}
.project-link:hover {
text-decoration: underline;
}
.post-detail-actions {
display: flex;
align-items: center;
+15 -3
View File
@@ -188,17 +188,21 @@ export class ContentRenderer {
a.textContent = "@" + part.username;
fragment.appendChild(a);
} else {
const el = this.urlToEmbed(part.value);
const { cleaned, trail } = this.stripTrailingPunct(part.value);
const el = this.urlToEmbed(cleaned);
if (el) {
fragment.appendChild(el);
} else {
const a = document.createElement("a");
a.href = part.value;
a.href = cleaned;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = part.value;
a.textContent = cleaned;
fragment.appendChild(a);
}
if (trail) {
fragment.appendChild(document.createTextNode(trail));
}
}
}
@@ -282,6 +286,14 @@ export class ContentRenderer {
return null;
}
stripTrailingPunct(url) {
const m = url.match(/[.,;:!?)\]}\"']+$/);
if (m) {
return { cleaned: url.slice(0, m.index), trail: m[0] };
}
return { cleaned: url, trail: "" };
}
walkNodes(root, callback) {
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]);
const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
-4
View File
@@ -17,10 +17,6 @@
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
{% if item.project_link %}
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
{% endif %}
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
-4
View File
@@ -28,10 +28,6 @@
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
{% if project_link %}
<a href="{{ project_link.url }}" class="project-link">Project: {{ project_link.name }}</a>
{% endif %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
+2 -47
View File
@@ -1,12 +1,10 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid, make_combined_slug
_counter_dr = [0]
@@ -48,13 +46,10 @@ def _register(password="secret123"):
raise AssertionError("registration did not open")
def _create_rant(params, text="this is a devrant rant body", tags="dev,test", project_uid=None):
data = {**params, "rant": text, "tags": tags}
if project_uid:
data["project_uid"] = project_uid
def _create_rant(params, text="this is a devrant rant body", tags="dev,test"):
r = requests.post(
f"{BASE_URL}/api/devrant/rants",
data=data,
data={**params, "rant": text, "tags": tags},
)
return r
@@ -240,43 +235,3 @@ def test_search_returns_results_shape(app_server):
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
assert r.json()["success"] is True
assert isinstance(r.json()["results"], list)
def test_rant_serialization_includes_project(app_server):
name, params = _register()
refresh_snapshot()
user = get_table("users").find_one(username=name)
uid = user["uid"]
project_uid = generate_uid()
slug = make_combined_slug("project-for-rant", project_uid)
projects = get_table("projects")
projects.insert({
"uid": project_uid,
"user_uid": uid,
"slug": slug,
"title": "Project For Rant",
"description": "project linked to a rant",
"project_type": "software",
"status": "In Development",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
})
refresh_snapshot()
rant_id = _create_rant(
params,
text="rant with a project link here",
tags="dev",
project_uid=project_uid,
).json()["rant_id"]
refresh_snapshot()
rant = requests.get(
f"{BASE_URL}/api/devrant/rants/{rant_id}",
params=params,
).json()["rant"]
assert rant.get("project_uid") == project_uid
assert rant.get("project") is not None
assert rant["project"]["uid"] == project_uid
assert rant["project"]["name"] == "Project For Rant"
assert "/projects/" in rant["project"]["url"]
-66
View File
@@ -716,69 +716,3 @@ def test_short_post_content_rejected(app_server):
allow_redirects=False,
)
assert "/posts/" not in (r.headers.get("location") or "")
def _new_project(session):
return session.post(
f"{BASE_URL}/projects/create",
headers=JSON_audit_log,
data={
"title": _unique("aupj"),
"description": "project for linked post test",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
).json()["data"]
def test_post_detail_includes_project_when_linked(app_server):
s, _ = _member()
project = _new_project(s)
project_uid = project["uid"]
created = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupjpost"),
"content": "this post is linked to a project",
"topic": "devlog",
"project_uid": project_uid,
},
).json()["data"]
slug = created["slug"]
detail = s.get(
f"{BASE_URL}/posts/{slug}",
headers=JSON_audit_log,
).json()
assert detail.get("project_link") is not None, "linked project must appear in post detail"
assert detail["project_link"]["uid"] == project_uid
assert detail["project_link"]["name"] is not None
assert "/projects/" in detail["project_link"]["url"]
def test_feed_includes_project_when_linked(app_server):
s, _ = _member()
project = _new_project(s)
project_uid = project["uid"]
s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("aupjfeed"),
"content": "this post appears in feed with a project link",
"topic": "devlog",
"project_uid": project_uid,
},
)
feed = s.get(
f"{BASE_URL}/feed",
headers=JSON_audit_log,
).json()
found = False
for item in feed["posts"]:
if item.get("project_link") is not None and item["project_link"]["uid"] == project_uid:
found = True
assert "/projects/" in item["project_link"]["url"]
break
assert found, "feed must include project info for linked posts"
+43
View File
@@ -263,3 +263,46 @@ def test_xss_legitimate_link_survives_audit():
out = str(render_content("see https://example.com/page ok"))
assert 'href="https://example.com/page"' in out
assert_no_executable_html(out)
def test_bare_url_with_trailing_period():
out = str(render_content("see https://example.com/page."))
assert 'href="https://example.com/page"' in out
assert "</a>." in out
assert "https://example.com/page.</a>" not in out
def test_bare_url_with_trailing_comma():
out = str(render_content("check https://example.com/page,"))
assert 'href="https://example.com/page"' in out
assert "</a>," in out
def test_bare_url_with_trailing_exclamation():
out = str(render_content("look https://example.com/page!"))
assert 'href="https://example.com/page"' in out
assert "</a>!" in out
def test_bare_url_with_trailing_question_mark():
out = str(render_content("did you see https://example.com/page?"))
assert 'href="https://example.com/page"' in out
assert "</a>?" in out
def test_bare_url_with_trailing_paren():
out = str(render_content("see (https://example.com/page)"))
assert 'href="https://example.com/page"' in out
assert "</a>)" in out
def test_bare_url_with_trailing_multiple_punctuation():
out = str(render_content("visit https://example.com/page..."))
assert 'href="https://example.com/page"' in out
assert "</a>..." in out
def test_bare_url_without_trailing_punctuation_unchanged():
out = str(render_content("see https://example.com/page ok"))
assert 'href="https://example.com/page"' in out
assert "https://example.com/page</a> " in out