This commit is contained in:
parent
c3a8347cc0
commit
503aab26ac
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@ __pycache__/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.env
|
.env
|
||||||
devplace.db*
|
devplace.db*
|
||||||
|
devplace-services.lock
|
||||||
notification-private.pem
|
notification-private.pem
|
||||||
notification-private.pkcs8.pem
|
notification-private.pkcs8.pem
|
||||||
notification-public.pem
|
notification-public.pem
|
||||||
|
|||||||
@ -19,11 +19,11 @@ Open `http://localhost:10500`.
|
|||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Backend | Python 3.13+, FastAPI, Uvicorn (single worker) |
|
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
|
||||||
| Templates | Jinja2 (server-side rendered) |
|
| Templates | Jinja2 (server-side rendered) |
|
||||||
| Frontend | Pure ES6 JavaScript, one class per file |
|
| Frontend | Pure ES6 JavaScript, one class per file |
|
||||||
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
|
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
|
||||||
| Auth | Session cookies, SHA256+SALT via passlib |
|
| Auth | Session cookies, PBKDF2-SHA256 via passlib |
|
||||||
| Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
|
| Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
|
||||||
| Validation | `hawk` (Python/JS/CSS/HTML) |
|
| Validation | `hawk` (Python/JS/CSS/HTML) |
|
||||||
| Load testing | Locust (locustfile.py) |
|
| Load testing | Locust (locustfile.py) |
|
||||||
|
|||||||
@ -13,6 +13,8 @@ SESSION_MAX_AGE = 86400 * 7
|
|||||||
PORT = 10500
|
PORT = 10500
|
||||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
SERVICE_LOCK_FILE = BASE_DIR / "devplace-services.lock"
|
||||||
|
|
||||||
VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem"
|
VAPID_PRIVATE_KEY_FILE = BASE_DIR / "notification-private.pem"
|
||||||
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
|
VAPID_PRIVATE_KEY_PKCS8_FILE = BASE_DIR / "notification-private.pkcs8.pem"
|
||||||
VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem"
|
VAPID_PUBLIC_KEY_FILE = BASE_DIR / "notification-public.pem"
|
||||||
|
|||||||
@ -75,13 +75,18 @@ def delete_content_item(table_name: str, target_type: str, user: dict, slug: str
|
|||||||
item = resolve_by_slug(table, slug)
|
item = resolve_by_slug(table, slug)
|
||||||
if is_owner(item, user):
|
if is_owner(item, user):
|
||||||
delete_target_attachments(target_type, item["uid"])
|
delete_target_attachments(target_type, item["uid"])
|
||||||
|
comment_uids = []
|
||||||
if "comments" in db.tables:
|
if "comments" in db.tables:
|
||||||
comments = get_table("comments")
|
comments = get_table("comments")
|
||||||
for comment in comments.find(target_uid=item["uid"]):
|
for comment in comments.find(target_uid=item["uid"]):
|
||||||
|
comment_uids.append(comment["uid"])
|
||||||
delete_target_attachments("comment", comment["uid"])
|
delete_target_attachments("comment", comment["uid"])
|
||||||
comments.delete(target_uid=item["uid"])
|
comments.delete(target_uid=item["uid"])
|
||||||
if "votes" in db.tables:
|
if "votes" in db.tables:
|
||||||
get_table("votes").delete(target_uid=item["uid"])
|
votes = get_table("votes")
|
||||||
|
votes.delete(target_uid=item["uid"])
|
||||||
|
if comment_uids:
|
||||||
|
votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment")
|
||||||
if inline_image_field:
|
if inline_image_field:
|
||||||
delete_inline_image(item.get(inline_image_field))
|
delete_inline_image(item.get(inline_image_field))
|
||||||
table.delete(id=item["id"])
|
table.delete(id=item["id"])
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import fcntl
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@ -7,7 +8,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from devplacepy.config import STATIC_DIR, PORT
|
from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE
|
||||||
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
|
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user, time_ago
|
from devplacepy.utils import get_current_user, time_ago
|
||||||
@ -26,6 +27,20 @@ _rate_limit_store = defaultdict(list)
|
|||||||
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
|
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
|
||||||
RATE_WINDOW = 60
|
RATE_WINDOW = 60
|
||||||
|
|
||||||
|
_service_lock_handle = None
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_service_lock() -> bool:
|
||||||
|
global _service_lock_handle
|
||||||
|
handle = open(SERVICE_LOCK_FILE, "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
handle.close()
|
||||||
|
return False
|
||||||
|
_service_lock_handle = handle
|
||||||
|
return True
|
||||||
|
|
||||||
class UploadStaticFiles(StaticFiles):
|
class UploadStaticFiles(StaticFiles):
|
||||||
async def get_response(self, path, scope):
|
async def get_response(self, path, scope):
|
||||||
response = await super().get_response(path, scope)
|
response = await super().get_response(path, scope)
|
||||||
@ -145,9 +160,12 @@ async def startup():
|
|||||||
from devplacepy.push import ensure_certificates
|
from devplacepy.push import ensure_certificates
|
||||||
ensure_certificates()
|
ensure_certificates()
|
||||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||||
news_service = NewsService()
|
if acquire_service_lock():
|
||||||
service_manager.register(news_service)
|
service_manager.register(NewsService())
|
||||||
asyncio.create_task(service_manager.start_all())
|
asyncio.create_task(service_manager.start_all())
|
||||||
|
logger.info(f"Background services started in worker pid {os.getpid()}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Worker pid {os.getpid()} declined service lock; another worker owns background services")
|
||||||
logger.info(f"DevPlace started on port {PORT}")
|
logger.info(f"DevPlace started on port {PORT}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
|
|||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge
|
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache
|
||||||
from devplacepy.seo import base_seo_context
|
from devplacepy.seo import base_seo_context
|
||||||
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
|
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
|
||||||
|
|
||||||
@ -205,6 +205,7 @@ async def logout(request: Request):
|
|||||||
session = sessions.find_one(session_token=token)
|
session = sessions.find_one(session_token=token)
|
||||||
if session:
|
if session:
|
||||||
sessions.delete(id=session["id"])
|
sessions.delete(id=session["id"])
|
||||||
|
clear_session_cache(token)
|
||||||
response = RedirectResponse(url="/", status_code=302)
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
response.delete_cookie("session")
|
response.delete_cookie("session")
|
||||||
return response
|
return response
|
||||||
|
|||||||
@ -152,6 +152,9 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
|
|||||||
content = data.content.strip()
|
content = data.content.strip()
|
||||||
receiver_uid = data.receiver_uid
|
receiver_uid = data.receiver_uid
|
||||||
|
|
||||||
|
if not get_table("users").find_one(uid=receiver_uid):
|
||||||
|
return RedirectResponse(url="/messages", status_code=302)
|
||||||
|
|
||||||
messages_table = get_table("messages")
|
messages_table = get_table("messages")
|
||||||
msg_uid = generate_uid()
|
msg_uid = generate_uid()
|
||||||
messages_table.insert({
|
messages_table.insert({
|
||||||
|
|||||||
@ -39,8 +39,8 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
|||||||
})
|
})
|
||||||
did_upvote = value == 1
|
did_upvote = value == 1
|
||||||
|
|
||||||
up_count = votes.count(target_uid=target_uid, value=1)
|
up_count = votes.count(target_uid=target_uid, target_type=target_type, value=1)
|
||||||
down_count = votes.count(target_uid=target_uid, value=-1)
|
down_count = votes.count(target_uid=target_uid, target_type=target_type, value=-1)
|
||||||
net = up_count - down_count
|
net = up_count - down_count
|
||||||
|
|
||||||
update_target_stars(target_type, target_uid, net)
|
update_target_stars(target_type, target_uid, net)
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
export class Avatar {
|
export class Avatar {
|
||||||
static imgHtml(username, size = 24) {
|
static imgElement(username, size = 24) {
|
||||||
const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`;
|
const img = document.createElement("img");
|
||||||
return `<img src="${url}" class="avatar-img" style="width:${size}px;height:${size}px;border-radius:50%" alt="" loading="lazy">`;
|
img.src = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`;
|
||||||
|
img.className = "avatar-img";
|
||||||
|
img.style.width = `${size}px`;
|
||||||
|
img.style.height = `${size}px`;
|
||||||
|
img.style.borderRadius = "50%";
|
||||||
|
img.alt = "";
|
||||||
|
img.loading = "lazy";
|
||||||
|
return img;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -75,7 +75,9 @@ export class MentionInput {
|
|||||||
item.type = "button";
|
item.type = "button";
|
||||||
item.className = "mention-dropdown-item";
|
item.className = "mention-dropdown-item";
|
||||||
item.dataset.username = r.username;
|
item.dataset.username = r.username;
|
||||||
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>";
|
const label = document.createElement("span");
|
||||||
|
label.textContent = "@" + r.username;
|
||||||
|
item.append(Avatar.imgElement(r.username), label);
|
||||||
item.addEventListener("mousedown", (e) => {
|
item.addEventListener("mousedown", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.insert(r.username);
|
this.insert(r.username);
|
||||||
|
|||||||
@ -41,7 +41,9 @@ export class MessageSearch {
|
|||||||
const item = document.createElement("a");
|
const item = document.createElement("a");
|
||||||
item.className = "search-dropdown-item";
|
item.className = "search-dropdown-item";
|
||||||
item.href = `/messages?with_uid=${r.uid}`;
|
item.href = `/messages?with_uid=${r.uid}`;
|
||||||
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
|
const label = document.createElement("span");
|
||||||
|
label.textContent = r.username;
|
||||||
|
item.append(Avatar.imgElement(r.username), label);
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
}
|
}
|
||||||
DomUtils.show(dropdown);
|
DomUtils.show(dropdown);
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export class ProfileEditor {
|
|||||||
const tag = document.createElement("span");
|
const tag = document.createElement("span");
|
||||||
tag.className = "platform-tag";
|
tag.className = "platform-tag";
|
||||||
tag.textContent = val;
|
tag.textContent = val;
|
||||||
|
tag.dataset.value = val;
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "x";
|
remove.textContent = "x";
|
||||||
@ -57,7 +58,7 @@ export class ProfileEditor {
|
|||||||
const updatePlatforms = () => {
|
const updatePlatforms = () => {
|
||||||
const values = [];
|
const values = [];
|
||||||
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
|
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
|
||||||
values.push(t.textContent.replace("x", "").trim());
|
values.push(t.dataset.value);
|
||||||
});
|
});
|
||||||
hiddenInput.value = values.join(",");
|
hiddenInput.value = values.join(",");
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
|
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
|
||||||
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
|
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
<a href="/profile/{{ item.author['username'] if item.author else '#' }}" class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</a>
|
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
|
||||||
<span class="comment-time">{{ item.time_ago }}</span>
|
<span class="comment-time">{{ item.time_ago }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>
|
<div class="comment-text rendered-content" data-render>{{ item.comment['content'] }}</div>
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
<form class="feed-comment-form" method="POST" action="/comments/create">
|
<form class="feed-comment-form" method="POST" action="/comments/create">
|
||||||
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
||||||
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
|
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img avatar-24" alt="" loading="lazy">
|
||||||
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
|
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off" data-mention>
|
||||||
<button type="submit" class="feed-comment-submit">Post</button>
|
<button type="submit" class="feed-comment-submit">Post</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||||
<div class="post-author-wrap">
|
<div class="post-author-wrap">
|
||||||
<a href="/profile/{{ _author['username'] if _author else '#' }}" class="post-author-link">{{ _author['username'] if _author else 'Unknown' }}</a>
|
{% set _user = _author %}{% set _class = "post-author-link" %}{% include "_user_link.html" %}
|
||||||
{% if _author and _author.get('role') %}
|
{% if _author and _author.get('role') %}
|
||||||
<span class="post-author-role">{{ _author['role'] }}</span>
|
<span class="post-author-role">{{ _author['role'] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
5
devplacepy/templates/_user_link.html
Normal file
5
devplacepy/templates/_user_link.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{% if _user %}
|
||||||
|
<a href="/profile/{{ _user['username'] }}"{% if _class %} class="{{ _class }}"{% endif %}>{{ _user['username'] }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="#"{% if _class %} class="{{ _class }}"{% endif %}>Unknown</a>
|
||||||
|
{% endif %}
|
||||||
@ -105,7 +105,7 @@
|
|||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
|
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
|
||||||
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
|
{% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %}
|
||||||
</span>
|
</span>
|
||||||
<span class="value">{{ author.get('stars', 0) }}</span>
|
<span class="value">{{ author.get('stars', 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<div class="gist-detail-author">
|
<div class="gist-detail-author">
|
||||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||||
<div>
|
<div>
|
||||||
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
|
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||||
{% if author and author.get('role') %}
|
{% if author and author.get('role') %}
|
||||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -50,7 +50,7 @@
|
|||||||
{% set _size_class = "sm" %}
|
{% set _size_class = "sm" %}
|
||||||
{% include "_avatar_link.html" %}
|
{% include "_avatar_link.html" %}
|
||||||
<div class="landing-post-author-wrap">
|
<div class="landing-post-author-wrap">
|
||||||
<a href="/profile/{{ item.author['username'] }}" class="landing-post-author">{{ item.author['username'] }}</a>
|
{% set _user = item.author %}{% set _class = "landing-post-author" %}{% include "_user_link.html" %}
|
||||||
<span class="landing-post-time">{{ item.time_ago }}</span>
|
<span class="landing-post-time">{{ item.time_ago }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
|
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
|
{% set _user = author %}{% set _size = 20 %}{% set _size_class = "xs" %}{% include "_avatar_link.html" %}
|
||||||
<a href="/profile/{{ author['username'] }}" class="top-author-name">{{ author['username'] }}</a>
|
{% set _user = author %}{% set _class = "top-author-name" %}{% include "_user_link.html" %}
|
||||||
</span>
|
</span>
|
||||||
<span class="value">{{ author.get('stars', 0) }}</span>
|
<span class="value">{{ author.get('stars', 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -52,7 +52,7 @@
|
|||||||
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
|
<li class="leaderboard-row{% if user and entry['uid'] == user['uid'] %} leaderboard-row-self{% endif %}">
|
||||||
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
|
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
|
||||||
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||||
<a href="/profile/{{ entry['username'] }}" class="leaderboard-name">{{ entry['username'] }}</a>
|
{% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %}
|
||||||
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
|
<span class="leaderboard-level">Level {{ entry.get('level', 1) }}</span>
|
||||||
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">★</span></span>
|
<span class="leaderboard-stars">{{ entry['stars'] }} <span class="leaderboard-star-icon">★</span></span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
<a href="/profile/{{ other_user['username'] }}">
|
<a href="/profile/{{ other_user['username'] }}">
|
||||||
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy">
|
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
<a href="/profile/{{ other_user['username'] }}"><h3>{{ other_user['username'] }}</h3></a>
|
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="messages-thread">
|
<div class="messages-thread">
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
<img src="{{ avatar_url('multiavatar', author['username'] if author else '?', 40) }}" class="avatar-img avatar-md" alt="{{ author['username'] if author else '?' }}" loading="lazy">
|
<img src="{{ avatar_url('multiavatar', author['username'] if author else '?', 40) }}" class="avatar-img avatar-md" alt="{{ author['username'] if author else '?' }}" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<a href="/profile/{{ author['username'] if author else '#' }}" class="post-detail-author">{{ author['username'] if author else 'Unknown' }}</a>
|
{% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %}
|
||||||
{% if author and author.get('role') %}
|
{% if author and author.get('role') %}
|
||||||
<span class="post-detail-role">· {{ author['role'] }}</span>
|
<span class="post-detail-role">· {{ author['role'] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
<div class="project-detail-author">
|
<div class="project-detail-author">
|
||||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||||
<div>
|
<div>
|
||||||
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
|
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||||
{% if author and author.get('role') %}
|
{% if author and author.get('role') %}
|
||||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -43,6 +43,10 @@ def clear_user_cache(user_uid: str) -> None:
|
|||||||
_user_cache.pop(token)
|
_user_cache.pop(token)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_cache(token: str) -> None:
|
||||||
|
_user_cache.pop(token)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request):
|
def get_current_user(request: Request):
|
||||||
token = request.cookies.get("session")
|
token = request.cookies.get("session")
|
||||||
if not token:
|
if not token:
|
||||||
|
|||||||
220
locustfile.py
220
locustfile.py
@ -23,6 +23,7 @@ NEWS_UIDS = []
|
|||||||
NEWS_SLUGS = []
|
NEWS_SLUGS = []
|
||||||
GIST_SLUGS = []
|
GIST_SLUGS = []
|
||||||
GIST_UIDS = []
|
GIST_UIDS = []
|
||||||
|
BUG_UIDS = []
|
||||||
NOTIFICATION_UIDS = []
|
NOTIFICATION_UIDS = []
|
||||||
ADMIN_USER = {}
|
ADMIN_USER = {}
|
||||||
ADMIN_TARGETS = {}
|
ADMIN_TARGETS = {}
|
||||||
@ -30,10 +31,6 @@ TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
|
|||||||
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
||||||
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
|
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
|
||||||
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
|
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
|
||||||
AVATAR_STYLES = [
|
|
||||||
"adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon",
|
|
||||||
"initials", "lorelei", "micah", "open-peeps", "pixel-art", "shapes",
|
|
||||||
]
|
|
||||||
UUID_RE = r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
|
UUID_RE = r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
|
||||||
PASSWORD = "testpass123"
|
PASSWORD = "testpass123"
|
||||||
|
|
||||||
@ -182,6 +179,20 @@ def seed_data(environment, **kwargs):
|
|||||||
|
|
||||||
logger.info(f"Created {len(GIST_SLUGS)} seed gists")
|
logger.info(f"Created {len(GIST_SLUGS)} seed gists")
|
||||||
|
|
||||||
|
for seed in SEED_USERS[:2]:
|
||||||
|
try:
|
||||||
|
opener = logged_in_opener(seed["email"])
|
||||||
|
body = urllib.parse.urlencode({
|
||||||
|
"title": f"Bug {uuid.uuid4().hex[:6]}",
|
||||||
|
"description": "Seed bug report for load testing.",
|
||||||
|
}).encode()
|
||||||
|
opener.open(
|
||||||
|
urllib.request.Request(f"{host}/bugs/create", data=body),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Create seed bug failed: {e}")
|
||||||
|
|
||||||
# ── Seed news articles (direct DB insert) ───────────────────
|
# ── Seed news articles (direct DB insert) ───────────────────
|
||||||
try:
|
try:
|
||||||
from devplacepy.database import get_table, init_db
|
from devplacepy.database import get_table, init_db
|
||||||
@ -298,11 +309,20 @@ def seed_data(environment, **kwargs):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Harvest comment UIDs failed: {e}")
|
logger.warning(f"Harvest comment UIDs failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
opener = logged_in_opener(SEED_USERS[0]["email"])
|
||||||
|
resp = opener.open(urllib.request.Request(f"{host}/bugs"), timeout=10)
|
||||||
|
html = resp.read().decode("utf-8", errors="replace")
|
||||||
|
bug_matches = re.findall(rf'/votes/bug/{uuid_pat}', html)
|
||||||
|
BUG_UIDS.extend(b for b in bug_matches if b not in BUG_UIDS)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Harvest bug UIDs failed: {e}")
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
|
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
|
||||||
f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
|
f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
|
||||||
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
|
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
|
||||||
f"{len(USER_UIDS)} uids"
|
f"{len(BUG_UIDS)} bugs, {len(USER_UIDS)} uids"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -334,7 +354,11 @@ class DevPlaceUser(HttpUser):
|
|||||||
params = {"tab": tab}
|
params = {"tab": tab}
|
||||||
if topic:
|
if topic:
|
||||||
params["topic"] = topic
|
params["topic"] = topic
|
||||||
self.client.get("/feed", params=params, name="feed")
|
resp = self.client.get("/feed", params=params, name="feed")
|
||||||
|
cursor = re.search(r'/feed\?before=([^"&]+)', resp.text)
|
||||||
|
if cursor:
|
||||||
|
params["before"] = cursor.group(1)
|
||||||
|
self.client.get("/feed", params=params, name="feed?before")
|
||||||
|
|
||||||
@task(4)
|
@task(4)
|
||||||
def view_post(self):
|
def view_post(self):
|
||||||
@ -350,14 +374,19 @@ class DevPlaceUser(HttpUser):
|
|||||||
if not ALL_USERNAMES:
|
if not ALL_USERNAMES:
|
||||||
return
|
return
|
||||||
self.client.get(
|
self.client.get(
|
||||||
f"/profile/{random.choice(ALL_USERNAMES)}", name="profile/[username]"
|
f"/profile/{random.choice(ALL_USERNAMES)}",
|
||||||
|
params={"tab": random.choice(["posts", "activity"])},
|
||||||
|
name="profile/[username]",
|
||||||
)
|
)
|
||||||
|
|
||||||
@task(3)
|
@task(3)
|
||||||
def view_projects(self):
|
def view_projects(self):
|
||||||
self.client.get("/projects", params={
|
params = {"tab": random.choice(["recent", "released", "popular", "new"])}
|
||||||
"tab": random.choice(["recent", "released", "popular", "new"]),
|
if random.random() < 0.3:
|
||||||
}, name="projects")
|
params["project_type"] = random.choice(PROJECT_TYPES)
|
||||||
|
if random.random() < 0.2 and USER_UIDS:
|
||||||
|
params["user_uid"] = random.choice(USER_UIDS)
|
||||||
|
self.client.get("/projects", params=params, name="projects")
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def view_landing(self):
|
def view_landing(self):
|
||||||
@ -396,7 +425,23 @@ class DevPlaceUser(HttpUser):
|
|||||||
|
|
||||||
@task(2)
|
@task(2)
|
||||||
def view_gists(self):
|
def view_gists(self):
|
||||||
self.client.get("/gists", name="gists")
|
params = {}
|
||||||
|
if random.random() < 0.3:
|
||||||
|
params["language"] = random.choice(GIST_LANGUAGES)
|
||||||
|
if random.random() < 0.2 and USER_UIDS:
|
||||||
|
params["user_uid"] = random.choice(USER_UIDS)
|
||||||
|
self.client.get("/gists", params=params, name="gists")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def view_leaderboard(self):
|
||||||
|
self.client.get("/leaderboard", name="leaderboard")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def view_pwa(self):
|
||||||
|
self.client.get(
|
||||||
|
random.choice(["/push.json", "/service-worker.js", "/manifest.json"]),
|
||||||
|
name="pwa",
|
||||||
|
)
|
||||||
|
|
||||||
@task(2)
|
@task(2)
|
||||||
def view_gist_detail(self):
|
def view_gist_detail(self):
|
||||||
@ -551,45 +596,58 @@ class DevPlaceUser(HttpUser):
|
|||||||
|
|
||||||
# ── social interaction ──────────────────────────────────────
|
# ── social interaction ──────────────────────────────────────
|
||||||
|
|
||||||
|
def _vote(self, target_type, uid):
|
||||||
|
data = {"value": random.choice([1, -1])}
|
||||||
|
name = f"votes/{target_type}"
|
||||||
|
if random.random() < 0.5:
|
||||||
|
self.client.post(
|
||||||
|
f"/votes/{target_type}/{uid}", data=data, name=name
|
||||||
|
)
|
||||||
|
return
|
||||||
|
with self.client.post(
|
||||||
|
f"/votes/{target_type}/{uid}", data=data,
|
||||||
|
headers={"x-requested-with": "fetch"},
|
||||||
|
catch_response=True, name=f"{name}/ajax",
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
resp.failure("vote ajax response not JSON")
|
||||||
|
return
|
||||||
|
if all(k in payload for k in ("net", "up", "down", "value")):
|
||||||
|
resp.success()
|
||||||
|
else:
|
||||||
|
resp.failure(f"vote ajax missing keys: {payload}")
|
||||||
|
|
||||||
@task(2)
|
@task(2)
|
||||||
def vote_on_post(self):
|
def vote_on_post(self):
|
||||||
if not POST_UIDS:
|
if not POST_UIDS:
|
||||||
return
|
return
|
||||||
self.client.post(
|
self._vote("post", random.choice(POST_UIDS))
|
||||||
f"/votes/post/{random.choice(POST_UIDS)}",
|
|
||||||
data={"value": random.choice([1, -1])},
|
|
||||||
name="votes/post",
|
|
||||||
)
|
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def vote_on_project(self):
|
def vote_on_project(self):
|
||||||
if not PROJECT_UIDS:
|
if not PROJECT_UIDS:
|
||||||
return
|
return
|
||||||
self.client.post(
|
self._vote("project", random.choice(PROJECT_UIDS))
|
||||||
f"/votes/project/{random.choice(PROJECT_UIDS)}",
|
|
||||||
data={"value": random.choice([1, -1])},
|
|
||||||
name="votes/project",
|
|
||||||
)
|
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def vote_on_comment(self):
|
def vote_on_comment(self):
|
||||||
if not COMMENT_UIDS:
|
if not COMMENT_UIDS:
|
||||||
return
|
return
|
||||||
self.client.post(
|
self._vote("comment", random.choice(COMMENT_UIDS))
|
||||||
f"/votes/comment/{random.choice(COMMENT_UIDS)}",
|
|
||||||
data={"value": random.choice([1, -1])},
|
|
||||||
name="votes/comment",
|
|
||||||
)
|
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def vote_on_gist(self):
|
def vote_on_gist(self):
|
||||||
if not GIST_UIDS:
|
if not GIST_UIDS:
|
||||||
return
|
return
|
||||||
self.client.post(
|
self._vote("gist", random.choice(GIST_UIDS))
|
||||||
f"/votes/gist/{random.choice(GIST_UIDS)}",
|
|
||||||
data={"value": random.choice([1, -1])},
|
@task(1)
|
||||||
name="votes/gist",
|
def vote_on_bug(self):
|
||||||
)
|
if not BUG_UIDS:
|
||||||
|
return
|
||||||
|
self._vote("bug", random.choice(BUG_UIDS))
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def follow_user(self):
|
def follow_user(self):
|
||||||
@ -632,6 +690,15 @@ class DevPlaceUser(HttpUser):
|
|||||||
def view_messages(self):
|
def view_messages(self):
|
||||||
self.client.get("/messages", name="messages")
|
self.client.get("/messages", name="messages")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def view_conversation(self):
|
||||||
|
if not USER_UIDS:
|
||||||
|
return
|
||||||
|
self.client.get(
|
||||||
|
"/messages", params={"with_uid": random.choice(USER_UIDS)},
|
||||||
|
name="messages?with_uid",
|
||||||
|
)
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def view_notifications(self):
|
def view_notifications(self):
|
||||||
resp = self.client.get("/notifications", name="notifications")
|
resp = self.client.get("/notifications", name="notifications")
|
||||||
@ -639,6 +706,21 @@ class DevPlaceUser(HttpUser):
|
|||||||
NOTIFICATION_UIDS.extend(
|
NOTIFICATION_UIDS.extend(
|
||||||
m for m in matches if m not in NOTIFICATION_UIDS
|
m for m in matches if m not in NOTIFICATION_UIDS
|
||||||
)
|
)
|
||||||
|
cursor = re.search(r'/notifications\?before=([^"]+)', resp.text)
|
||||||
|
if cursor:
|
||||||
|
self.client.get(
|
||||||
|
"/notifications", params={"before": cursor.group(1)},
|
||||||
|
name="notifications?before",
|
||||||
|
)
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def open_notification(self):
|
||||||
|
if not NOTIFICATION_UIDS:
|
||||||
|
return
|
||||||
|
self.client.get(
|
||||||
|
f"/notifications/open/{random.choice(NOTIFICATION_UIDS)}",
|
||||||
|
name="notifications/open",
|
||||||
|
)
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def mark_all_notifications_read(self):
|
def mark_all_notifications_read(self):
|
||||||
@ -741,14 +823,45 @@ class DevPlaceUser(HttpUser):
|
|||||||
else:
|
else:
|
||||||
resp.failure(f"upload failed: status={resp.status_code}")
|
resp.failure(f"upload failed: status={resp.status_code}")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def register_push(self):
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
body = {
|
||||||
|
"endpoint": f"https://push.locust/endpoint/{token}",
|
||||||
|
"keys": {
|
||||||
|
"auth": uuid.uuid4().hex[:22],
|
||||||
|
"p256dh": (uuid.uuid4().hex + uuid.uuid4().hex)[:43],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with self.client.post(
|
||||||
|
"/push.json", json=body,
|
||||||
|
catch_response=True, name="push.json/register",
|
||||||
|
) as resp:
|
||||||
|
if resp.status_code in (200, 400):
|
||||||
|
resp.success()
|
||||||
|
else:
|
||||||
|
resp.failure(f"push register: status={resp.status_code}")
|
||||||
|
|
||||||
# ── static / media ──────────────────────────────────────────
|
# ── static / media ──────────────────────────────────────────
|
||||||
|
|
||||||
@task(2)
|
@task(2)
|
||||||
def view_multiavatar(self):
|
def view_multiavatar(self):
|
||||||
seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
|
seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
|
||||||
self.client.get(
|
resp = self.client.get(
|
||||||
f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar"
|
f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar"
|
||||||
)
|
)
|
||||||
|
etag = resp.headers.get("ETag")
|
||||||
|
if not etag:
|
||||||
|
return
|
||||||
|
with self.client.get(
|
||||||
|
f"/avatar/multiavatar/{seed}?size=32",
|
||||||
|
headers={"If-None-Match": etag},
|
||||||
|
catch_response=True, name="avatar/multiavatar/304",
|
||||||
|
) as cached:
|
||||||
|
if cached.status_code == 304:
|
||||||
|
cached.success()
|
||||||
|
else:
|
||||||
|
cached.failure(f"expected 304, got {cached.status_code}")
|
||||||
|
|
||||||
@task(1)
|
@task(1)
|
||||||
def comment_on_news(self):
|
def comment_on_news(self):
|
||||||
@ -760,6 +873,49 @@ class DevPlaceUser(HttpUser):
|
|||||||
"target_type": "news",
|
"target_type": "news",
|
||||||
}, name="comments/news")
|
}, name="comments/news")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def comment_on_target(self):
|
||||||
|
pools = []
|
||||||
|
if PROJECT_UIDS:
|
||||||
|
pools.append(("project", PROJECT_UIDS))
|
||||||
|
if GIST_UIDS:
|
||||||
|
pools.append(("gist", GIST_UIDS))
|
||||||
|
if BUG_UIDS:
|
||||||
|
pools.append(("bug", BUG_UIDS))
|
||||||
|
if not pools:
|
||||||
|
return
|
||||||
|
target_type, uids = random.choice(pools)
|
||||||
|
self.client.post("/comments/create", data={
|
||||||
|
"content": f"{target_type} comment {uuid.uuid4().hex[:6]} " * 2,
|
||||||
|
"target_uid": random.choice(uids),
|
||||||
|
"target_type": target_type,
|
||||||
|
}, name=f"comments/{target_type}")
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def reply_to_comment(self):
|
||||||
|
if not POST_UIDS or not COMMENT_UIDS:
|
||||||
|
return
|
||||||
|
self.client.post("/comments/create", data={
|
||||||
|
"content": f"Reply {uuid.uuid4().hex[:6]} " * 2,
|
||||||
|
"post_uid": random.choice(POST_UIDS),
|
||||||
|
"parent_uid": random.choice(COMMENT_UIDS),
|
||||||
|
}, name="comments/reply")
|
||||||
|
|
||||||
|
@task(2)
|
||||||
|
def browse_and_engage(self):
|
||||||
|
slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
|
||||||
|
if not slugs:
|
||||||
|
return
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/posts/{random.choice(slugs)}", name="posts/[uid]"
|
||||||
|
)
|
||||||
|
vote_uid = re.search(rf'/votes/post/({UUID_RE})', resp.text)
|
||||||
|
if vote_uid:
|
||||||
|
self._vote("post", vote_uid.group(1))
|
||||||
|
comment_uid = re.search(rf'/comments/delete/({UUID_RE})', resp.text)
|
||||||
|
if comment_uid and comment_uid.group(1) not in COMMENT_UIDS:
|
||||||
|
COMMENT_UIDS.append(comment_uid.group(1))
|
||||||
|
|
||||||
|
|
||||||
class AdminUser(HttpUser):
|
class AdminUser(HttpUser):
|
||||||
weight = 1
|
weight = 1
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from playwright.sync_api import expect
|
||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.utils import hash_password, generate_uid
|
from devplacepy.utils import hash_password, generate_uid
|
||||||
@ -99,8 +100,7 @@ def test_signup_existing_username(page, app_server):
|
|||||||
page.fill("#password", "secret123")
|
page.fill("#password", "secret123")
|
||||||
page.fill("#confirm_password", "secret123")
|
page.fill("#confirm_password", "secret123")
|
||||||
page.click("button:has-text('Create account')")
|
page.click("button:has-text('Create account')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Username already taken")).to_be_visible()
|
||||||
assert page.is_visible("text=Username already taken")
|
|
||||||
|
|
||||||
|
|
||||||
def test_signup_existing_email(page, app_server):
|
def test_signup_existing_email(page, app_server):
|
||||||
@ -119,8 +119,7 @@ def test_signup_existing_email(page, app_server):
|
|||||||
page.fill("#password", "secret123")
|
page.fill("#password", "secret123")
|
||||||
page.fill("#confirm_password", "secret123")
|
page.fill("#confirm_password", "secret123")
|
||||||
page.click("button:has-text('Create account')")
|
page.click("button:has-text('Create account')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Email already registered")).to_be_visible()
|
||||||
assert page.is_visible("text=Email already registered")
|
|
||||||
|
|
||||||
|
|
||||||
def test_signup_password_mismatch(page, app_server):
|
def test_signup_password_mismatch(page, app_server):
|
||||||
@ -130,8 +129,7 @@ def test_signup_password_mismatch(page, app_server):
|
|||||||
page.fill("#password", "secret123")
|
page.fill("#password", "secret123")
|
||||||
page.fill("#confirm_password", "different456")
|
page.fill("#confirm_password", "different456")
|
||||||
page.click("button:has-text('Create account')")
|
page.click("button:has-text('Create account')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Passwords do not match")).to_be_visible()
|
||||||
assert page.is_visible("text=Passwords do not match")
|
|
||||||
|
|
||||||
|
|
||||||
def test_signup_short_password(page, app_server):
|
def test_signup_short_password(page, app_server):
|
||||||
@ -141,8 +139,7 @@ def test_signup_short_password(page, app_server):
|
|||||||
page.fill("#password", "ab")
|
page.fill("#password", "ab")
|
||||||
page.fill("#confirm_password", "ab")
|
page.fill("#confirm_password", "ab")
|
||||||
page.click("button:has-text('Create account')")
|
page.click("button:has-text('Create account')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Password must be at least 6 characters")).to_be_visible()
|
||||||
assert page.is_visible("text=Password must be at least 6 characters")
|
|
||||||
|
|
||||||
|
|
||||||
def test_signup_invalid_username(page, app_server):
|
def test_signup_invalid_username(page, app_server):
|
||||||
@ -152,8 +149,7 @@ def test_signup_invalid_username(page, app_server):
|
|||||||
page.fill("#password", "secret123")
|
page.fill("#password", "secret123")
|
||||||
page.fill("#confirm_password", "secret123")
|
page.fill("#confirm_password", "secret123")
|
||||||
page.click("button:has-text('Create account')")
|
page.click("button:has-text('Create account')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Username must be between 3 and 32 characters")).to_be_visible()
|
||||||
assert page.is_visible("text=Username must be between 3 and 32 characters")
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_page_loads(page, app_server):
|
def test_login_page_loads(page, app_server):
|
||||||
@ -198,8 +194,7 @@ def test_login_wrong_password(page, app_server):
|
|||||||
page.fill("#email", "wrongpw@test.devplace")
|
page.fill("#email", "wrongpw@test.devplace")
|
||||||
page.fill("#password", "badpassword")
|
page.fill("#password", "badpassword")
|
||||||
page.click("button:has-text('Sign in')")
|
page.click("button:has-text('Sign in')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Invalid email or password")).to_be_visible()
|
||||||
assert page.is_visible("text=Invalid email or password")
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_nonexistent_email(page, app_server):
|
def test_login_nonexistent_email(page, app_server):
|
||||||
@ -207,8 +202,7 @@ def test_login_nonexistent_email(page, app_server):
|
|||||||
page.fill("#email", "nobody@nowhere.devplace")
|
page.fill("#email", "nobody@nowhere.devplace")
|
||||||
page.fill("#password", "secret123")
|
page.fill("#password", "secret123")
|
||||||
page.click("button:has-text('Sign in')")
|
page.click("button:has-text('Sign in')")
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator("text=Invalid email or password")).to_be_visible()
|
||||||
assert page.is_visible("text=Invalid email or password")
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_remember_me(page, app_server):
|
def test_login_remember_me(page, app_server):
|
||||||
|
|||||||
@ -171,19 +171,24 @@ def test_feed_inline_comment(alice):
|
|||||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
inline_form = page.locator(".feed-comment-form").first
|
inline_form = page.locator(".feed-comment-form").first
|
||||||
if inline_form.is_visible():
|
expect(inline_form).to_be_visible()
|
||||||
inline_form.locator("input[name='content']").fill("Inline comment from feed")
|
inline_form.locator("input[name='content']").fill("Inline comment from feed")
|
||||||
inline_form.locator(".feed-comment-submit").click()
|
inline_form.locator(".feed-comment-submit").click()
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||||
|
expect(page.locator(".comment-text:has-text('Inline comment from feed')")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
def test_feed_inline_comment_placeholder(alice):
|
def test_feed_inline_comment_placeholder(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
inline_input = page.locator(".feed-comment-form input").first
|
page.locator(".feed-fab").first.click()
|
||||||
if inline_input.is_visible():
|
page.fill("#post-content", "Placeholder check post " + "x" * 20)
|
||||||
placeholder = inline_input.get_attribute("placeholder")
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||||
assert placeholder == "Your opinion goes here..."
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||||
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
|
inline_input = page.locator(".feed-comment-form input[name='content']").first
|
||||||
|
expect(inline_input).to_be_visible()
|
||||||
|
assert inline_input.get_attribute("placeholder") == "Your opinion goes here..."
|
||||||
|
|
||||||
|
|
||||||
def test_feed_signals_topic(alice):
|
def test_feed_signals_topic(alice):
|
||||||
|
|||||||
@ -77,9 +77,9 @@ def test_comment_voted_state_persists(alice):
|
|||||||
textarea = page.locator(".comment-form textarea[name='content']")
|
textarea = page.locator(".comment-form textarea[name='content']")
|
||||||
textarea.fill("Comment whose vote should persist")
|
textarea.fill("Comment whose vote should persist")
|
||||||
page.locator(".comment-form button:has-text('Post')").click()
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
page.wait_for_timeout(500)
|
expect(page.locator(".comment-text:has-text('Comment whose vote should persist')")).to_be_visible()
|
||||||
page.locator(".comment-vote-btn").first.click()
|
page.locator(".comment-vote-btn").first.click()
|
||||||
page.wait_for_timeout(500)
|
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||||
page.reload(wait_until="domcontentloaded")
|
page.reload(wait_until="domcontentloaded")
|
||||||
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
|
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||||
|
|
||||||
@ -113,8 +113,7 @@ def test_add_comment(alice):
|
|||||||
textarea = page.locator(".comment-form textarea[name='content']")
|
textarea = page.locator(".comment-form textarea[name='content']")
|
||||||
textarea.fill("This is a test comment from Playwright")
|
textarea.fill("This is a test comment from Playwright")
|
||||||
page.locator(".comment-form button:has-text('Post')").click()
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
page.wait_for_timeout(500)
|
expect(page.locator(".comment-text:has-text('This is a test comment from Playwright')")).to_be_visible()
|
||||||
assert page.is_visible("text=This is a test comment from Playwright")
|
|
||||||
|
|
||||||
|
|
||||||
def test_comment_voting(alice):
|
def test_comment_voting(alice):
|
||||||
@ -123,11 +122,11 @@ def test_comment_voting(alice):
|
|||||||
textarea = page.locator(".comment-form textarea[name='content']")
|
textarea = page.locator(".comment-form textarea[name='content']")
|
||||||
textarea.fill("Votable comment")
|
textarea.fill("Votable comment")
|
||||||
page.locator(".comment-form button:has-text('Post')").click()
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
page.wait_for_timeout(500)
|
expect(page.locator(".comment-text:has-text('Votable comment')")).to_be_visible()
|
||||||
vote_btns = page.locator(".comment-vote-btn")
|
vote_btns = page.locator(".comment-vote-btn")
|
||||||
upvote = vote_btns.first
|
upvote = vote_btns.first
|
||||||
upvote.click()
|
upvote.click()
|
||||||
page.wait_for_timeout(500)
|
expect(vote_btns.first).to_have_class(re.compile(r"\bvoted\b"))
|
||||||
|
|
||||||
|
|
||||||
def test_delete_own_comment(alice):
|
def test_delete_own_comment(alice):
|
||||||
@ -136,11 +135,12 @@ def test_delete_own_comment(alice):
|
|||||||
textarea = page.locator(".comment-form textarea[name='content']")
|
textarea = page.locator(".comment-form textarea[name='content']")
|
||||||
textarea.fill("Comment to delete")
|
textarea.fill("Comment to delete")
|
||||||
page.locator(".comment-form button:has-text('Post')").click()
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
page.wait_for_timeout(500)
|
comment = page.locator(".comment-text:has-text('Comment to delete')")
|
||||||
|
expect(comment).to_be_visible()
|
||||||
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
||||||
if delete_btn.is_visible():
|
expect(delete_btn).to_be_visible()
|
||||||
delete_btn.click()
|
delete_btn.click()
|
||||||
page.wait_for_timeout(500)
|
expect(comment).to_have_count(0)
|
||||||
|
|
||||||
|
|
||||||
def test_comment_form_elements(alice):
|
def test_comment_form_elements(alice):
|
||||||
@ -170,7 +170,7 @@ def test_multiple_comments_on_post(alice):
|
|||||||
for i in range(3):
|
for i in range(3):
|
||||||
page.locator(".comment-form textarea[name='content']").fill(f"Comment number {i + 1}")
|
page.locator(".comment-form textarea[name='content']").fill(f"Comment number {i + 1}")
|
||||||
page.locator(".comment-form button:has-text('Post')").click()
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator(f".comment-text:has-text('Comment number {i + 1}')")).to_be_visible()
|
||||||
assert page.is_visible("text=Comment number 1")
|
assert page.is_visible("text=Comment number 1")
|
||||||
assert page.is_visible("text=Comment number 3")
|
assert page.is_visible("text=Comment number 3")
|
||||||
|
|
||||||
@ -180,17 +180,17 @@ def test_comment_and_vote_then_delete(alice):
|
|||||||
create_post(page, "showcase", "Full comment lifecycle post")
|
create_post(page, "showcase", "Full comment lifecycle post")
|
||||||
page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment")
|
page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment")
|
||||||
page.locator(".comment-form button:has-text('Post')").click()
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
page.wait_for_timeout(300)
|
comment = page.locator(".comment-text:has-text('Lifecycle comment')")
|
||||||
assert page.is_visible("text=Lifecycle comment")
|
expect(comment).to_be_visible()
|
||||||
|
|
||||||
vote_up = page.locator(".comment-vote-btn").first
|
vote_up = page.locator(".comment-vote-btn").first
|
||||||
vote_up.click()
|
vote_up.click()
|
||||||
page.wait_for_timeout(300)
|
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||||
|
|
||||||
delete_btn = page.locator(".comment-action-btn:has-text('Delete')")
|
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
||||||
if delete_btn.is_visible():
|
expect(delete_btn).to_be_visible()
|
||||||
delete_btn.click()
|
delete_btn.click()
|
||||||
page.wait_for_timeout(300)
|
expect(comment).to_have_count(0)
|
||||||
|
|
||||||
|
|
||||||
def test_post_edit_button(alice):
|
def test_post_edit_button(alice):
|
||||||
@ -229,8 +229,7 @@ def test_post_edit_submit(alice):
|
|||||||
page.fill("#edit-title", "Edited Title")
|
page.fill("#edit-title", "Edited Title")
|
||||||
page.fill("#edit-content", "Edited content for the post")
|
page.fill("#edit-content", "Edited content for the post")
|
||||||
page.click("button:has-text('Save Changes')")
|
page.click("button:has-text('Save Changes')")
|
||||||
page.wait_for_timeout(500)
|
expect(page.locator("text=Edited Title")).to_be_visible()
|
||||||
assert page.is_visible("text=Edited Title")
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_across_all_topics(alice):
|
def test_post_across_all_topics(alice):
|
||||||
@ -239,8 +238,7 @@ def test_post_across_all_topics(alice):
|
|||||||
for topic in topics:
|
for topic in topics:
|
||||||
create_post(page, topic, f"Topic test post for {topic}")
|
create_post(page, topic, f"Topic test post for {topic}")
|
||||||
badge = page.locator(f".badge-{topic}")
|
badge = page.locator(f".badge-{topic}")
|
||||||
assert badge.is_visible()
|
expect(badge).to_be_visible()
|
||||||
page.wait_for_timeout(200)
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_own_post(alice):
|
def test_delete_own_post(alice):
|
||||||
|
|||||||
64
tests/test_push.py
Normal file
64
tests/test_push.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tests.conftest import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _session():
|
||||||
|
s = requests.Session()
|
||||||
|
name = f"push_{uuid.uuid4().hex[:10]}"
|
||||||
|
s.post(f"{BASE_URL}/auth/signup", data={
|
||||||
|
"username": name,
|
||||||
|
"email": f"{name}@test.dev",
|
||||||
|
"password": "secret123",
|
||||||
|
"confirm_password": "secret123",
|
||||||
|
}, allow_redirects=True)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_key_endpoint(app_server):
|
||||||
|
r = requests.get(f"{BASE_URL}/push.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("publicKey")
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_requires_auth(app_server):
|
||||||
|
r = requests.post(f"{BASE_URL}/push.json", json={
|
||||||
|
"endpoint": "https://push.example.com/anon",
|
||||||
|
"keys": {"p256dh": "key", "auth": "auth"},
|
||||||
|
}, allow_redirects=False)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_success(app_server):
|
||||||
|
s = _session()
|
||||||
|
r = s.post(f"{BASE_URL}/push.json", json={
|
||||||
|
"endpoint": "https://push.example.com/sub-1",
|
||||||
|
"keys": {"p256dh": "p256dh_fake", "auth": "auth_fake"},
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json().get("registered") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_missing_keys_rejected(app_server):
|
||||||
|
s = _session()
|
||||||
|
r = s.post(f"{BASE_URL}/push.json", json={"endpoint": "https://push.example.com/x"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_invalid_json_rejected(app_server):
|
||||||
|
s = _session()
|
||||||
|
r = s.post(f"{BASE_URL}/push.json", data="not-json", headers={"Content-Type": "application/json"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_worker_served(app_server):
|
||||||
|
r = requests.get(f"{BASE_URL}/service-worker.js")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers.get("Service-Worker-Allowed") == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_served(app_server):
|
||||||
|
r = requests.get(f"{BASE_URL}/manifest.json")
|
||||||
|
assert r.status_code == 200
|
||||||
35
tests/test_xss.py
Normal file
35
tests/test_xss.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from tests.conftest import BASE_URL
|
||||||
|
from tests.test_post import create_post
|
||||||
|
|
||||||
|
POST_PAYLOAD = "XSSPROBE <img src=x onerror=alert('p')><script>alert('s')</script> end of post"
|
||||||
|
COMMENT_PAYLOAD = "CMTPROBE <img src=x onerror=alert('c')><script>alert('s')</script> end of comment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_content_is_sanitized(alice):
|
||||||
|
page, _ = alice
|
||||||
|
fired = []
|
||||||
|
page.on("dialog", lambda d: (fired.append(d.message), d.dismiss()))
|
||||||
|
create_post(page, "random", POST_PAYLOAD)
|
||||||
|
content = page.locator(".post-detail-content")
|
||||||
|
content.wait_for(state="visible")
|
||||||
|
page.locator(".post-detail-content p").first.wait_for(state="visible")
|
||||||
|
html = content.inner_html().lower()
|
||||||
|
assert "<script" not in html
|
||||||
|
assert "onerror" not in html
|
||||||
|
assert not fired, f"XSS payload executed: {fired}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_content_is_sanitized(alice):
|
||||||
|
page, _ = alice
|
||||||
|
fired = []
|
||||||
|
page.on("dialog", lambda d: (fired.append(d.message), d.dismiss()))
|
||||||
|
create_post(page, "random", "Clean host post for comment sanitization")
|
||||||
|
page.locator(".comment-form textarea[name='content']").fill(COMMENT_PAYLOAD)
|
||||||
|
page.locator(".comment-form button:has-text('Post')").click()
|
||||||
|
comment = page.locator(".comment-text:has-text('CMTPROBE')")
|
||||||
|
comment.wait_for(state="visible")
|
||||||
|
page.locator(".comment-text p").first.wait_for(state="visible")
|
||||||
|
html = comment.inner_html().lower()
|
||||||
|
assert "<script" not in html
|
||||||
|
assert "onerror" not in html
|
||||||
|
assert not fired, f"XSS payload executed: {fired}"
|
||||||
Loading…
Reference in New Issue
Block a user