Compare commits

...

2 Commits

Author SHA1 Message Date
51832664c4 Updatex
All checks were successful
DevPlace CI / test (push) Successful in 6m22s
2026-05-27 22:03:12 +02:00
4e26ad740e Update. 2026-05-27 21:07:02 +02:00
22 changed files with 100 additions and 93 deletions

View File

@ -55,10 +55,13 @@ make locust-headless # Locust in headless CLI mode (for CI)
1. **Emoji shortcodes** β†’ Unicode emoji (`:fire:` β†’ πŸ”₯, 80+ shortcodes)
2. **Markdown parse** β†’ via `marked` with GFM tables, line breaks
3. **Code syntax highlight** β†’ `highlight.js` on all `<pre><code>` blocks
4. **Image URLs** β†’ standalone `.jpg/.png/.gif` URLs become `<img>` tags
5. **YouTube URLs** β†’ `youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
6. **All URLs** β†’ become `<a>` links with `target="_blank"` and `rel="noopener"`
3. **Sanitize** β†’ `DOMPurify.sanitize` strips script/event-handler/iframe/`javascript:` payloads from the marked output
4. **Code syntax highlight** β†’ `highlight.js` on all `<pre><code>` blocks
5. **Image URLs** β†’ standalone `.jpg/.png/.gif` URLs become `<img>` tags
6. **YouTube URLs** β†’ `youtube.com/watch?v=` or `youtu.be/` become embedded iframe players
7. **All URLs** β†’ become `<a>` links with `target="_blank"` and `rel="noopener"`
**Sanitization is the XSS control.** Content is rendered client-side from `element.textContent`, so Jinja autoescaping does not protect it. `DOMPurify.sanitize` (vendored at `static/vendor/purify.min.js`, loaded `defer` in `base.html`) runs on the raw `marked` output before `processMedia` injects the trusted YouTube iframes, so user payloads are removed while our embeds survive. It is fail-closed: `render()` throws if `DOMPurify` is missing rather than emitting unsanitized HTML β€” never relax this into a `typeof` skip.
**Code blocks are protected** - `NodeIterator` skips `CODE`, `PRE`, `SCRIPT`, `STYLE` elements during URL/media processing, so source code in markdown code blocks is never touched.
@ -71,6 +74,7 @@ Loaded via `<script>` tags in `base.html`. ALL must use `defer` to avoid blockin
```html
<script defer src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script defer src="/static/js/ContentRenderer.js"></script>
<script defer src="/static/js/EmojiPicker.js"></script>
@ -563,19 +567,11 @@ All tests must pass. Tests stop at first failure (`-x`).
### Step 7: Run full suite again (only if asked by user)
```bash
hawk .
make test
make test-headed # visual confirmation
```
### Step 8: Visual verification (if UI changed)
```bash
falcon take --output /tmp/verify.png
falcon describe /tmp/verify.png
```
### Step 9: Document
### Step 8: Document
- Update `AGENTS.md` if new conventions introduced
- Update `README.md` if new routes, config, or dependencies added

View File

