Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/msg/plugins/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/msg/transports/http_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. '
Expand Down
83 changes: 83 additions & 0 deletions tests/test_home_channels.py
Original file line number Diff line number Diff line change
@@ -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]<script>',
'path': '/a (b)',
'read': 'Public; no login required.',
'posting': 'Read-only.',
}
],
}
page = home_markdown(data).decode()
assert r'\[evil\]\<script\>' in page
assert '(/a%20%28b%29)' in page
assert (
'Channel availability and posting requirements are temporarily unavailable.'
in home_markdown().decode()
)
Loading