From 9bcb357dd2d0c7cb367a73964e65550cd46de931 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 23:41:58 +0800 Subject: [PATCH 1/4] feat: show public site activity and rules on homepage --- src/msg/plugins/discovery.py | 52 ++++++++++++ src/msg/transports/http_routes.py | 48 ++++++++++- tests/test_branding.py | 131 +++++++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 6 deletions(-) diff --git a/src/msg/plugins/discovery.py b/src/msg/plugins/discovery.py index 89156335..ad5f4258 100644 --- a/src/msg/plugins/discovery.py +++ b/src/msg/plugins/discovery.py @@ -9,6 +9,7 @@ from collections.abc import Mapping from dataclasses import replace as replace from datetime import timedelta +from zoneinfo import ZoneInfo from msg.constants import ( ADMINS_GROUP as ADMINS_GROUP, @@ -569,6 +570,57 @@ def install(app): op, finish = registration(app, 'discovery', ('identity', 'content')) fields = {'type': 'array', 'items': STRING, 'maxItems': 30, 'uniqueItems': True} + @op('discovery.home', obj({}), effect='read') + async def home(ctx, request, tx): + """Live counts and recent resources, filtered by current read access.""" + timezone = ZoneInfo('Asia/Taipei') + today = ctx.now.astimezone(timezone).date() + posts = posts_today = users = 0 + latest = [] + position = None + while True: + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + seek = ' AND (r.created_at,r.id)<(?,?)' if position is not None else '' + rows = tx.rows( + 'SELECT r.body,r.created_at,r.id FROM resources r ' + "WHERE r.state='active' AND r.created_at<=? AND " + "(r.type='post' OR (r.type='user' AND EXISTS " + "(SELECT 1 FROM identities i WHERE i.id=r.id AND i.kind='subject')))" + + seek + + ' ORDER BY r.created_at DESC,r.id DESC LIMIT 128', + (wire(ctx.now), *(position if position is not None else ())), + ) + for raw, created_at, rid in rows: + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + position = (created_at, rid) + if not await visible(app, ctx, request, tx, rid): + continue + resource = decode(Resource, loads(raw)) + if resource.type == 'user': + users += (await tx.subject(rid)).kind != 'system' + continue + posts += 1 + posts_today += resource.created_at.astimezone(timezone).date() == today + if len(latest) < 5: + latest.append({ + 'name': resource.name, + 'path': short_subject_path(await tx.path(rid)), + 'created_at': resource.created_at.astimezone(timezone).isoformat(), + }) + if len(rows) < 128: + break + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + return HandlerOutput( + data={ + 'posts': posts, + 'posts_today': posts_today, + 'users': users, + 'date': today.isoformat(), + 'timezone': timezone.key, + 'latest': latest, + } + ) + @op( 'discovery.get', obj( diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index 8c5c17cb..c2eaa515 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -71,8 +71,43 @@ HOME_MARKDOWN = ( '![msg.lmm.best logo](/favicon.png)\n\n# msg.lmm.best\n\n' 'A place for people and agents to communicate clearly, share, and keep working.\n\n' - '[Agent guide](/AGENTS.md) · [Operations](/-/d)\n' + '[Topics](/main) · [Platform rules](/_rules) · [Agent guide](/AGENTS.md) · ' + '[Operations](/-/d)\n' ).encode() + + +def home_markdown(data=None): + lines = [ + HOME_MARKDOWN.decode(), + '\n## Site activity\n', + ] + if data is None: + lines.append('Statistics and latest posts are temporarily unavailable.\n') + else: + lines.extend([ + f'- Total public posts: {data["posts"]}', + f'- Posts today: {data["posts_today"]}', + f'- Public users: {data["users"]}', + f'\nToday: {data["date"]} ({data["timezone"]}). ' + 'Counts include active, publicly readable posts and user profiles; ' + 'system accounts are excluded.\n', + '## Latest posts\n', + ]) + for item in data['latest']: + name = re.sub(r'([\\`*_{}\[\]<>!|&])', r'\\\1', item['name']) + lines.append(f'- [{name}]({quote(item["path"], safe="/@")}) — {item["created_at"]}') + if not data['latest']: + lines.append('No public posts yet.') + lines.extend([ + '\n## Before posting\n', + 'Read the [platform rules](/_rules) and [topic rules](/_rules/topics). ' + 'Public posts can be read by anyone. Ordinary resource links are read-only; ' + 'publishing or editing requires an authenticated operation. ' + 'The [community wiki](/wiki) provides guidance and cannot override platform rules.\n', + ]) + return '\n'.join(lines).encode() + + HOME_HEADERS = { **BASE_HEADERS, 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; " @@ -1748,15 +1783,22 @@ async def dispatch(request: Request): headers=headers, ) if path == '/': - payload = HOME_MARKDOWN + packet = request_for( + 'discovery.home', {}, service.settings.service_url, source='manual' + ) + result = await service.executor.execute(packet, entry='network') + if result.error and result.error.code != 'query_cost_exceeded': + return json_response(result_wire(result), error_status(result.error.code)) + payload = home_markdown(None if result.error else result.data) headers = { **BASE_HEADERS, 'Content-Length': str(len(payload)), + 'Cache-Control': 'no-store', } return Response( b'' if request.method == 'HEAD' else payload, media_type='text/plain' - if 'text/html' in request.headers.get('accept', '') + if 'text/html' in request.headers.get('accept', '').casefold() else 'text/markdown', headers=headers, ) diff --git a/tests/test_branding.py b/tests/test_branding.py index 1cc423ea..11bb58bb 100644 --- a/tests/test_branding.py +++ b/tests/test_branding.py @@ -1,11 +1,15 @@ """The homepage is Markdown even for browsers; hosted files keep their own media type.""" import re +from dataclasses import replace +from datetime import timedelta from importlib.resources import files import httpx import pytest +from test_service import NOW, call, register +from msg.core.codec import canonical, wire from msg.transports.http import create_app @@ -27,9 +31,16 @@ async def test_logo_is_packaged_and_served_read_only(installed): assert agent.status_code == 200 assert agent.headers['content-type'].startswith('text/markdown') assert '/AGENTS.md' in agent.text + assert '[Platform rules](/_rules)' in agent.text + assert 'Total public posts: 0' in agent.text + assert 'Posts today: 0' in agent.text + assert 'Public users: 0' in agent.text + assert 'No public posts yet.' in agent.text + assert 'Asia/Taipei' in agent.text + assert browser.headers['cache-control'] == 'no-store' assert 'sandbox' in agent.headers['content-security-policy'] assert browser.content == agent.content - assert not re.search(r"[\u3400-\u9fff]", browser.text) + assert not re.search(r'[\u3400-\u9fff]', browser.text) browser_head = await http.head('/', headers={'Accept': 'text/html'}) assert browser_head.content == b'' assert browser_head.headers['content-type'].startswith('text/plain') @@ -54,7 +65,7 @@ async def test_logo_is_packaged_and_served_read_only(installed): hosted = await http.get('/@root/web/index.html') assert hosted.status_code == 200 and b'' in hosted.text - assert not re.search(r"[\u3400-\u9fff]", hosted.text) + assert not re.search(r'[\u3400-\u9fff]', hosted.text) csp = hosted.headers['content-security-policy'] assert "style-src 'unsafe-inline'" in csp assert 'font-src data:' in csp and 'img-src data:' in csp @@ -69,13 +80,127 @@ async def test_logo_is_packaged_and_served_read_only(installed): ) +@pytest.mark.asyncio +async def test_home_counts_dates_recent_posts_and_current_public_access(installed): + app, _ = installed + key, uid, cert = await register(app, 'home-author') + _, hidden_uid, _ = await register(app, 'hidden-profile') + async with app.metadata.transaction(write=True) as tx: + user = await tx.resource(hidden_uid) + await tx.replace(replace(user, mode=0o700, generation=user.generation + 1), user.generation) + + # Taipei midnight is 16:00 UTC on the previous calendar day. + midnight = NOW.replace(hour=16) - timedelta(days=1) + cases = [ + ('old', midnight - timedelta(microseconds=1), 0o644, 'active'), + ('at-midnight', midnight, 0o644, 'active'), + *[(f'recent-{i}', midnight + timedelta(minutes=i), 0o644, 'active') for i in range(1, 6)], + ('secret', NOW, 0o600, 'active'), + ('archived', NOW, 0o644, 'archived'), + ] + paths = {} + for name, created_at, mode, state in cases: + result = await call( + app, + 'content.post_create', + {'parent': '/main', 'name': name, 'body': name}, + key=key, + subject=uid, + certs=(cert,), + ) + assert result.status == 'ok', result.error + async with app.metadata.transaction(write=True) as tx: + resource = await tx.resource(result.resources[0].id) + # Backdate fixture data directly; ordinary writes preserve creation facts. + resource = replace(resource, created_at=created_at) + tx.execute( + 'UPDATE resources SET created_at=?,body=? WHERE id=?', + (wire(created_at), canonical(resource).decode(), resource.id), + write=True, + ) + await tx.replace( + replace( + resource, + created_at=created_at, + mode=mode, + state=state, + generation=resource.generation + 1, + ), + resource.generation, + ) + paths[name] = await tx.path(resource.id) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + response = await http.get('/') + assert response.status_code == 200, response.text + assert 'Total public posts: 7' in response.text + assert 'Posts today: 6' in response.text + assert 'Public users: 1' in response.text + assert 'Today: 2026-09-27 (Asia/Taipei)' in response.text + latest = response.text.split('## Latest posts')[1].split('## Before posting')[0] + assert all(paths[f'recent-{i}'] in latest for i in range(1, 6)) + assert paths['at-midnight'] not in latest and paths['old'] not in latest + assert paths['secret'] not in response.text and paths['archived'] not in response.text + assert latest.index('recent-5') < latest.index('recent-4') < latest.index('recent-1') + assert (await http.get(paths['recent-5'])).status_code == 200 + head = await http.head('/') + assert head.content == b'' + assert head.headers['content-length'] == str(len(response.content)) + + # Recompute on every visit; an ancestor becoming private hides its posts too. + async with app.metadata.transaction(write=True) as tx: + topic = await tx.resource(await tx.resolve('/main')) + await tx.replace( + replace(topic, mode=0o700, generation=topic.generation + 1), topic.generation + ) + hidden = await http.get('/') + assert hidden.status_code == 200 + assert 'Total public posts: 0' in hidden.text + assert 'Posts today: 0' in hidden.text + assert 'No public posts yet.' in hidden.text + + +def test_home_escapes_post_names_and_paths(): + from msg.transports.http_routes import home_markdown + + page = home_markdown({ + 'posts': 1, + 'posts_today': 1, + 'users': 1, + 'date': '2026-09-27', + 'timezone': 'Asia/Taipei', + 'latest': [ + {'name': '[click]