@ -151,6 +151,17 @@ def software_source_code_schema(gist, base_url):
}
def _json_ld_dumps(payload):
raw = json.dumps(payload, ensure_ascii=False)
return (
raw.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
.replace("
", "\\u2028")
.replace("
", "\\u2029")
)
def combine(schemas):
if not schemas:
return None
@ -161,9 +172,12 @@ def combine(schemas):
cleaned.append(s)
if not cleaned:
return None
if len(cleaned) == 1:
return json.dumps({"@context": "https://schema.org", **cleaned[0]}, ensure_ascii=False)
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
payload = (
{"@context": "https://schema.org", **cleaned[0]}
if len(cleaned) == 1
else {"@context": "https://schema.org", "@graph": cleaned}
)
return _json_ld_dumps(payload)
DEFAULT_OG_IMAGE = "/static/og-default.png"

View File

@ -969,3 +969,20 @@ img {
font-size: 0.8125rem;
}
}
.card-link-host {
position: relative;
}
.card-link {
position: absolute;
inset: 0;
z-index: 1;
}
.card-link-host a:not(.card-link),
.card-link-host button,
.card-link-host form {
position: relative;
z-index: 2;
}

View File

@ -51,6 +51,11 @@ export class ContentRenderer {
html = "<p>" + text.replace(/\n/g, "<br>") + "</p>";
}
if (typeof DOMPurify === "undefined") {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
html = DOMPurify.sanitize(html);
html = this.processMedia(html);
return html;

View File

@ -6,7 +6,6 @@ export class DomUtils {
this.initShareButtons();
this.initTogglers();
this.initStopPropagation();
this.initCardLinks();
}
initClipboardCopy() {
@ -54,14 +53,4 @@ export class DomUtils {
el.addEventListener("click", (e) => e.stopPropagation());
});
}
initCardLinks() {
document.querySelectorAll("[data-href]").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-stop-propagation]")) return;
const href = el.dataset.href;
if (href) window.location.href = href;
});
});
}
}

View File

