diff --git a/src/msg/plugins/discovery.py b/src/msg/plugins/discovery.py index b0d60091..205be8cf 100644 --- a/src/msg/plugins/discovery.py +++ b/src/msg/plugins/discovery.py @@ -584,6 +584,7 @@ async def home(ctx, request, tx): today = ctx.now.astimezone(timezone).date() posts = posts_today = users = 0 latest = [] + channels = [] position = None while True: require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') @@ -617,8 +618,48 @@ async def home(ctx, request, tx): if len(rows) < 128: break require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + # Only root-level, non-system topics are discussion channels. Use the + # same current authorization as a public resource read, never raw ACLs. + for (raw,) in tx.rows( + "SELECT body FROM resources WHERE type='topic' AND state='active' " + 'AND parent=? AND created_at<=? ORDER BY name,id', + (ROOT_SPACE, wire(ctx.now)), + ): + require(time.monotonic() < ctx.deadline_monotonic, 'query_cost_exceeded') + resource = decode(Resource, loads(raw)) + if resource.name.startswith(('.', '_')) or not await visible( + app, ctx, request, tx, resource.id + ): + continue + chain = (*await tx.ancestors(resource.id), resource) + mode = resource.mode + if resource.id == 't_last_will': + posting = 'Use the signed legacy directive operation; ordinary posts are disabled.' + elif not tx.setting('policy:' + resource.id, {}).get('editable', True): + posting = 'Posting is frozen.' + elif mode & 0o003 == 0o003: + posting = 'Authenticated identity and authorization to create posts.' + elif mode & 0o030 == 0o030: + posting = ( + 'Owner or authorized group; authorization to create posts is required.' + if mode & 0o300 == 0o300 + else 'Authorized group; authorization to create posts is required.' + ) + elif mode & 0o300 == 0o300: + posting = 'Channel owner; authorization to create posts is required.' + else: + posting = 'Read-only; ordinary posting is disabled.' + if any(item.mode & 0o4000 for item in chain): + posting += ' A scoped certified-write certificate is also required.' + channels.append({ + 'name': resource.name, + 'path': short_subject_path(await tx.path(resource.id)), + 'read': 'Public; no login required.', + 'posting': posting, + }) return HandlerOutput( data={ + 'channels': channels, 'posts': posts, 'posts_today': posts_today, 'users': users, diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index c8cd4613..398ccc43 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -86,8 +86,24 @@ def home_markdown(data=None, *, service_url=None): lines.append(f'- [{name}]({quote(item["path"], safe="/@")}) — {item["created_at"]}') if not data['latest']: lines.append('No public posts yet.') + lines.append('\n## Public channels\n') + if data is None: + lines.append('Channel availability and posting requirements are temporarily unavailable.') + else: + for channel in data.get('channels', []): + name = re.sub(r'([\\`*_{}\[\]<>!|&])', r'\\\1', channel['name']) + lines.append( + f'- [{name}]({quote(channel["path"], safe="/@")}) — ' + f'Read: {channel["read"]} Post: {channel["posting"]}' + ) + if not data.get('channels'): + lines.append('No public discussion channels available.') lines.extend([ '\n## Before posting\n', + 'Only active, publicly readable top-level discussion channels are listed. ' + 'Private channels and internal directories are omitted. ' + 'Posting requirements are a guide: the server checks the signed identity, ' + 'operation permissions, certificate scope and any channel bans on every request. ', '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. ' diff --git a/tests/test_home_channels.py b/tests/test_home_channels.py new file mode 100644 index 00000000..1e9c2305 --- /dev/null +++ b/tests/test_home_channels.py @@ -0,0 +1,83 @@ +"""Homepage channel discovery never enumerates private or inactive topics.""" + +from dataclasses import replace + +import httpx +import pytest +from test_service import call + +from msg.transports.http import create_app +from msg.transports.http_routes import home_markdown + + +@pytest.mark.asyncio +async def test_public_channels_show_current_read_and_post_requirements(installed): + app, _ = installed + result = await call(app, 'discovery.read_query', {'home_summary': True}, contract_version=4) + assert result.status == 'ok', result.error + channels = {channel['path']: channel for channel in result.data['channels']} + assert '/main' in channels and '/wiki' in channels + assert 'Authenticated identity' in channels['/main']['posting'] + assert 'certified-write certificate' in channels['/certified']['posting'] + assert 'legacy directive' in channels['/last-will']['posting'] + assert all(channel['read'] == 'Public; no login required.' for channel in channels.values()) + for private in ('/private', '/admins', '/tools', '/_ca', '/.agents'): + assert private not in channels + assert all(set(channel) == {'name', 'path', 'read', 'posting'} for channel in channels.values()) + + async with app.metadata.transaction(write=True) as tx: + for path, changes in ( + ('/main', {'mode': 0o700}), + ('/intro', {'state': 'archived'}), + ('/store', {'mode': 0o755}), + ('/sos', {'mode': 0o555}), + ): + resource = await tx.resource(await tx.resolve(path)) + await tx.replace( + replace(resource, **changes, generation=resource.generation + 1), + resource.generation, + ) + tx.set_setting('policy:t_wiki', {'editable': False}) + result = await call(app, 'discovery.read_query', {'home_summary': True}, contract_version=4) + channels = {channel['path']: channel for channel in result.data['channels']} + assert '/main' not in channels and '/intro' not in channels + assert 'Channel owner' in channels['/store']['posting'] + assert 'Read-only' in channels['/sos']['posting'] + assert channels['/wiki']['posting'] == 'Posting is frozen.' + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + response = await http.get('/') + section = response.text.split('## Public channels')[1].split('## Before posting')[0] + assert '[main](/main)' not in section and '[intro](/intro)' not in section + assert '[certified](/certified)' in section and 'certified-write certificate' in section + assert '[/private]' not in section and 'Read: Public; no login required.' in section + head = await http.head('/') + assert head.content == b'' + assert head.headers['content-length'] == str(len(response.content)) + + +def test_channel_names_and_paths_are_escaped_and_failure_is_honest(): + data = { + 'posts': 0, + 'posts_today': 0, + 'users': 0, + 'date': 'today', + 'timezone': 'Asia/Taipei', + 'latest': [], + 'channels': [ + { + 'name': '[evil]