@ -1,36 +1,8 @@
import { Http } from "./Http.js";
export class NotificationManager {
constructor() {
this.initCardNavigation();
this.initDismiss();
this.initHashScroll();
}
initCardNavigation() {
document.querySelectorAll(".notification-card[data-href]").forEach((card) => {
card.addEventListener("click", (e) => {
if (e.target.closest("a, button")) {
return;
}
window.location.href = card.dataset.href;
});
});
}
initDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const uid = btn.dataset.uid;
if (!uid) {
return;
}
Http.postForm(`/notifications/mark-read/${uid}`);
});
});
}
initHashScroll() {
const hash = window.location.hash;
if (!hash.startsWith("#comment-")) {

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<a class="card-link" href="{{ _href }}" aria-label="{{ _label | default('', true) }}"></a>

View File

@ -29,10 +29,10 @@
{% include "_attachment_display.html" %}
{% endif %}
<div class="comment-actions">
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span>Reply</button>
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span> Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>
@ -67,7 +67,7 @@
data-max-size="{{ max_upload_size_mb() }}"
data-max-files="{{ max_attachments_per_resource() }}"
data-allowed-types="{{ allowed_file_types() }}"></div>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span>Post</button>
<button type="submit" class="comment-form-submit"><span class="icon">&#x1F4E4;</span> Post</button>
</div>
</form>
{% endif %}

View File

@ -52,13 +52,13 @@
{% if u['uid'] != user['uid'] %}
<form method="POST" action="/admin/users/{{ u['uid'] }}/toggle" class="admin-inline-form">
<button type="submit" class="admin-btn admin-btn-sm">
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span>{% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
<span class="icon">{% if u.get('is_active', True) %}&#x26A1;{% else %}&#x1F512;{% endif %}</span> {% if u.get('is_active', True) %}Disable{% else %}Enable{% endif %}
</button>
</form>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span>Password</button>
<button type="button" class="admin-btn admin-btn-sm" data-toggle="pw-{{ u['uid'] }}"><span class="icon">&#x1F511;</span> Password</button>
<form id="pw-{{ u['uid'] }}" method="POST" action="/admin/users/{{ u['uid'] }}/password" class="admin-pw-form hidden">
<input type="password" name="password" placeholder="New password" minlength="6" class="admin-input-sm">
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span>Set</button>
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">&#x1F4BE;</span> Set</button>
</form>
{% else %}
<span class="admin-text-muted">You</span>

View File

@ -85,11 +85,11 @@
</div>
</a>
<div class="dropdown-menu">
<a href="/auth/logout" class="dropdown-item"><span class="icon">πŸšͺ</span>Logout</a>
<a href="/auth/logout" class="dropdown-item"><span class="icon">πŸšͺ</span> Logout</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="topnav-link"><span class="icon">πŸ”‘</span>Login</a>
<a href="/auth/login" class="topnav-link"><span class="icon">πŸ”‘</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon">✨</span>Sign Up</a>
{% endif %}
<button class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu">&#x2630;</button>
@ -167,6 +167,7 @@
<script defer src="/static/vendor/marked.umd.js"></script>
<script defer src="/static/vendor/highlight.min.js"></script>
<script defer src="/static/vendor/purify.min.js"></script>
<script type="module" src="/static/vendor/emoji-picker-element/index.js"></script>
<script type="module" src="/static/js/Application.js"></script>
{% block extra_js %}{% endblock %}

View File

@ -30,7 +30,7 @@
<label for="email">Email address</label>
<input type="email" id="email" name="email" required maxlength="255" placeholder="you@example.com">
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span>Send Reset Link</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F4E7;</span> Send Reset Link</button>
</form>
<div class="auth-footer">

View File

@ -49,7 +49,10 @@
<div class="gists-grid">
{% for item in gists %}
<div class="gist-card fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
<div class="gist-card fade-in card-link-host">
{% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %}
{% set _label = item.gist['title'] %}
{% include "_card_link.html" %}
<div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>

View File

@ -9,7 +9,7 @@
<section class="landing-hero">
<h1>Devplace.net &mdash; The Developer <span>Social Network</span></h1>
<p>Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.</p>
<a href="/auth/signup" class="landing-cta"><span class="icon">&#x2728;</span>Join DevPlace Free</a>
<a href="/auth/signup" class="landing-cta"><span class="icon">&#x2728;</span> Join DevPlace Free</a>
<div class="landing-features">
<div class="landing-feature">
@ -117,11 +117,11 @@
<footer class="landing-footer">
<p>&copy; DevPlace &mdash; The Developer Social Network</p>
<div class="landing-footer-links">
<a href="/feed"><span class="icon">&#x1F4DD;</span>Posts</a>
<a href="/news"><span class="icon">&#x1F4F0;</span>News</a>
<a href="/projects"><span class="icon">&#x1F680;</span>Projects</a>
<a href="/auth/login"><span class="icon">&#x1F511;</span>Login</a>
<a href="/auth/signup"><span class="icon">&#x2728;</span>Sign Up</a>
<a href="/feed"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/news"><span class="icon">&#x1F4F0;</span> News</a>
<a href="/projects"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/auth/login"><span class="icon">&#x1F511;</span> Login</a>
<a href="/auth/signup"><span class="icon">&#x2728;</span> Sign Up</a>
<a href="/bugs"><span class="icon">&#x1F41B;</span> Bug Report</a>
</div>
</footer>

View File

@ -40,7 +40,7 @@
<a href="/auth/forgot-password">Forgot your password?</a>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F511;</span>Sign in</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F511;</span> Sign in</button>
</form>
<div class="auth-footer">

View File

@ -17,8 +17,11 @@
<div class="notification-group">
<div class="notification-group-label">{{ group.label }}</div>
{% for item in group.entries %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}" data-href="/notifications/open/{{ item.notification['uid'] }}">
<div class="notification-card card-link-host {% if not item.notification['read'] %}unread{% endif %}">
{% set actor_username = item.actor['username'] if item.actor else '#' %}
{% set _href = "/notifications/open/" ~ item.notification['uid'] %}
{% set _label = item.notification['message'] %}
{% include "_card_link.html" %}
<a href="/profile/{{ actor_username }}" style="flex-shrink:0">
<img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy">
</a>
@ -27,7 +30,7 @@
<div class="notification-time">{{ item.time_ago }}</div>
</div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
<button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">&times;</button>
<button type="submit" class="notification-dismiss">&times;</button>
</form>
</div>
{% endfor %}

View File

@ -145,10 +145,10 @@
<a href="/feed" class="back-link">&larr; Back</a>
<div class="profile-tabs">
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span>Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span>Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span>Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span>Activity</a>
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span> Activity</a>
<button class="btn-ghost btn-icon profile-tab-btn">&#x25B3;</button>
</div>

View File

@ -141,7 +141,7 @@
{% endif %}
{% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
</div>

View File

@ -53,7 +53,10 @@
<div class="projects-grid">
{% for project in projects %}
<div class="project-card fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
<div class="project-card fade-in card-link-host">
{% set _href = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set _label = project['title'] %}
{% include "_card_link.html" %}
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">

View File

@ -34,7 +34,7 @@
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span>Reset Password</button>
<button type="submit" class="auth-submit"><span class="icon">&#x1F512;</span> Reset Password</button>
</form>
</div>
</div>

View File

@ -47,7 +47,7 @@
</div>
</div>
<button type="submit" class="auth-submit"><span class="icon">&#x2728;</span>Create account</button>
<button type="submit" class="auth-submit"><span class="icon">&#x2728;</span> Create account</button>
</form>
<div class="auth-footer">

View File

@ -391,12 +391,12 @@ def test_comment_notification_click_opens_comment(app_server, browser, seeded_db
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card[data-href]").filter(has_text="commented on your post").first
card = pa.locator(".notification-card").filter(has_text="commented on your post").first
card.wait_for(state="visible", timeout=10000)
href = card.get_attribute("data-href")
assert href.startswith("/notifications/open/"), f"unexpected data-href: {href}"
href = card.locator("a.card-link").get_attribute("href")
assert href.startswith("/notifications/open/"), f"unexpected href: {href}"
card.locator(".notification-text").click()
card.locator("a.card-link").click()
pa.wait_for_url("**/posts/**", timeout=10000, wait_until="domcontentloaded")
assert "#comment-" in pa.url, f"click did not deep-link to a comment: {pa.url}"
@ -406,7 +406,7 @@ def test_comment_notification_click_opens_comment(app_server, browser, seeded_db
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
target = pa.locator(f'.notification-card[data-href="{href}"]')
target = pa.locator(f'.notification-card:has(a.card-link[href="{href}"])')
target.wait_for(state="visible", timeout=10000)
assert "unread" not in (target.get_attribute("class") or ""), "opening a notification should mark it read"
@ -434,9 +434,9 @@ def test_follow_notification_click_opens_profile(app_server, browser, seeded_db)
pb.wait_for_timeout(1000)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card[data-href]").filter(has_text="started following you").first
card = pa.locator(".notification-card").filter(has_text="started following you").first
card.wait_for(state="visible", timeout=10000)
card.locator(".notification-text").click()
card.locator("a.card-link").click()
pa.wait_for_url("**/profile/bob_test", timeout=10000, wait_until="domcontentloaded")
assert pa.url.endswith("/profile/bob_test"), f"follow notification should open the follower profile: {pa.url}"
@ -465,9 +465,9 @@ def test_message_notification_click_opens_conversation(app_server, browser, seed
pb.wait_for_timeout(1500)
pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pa.locator(".notification-card[data-href]").filter(has_text="sent you a message").first
card = pa.locator(".notification-card").filter(has_text="sent you a message").first
card.wait_for(state="visible", timeout=10000)
card.locator(".notification-text").click()
card.locator("a.card-link").click()
pa.wait_for_url("**/messages**", timeout=10000, wait_until="domcontentloaded")
assert "with_uid=" in pa.url, f"message notification should open the conversation: {pa.url}"
@ -503,9 +503,9 @@ def test_vote_notification_click_opens_target(app_server, browser, seeded_db):
pa.wait_for_timeout(1500)
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
card = pb.locator(".notification-card[data-href]").filter(has_text="++'d").first
card = pb.locator(".notification-card").filter(has_text="++'d").first
card.wait_for(state="visible", timeout=10000)
card.locator(".notification-text").click()
card.locator("a.card-link").click()
pb.wait_for_url(f"**{post_path}", timeout=10000, wait_until="domcontentloaded")
assert post_path in pb.url, f"vote notification should open the voted post: {pb.url}"