msgctl 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/plugins/content.py
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
"""Topics, posts, files and templates all use Resource + immutable Revision."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
import time
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
from msg.constants import ROOT_SPACE,ROOT_SUBJECT
|
|
7
|
+
from msg.core.codec import canonical,decode,loads,wire,unb64,digest,parse_time
|
|
8
|
+
from msg.core.errors import Failure,require
|
|
9
|
+
from msg.core.models import HandlerOutput,ResourceRef,Relation,EffectJob,BlobRef,Event
|
|
10
|
+
from msg.core.tags import normalize_tags
|
|
11
|
+
from msg.core.text_patch import (PATCH_LIMIT,PATCH_CONTEXT_LIMIT,PATCH_CANDIDATE_LIMIT,
|
|
12
|
+
PATCH_SCHEMA,apply_text_patch,apply_patch,validate_patch)
|
|
13
|
+
from msg.core.template_dsl import parse_template,normalize_values
|
|
14
|
+
from msg.plugins.common import *
|
|
15
|
+
from msg.plugins.schemas import *
|
|
16
|
+
from msg.security.policy import STICKY
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def topic_admin(tx,topic,subject):
|
|
20
|
+
row=tx.one('SELECT role,status FROM topic_memberships WHERE topic=? AND subject=?',(topic,subject))
|
|
21
|
+
return row==('admin','active')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def topic_member(tx,topic,subject):
|
|
25
|
+
return tx.one('SELECT role,status,joined_at,invited_by FROM topic_memberships WHERE topic=? AND subject=?',
|
|
26
|
+
(topic,subject))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def active_topic_ban(tx,topic,subject,now):
|
|
30
|
+
row=tx.one('SELECT expires_at FROM topic_bans WHERE topic=? AND subject=? AND status=?',
|
|
31
|
+
(topic,subject,'active'))
|
|
32
|
+
return row is not None and (row[0] is None or parse_time(row[0])>now)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
TOPIC_EVENT_CODES={
|
|
36
|
+
'topic.create':'tc','topic.member.join':'mj','topic.member.request':'mr',
|
|
37
|
+
'topic.member.leave':'ml','topic.member.invite':'mi','topic.member.approve':'ma',
|
|
38
|
+
'topic.member.remove':'mx','topic.member.promote':'mp','topic.member.demote':'md',
|
|
39
|
+
'topic.member.ban':'mb','topic.member.unban':'mu','topic.policy.change':'pc',
|
|
40
|
+
'topic.archive':'ta','topic.restore':'tr','topic.move':'tm',
|
|
41
|
+
'topic.chmod':'tmo','topic.chgrp':'tgr','topic.chown':'tow','topic.configure':'tcf',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def topic_governance_event(tx,ctx,request,topic,action,target, *, reason=None):
|
|
46
|
+
event=Event(id=new_id('te'),type='topic.'+action,time=ctx.now,
|
|
47
|
+
request_id=request.request_id,actor=ctx.principal.actor,subject=ctx.principal.subject,
|
|
48
|
+
resources=(ResourceRef(id=topic),),
|
|
49
|
+
data={'topic_id':topic,'action':action,'target_subject':target,
|
|
50
|
+
**({'reason':reason} if reason else {})})
|
|
51
|
+
await tx.append_event(event)
|
|
52
|
+
tx.execute('INSERT INTO topic_event_projection (seq,topic) '
|
|
53
|
+
'SELECT seq,? FROM events WHERE id=?',(topic,event.id),write=True)
|
|
54
|
+
if target is not None:
|
|
55
|
+
notice={'id':new_id('message'),'sender':ctx.principal.subject,'actor':ctx.principal.actor,
|
|
56
|
+
'recipient':target,'resource':{'id':target,'revision':None},'time':wire(ctx.now),
|
|
57
|
+
'state':'delivered','source':'topic_governance','topic_id':topic,'action':action}
|
|
58
|
+
tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
|
|
59
|
+
(notice['id'],ctx.principal.subject,target,target,event.id,canonical(notice).decode()),write=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def template_content(app,ctx,request,tx,parent,template,values):
|
|
63
|
+
if isinstance(template,str):
|
|
64
|
+
if '/' in template or template.startswith('tpl_'):
|
|
65
|
+
rid=await resolve(tx,template)
|
|
66
|
+
else:
|
|
67
|
+
local=await tx.path(parent)
|
|
68
|
+
try:
|
|
69
|
+
rid=await tx.resolve(local+'/templates/'+template)
|
|
70
|
+
except Failure as exc:
|
|
71
|
+
if exc.code!='not_found':
|
|
72
|
+
raise
|
|
73
|
+
rid=await tx.resolve('/templates/'+template)
|
|
74
|
+
resource=await tx.resource(rid)
|
|
75
|
+
ref=ResourceRef(id=rid,revision=resource.revision)
|
|
76
|
+
else:
|
|
77
|
+
rid=await resolve(tx,template['id'])
|
|
78
|
+
version=template.get('version')
|
|
79
|
+
revision=tx.setting(f'template_version:{rid}:{version}') if version is not None else (await tx.resource(rid)).revision
|
|
80
|
+
require(revision is not None,'template_version_not_found')
|
|
81
|
+
ref=ResourceRef(id=rid,revision=revision)
|
|
82
|
+
await check_access(app,ctx,request,tx,rid,'read')
|
|
83
|
+
resource=await tx.resource(rid)
|
|
84
|
+
require(resource.type=='template','not_a_template')
|
|
85
|
+
rev=await tx.revision(ref)
|
|
86
|
+
source=(await app.contents.read_bytes(rev.content)).decode('utf-8')
|
|
87
|
+
parsed=parse_template(source)
|
|
88
|
+
normalized=normalize_values(parsed,values or {})
|
|
89
|
+
content={'template_id':rid,'template_version':parsed.version,'template_digest':rev.content.digest,'values':normalized}
|
|
90
|
+
return canonical(content),'application/json',content,Relation(type='template',target=ref)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def create_post(app,ctx,request,tx, *, parent,relations=()):
|
|
94
|
+
args=request.arguments
|
|
95
|
+
policy=tx.setting('policy:'+parent,{})
|
|
96
|
+
require(policy.get('editable',True),'content_frozen')
|
|
97
|
+
metadata={}
|
|
98
|
+
if args.get('template') is not None:
|
|
99
|
+
require('body' not in args and 'source' not in args,'ambiguous_content')
|
|
100
|
+
body,media,meta,relation=await template_content(app,ctx,request,tx,parent,args['template'],args.get('values',{}))
|
|
101
|
+
metadata={k:v for k,v in meta.items() if k!='values'}
|
|
102
|
+
relations=(*relations,relation)
|
|
103
|
+
elif args.get('source') is not None:
|
|
104
|
+
require('body' not in args,'ambiguous_content')
|
|
105
|
+
body,media=await source_content(app,ctx,request,tx,args['source'])
|
|
106
|
+
else:
|
|
107
|
+
require('body' in args,'body_required')
|
|
108
|
+
body,media=args['body'],'text/markdown'
|
|
109
|
+
if not isinstance(body,BlobRef):
|
|
110
|
+
require(len(body.encode() if isinstance(body,str) else body)<=app.settings.server.limits.max_request_bytes,'use_transfer')
|
|
111
|
+
resource=await create_resource(app,ctx,request,tx,parent=parent,type='post',name=args.get('name'),
|
|
112
|
+
body=body,media_type=media,relations=relations,resource_id=args.get('resource_id'),
|
|
113
|
+
revision_id=args.get('revision_id'),content_signature=args.get('content_signature'))
|
|
114
|
+
return resource,metadata
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def source_content(app,ctx,request,tx,value):
|
|
118
|
+
import codecs
|
|
119
|
+
from msg.plugins.communication import direct_ancestor
|
|
120
|
+
ref=decode(ResourceRef,value)
|
|
121
|
+
require(ref.revision is not None,'source_revision_required')
|
|
122
|
+
await check_access(app,ctx,request,tx,ref.id,'read')
|
|
123
|
+
require(await direct_ancestor(tx,ref.id) is None,'dm_reference_private')
|
|
124
|
+
revision=await tx.revision(ref)
|
|
125
|
+
require(revision.content.media_type in {'text/plain','text/markdown'},'text_source_required')
|
|
126
|
+
# Validation does not normalize or rewrite signed source bytes.
|
|
127
|
+
decoder=codecs.getincrementaldecoder('utf-8')()
|
|
128
|
+
try:
|
|
129
|
+
async for piece in app.contents.read(revision.content):
|
|
130
|
+
decoder.decode(piece)
|
|
131
|
+
decoder.decode(b'',final=True)
|
|
132
|
+
except UnicodeError as exc:
|
|
133
|
+
raise Failure('invalid_utf8') from exc
|
|
134
|
+
return revision.content,revision.content.media_type
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def removable(app,ctx,request,tx,resource):
|
|
138
|
+
chain=(*await tx.ancestors(resource.id),resource)
|
|
139
|
+
require(not any(item.id=='t_last_will' for item in chain),'legacy_directive_only')
|
|
140
|
+
require(not any(parent.type=='user' and child.name in {'SOUL.md','AGENTS.md','notes','todos'}
|
|
141
|
+
for parent,child in zip(chain,chain[1:])),
|
|
142
|
+
'personal_managed_resource')
|
|
143
|
+
require(resource.id!='r_agents' and all(
|
|
144
|
+
ancestor.id!='r_rules' for ancestor in await tx.ancestors(resource.id)) and
|
|
145
|
+
resource.id!='r_rules','system_managed_resource')
|
|
146
|
+
require(resource.parent is not None and resource.id!=ROOT_SUBJECT,'protected_resource')
|
|
147
|
+
parent=await tx.resource(resource.parent)
|
|
148
|
+
await check_access(app,ctx,request,tx,parent.id,'remove')
|
|
149
|
+
if parent.mode&STICKY:
|
|
150
|
+
require(ctx.principal.subject in {resource.owner,parent.owner},'sticky_denied')
|
|
151
|
+
return parent
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def require_unmanaged_personal(tx,resource):
|
|
155
|
+
chain=(*await tx.ancestors(resource.id),resource)
|
|
156
|
+
require(not any(parent.type=='user' and child.name in {'SOUL.md','AGENTS.md','notes','todos'}
|
|
157
|
+
for parent,child in zip(chain,chain[1:])),
|
|
158
|
+
'personal_managed_resource')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def ensure_public_repositories(tx,resource, *, mode=None,parent=None):
|
|
162
|
+
repos=tx.rows("SELECT id FROM resources WHERE type='repo' AND state='active'")
|
|
163
|
+
for (rid,) in repos:
|
|
164
|
+
repo=await tx.resource(rid)
|
|
165
|
+
ancestry=await tx.ancestors(rid)
|
|
166
|
+
if rid==resource.id:
|
|
167
|
+
require((repo.mode if mode is None else mode)&4,'repo_public_read_required')
|
|
168
|
+
if any(a.id==resource.id for a in ancestry):
|
|
169
|
+
if mode is not None:
|
|
170
|
+
require(mode&1,'repo_public_read_required')
|
|
171
|
+
if parent is not None:
|
|
172
|
+
new_parent=await tx.resource(parent)
|
|
173
|
+
require(new_parent.mode&1 and all(a.mode&1 for a in await tx.ancestors(parent)),
|
|
174
|
+
'repo_public_read_required')
|
|
175
|
+
if resource.type=='repo' and parent is not None:
|
|
176
|
+
p=await tx.resource(parent)
|
|
177
|
+
require(p.mode&1 and all(a.mode&1 for a in await tx.ancestors(parent)),'repo_public_read_required')
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def prepare_text_patch(app,ctx,request,tx,args, *, post_only=False):
|
|
181
|
+
"""Prepare an authorized edit without publishing any content or SQL reference."""
|
|
182
|
+
resource=await tx.resource(await resolve(tx,args['id']))
|
|
183
|
+
if post_only:
|
|
184
|
+
require(resource.type=='post','not_editable')
|
|
185
|
+
require(resource.type in {'post','file'} and resource.state=='active','not_editable')
|
|
186
|
+
chain=(*await tx.ancestors(resource.id),resource)
|
|
187
|
+
require(not any(item.id=='t_last_will' for item in chain),'legacy_directive_only')
|
|
188
|
+
require(resource.id!='r_agents' and all(item.id!='r_rules' for item in chain),
|
|
189
|
+
'system_managed_resource')
|
|
190
|
+
await require_unmanaged_personal(tx,resource)
|
|
191
|
+
await check_access(app,ctx,request,tx,resource.id,'write')
|
|
192
|
+
await assert_generation(request,resource)
|
|
193
|
+
require((await topic_policy(tx,resource)).get('editable',True),'content_frozen')
|
|
194
|
+
current=await tx.revision(ResourceRef(id=resource.id,revision=resource.revision))
|
|
195
|
+
media=current.content.media_type
|
|
196
|
+
require(media in {'text/plain','text/markdown'},'text_patch_required')
|
|
197
|
+
patch=args.get('patch')
|
|
198
|
+
if patch is not None:
|
|
199
|
+
validate_patch(patch)
|
|
200
|
+
else:
|
|
201
|
+
pieces=(args['exact'],args['replacement'],args.get('before',''),args.get('after',''))
|
|
202
|
+
require(sum(len(piece.encode('utf-8')) for piece in pieces)<=PATCH_LIMIT,'patch_too_large')
|
|
203
|
+
base_source=None
|
|
204
|
+
require(current.content.size<=PATCH_LIMIT,'patch_too_large')
|
|
205
|
+
try:
|
|
206
|
+
source=(await app.contents.read_bytes(current.content,limit=PATCH_LIMIT)).decode('utf-8')
|
|
207
|
+
except UnicodeError as exc:
|
|
208
|
+
raise Failure('invalid_utf8') from exc
|
|
209
|
+
stale=current.id!=args['base_revision']
|
|
210
|
+
if stale:
|
|
211
|
+
require(args.get('rebase') is True,'revision_conflict',details={'revision':current.id})
|
|
212
|
+
require('base_generation' in args,'base_generation_required')
|
|
213
|
+
require(args['base_generation']<resource.generation,'base_generation_conflict')
|
|
214
|
+
base=await tx.revision(ResourceRef(id=resource.id,revision=args['base_revision']))
|
|
215
|
+
# Never rebase across an unrelated historical branch.
|
|
216
|
+
ancestor=current
|
|
217
|
+
seen=set()
|
|
218
|
+
while ancestor.id!=base.id:
|
|
219
|
+
require(len(seen)<4096 and time.monotonic()<ctx.deadline_monotonic,'patch_too_complex')
|
|
220
|
+
require(ancestor.id not in seen and len(ancestor.parents)==1,'revision_conflict')
|
|
221
|
+
seen.add(ancestor.id)
|
|
222
|
+
ancestor=await tx.revision(ResourceRef(id=resource.id,revision=ancestor.parents[0]))
|
|
223
|
+
require(base.content.media_type==media and base.content.size<=PATCH_LIMIT,'text_patch_required')
|
|
224
|
+
try:
|
|
225
|
+
base_source=(await app.contents.read_bytes(base.content,limit=PATCH_LIMIT)).decode('utf-8')
|
|
226
|
+
except UnicodeError as exc:
|
|
227
|
+
raise Failure('invalid_utf8') from exc
|
|
228
|
+
# A unique anchor must have selected exactly one block in the base.
|
|
229
|
+
if patch is None:
|
|
230
|
+
apply_text_patch(base_source,*pieces)
|
|
231
|
+
else:
|
|
232
|
+
require('base_generation' not in args or args['base_generation']==resource.generation,
|
|
233
|
+
'base_generation_conflict')
|
|
234
|
+
patched=(apply_patch(source,patch,base_source=base_source) if patch is not None
|
|
235
|
+
else apply_text_patch(source,*pieces))
|
|
236
|
+
return resource,current,patched,media
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def install(app):
|
|
240
|
+
op,finish=registration(app,'content',('identity',))
|
|
241
|
+
post_fields={'parent':IDENTIFIER,'name':STRING,'body':STRING,'template':{'anyOf':[STRING,obj({'id':IDENTIFIER,'version':INTEGER},('id',))]},
|
|
242
|
+
'values':{'type':'object'},'source':REF,'content_created_at':STRING,'resource_id':IDENTIFIER,'revision_id':IDENTIFIER,'content_signature':SIGNATURE}
|
|
243
|
+
app.post_fields=post_fields
|
|
244
|
+
|
|
245
|
+
@op('content.topic_create',obj({'parent':IDENTIFIER,'name':STRING},('parent','name')),
|
|
246
|
+
requirements=requirement('parent','create'))
|
|
247
|
+
async def topic_create(ctx,request,tx):
|
|
248
|
+
resource=await create_resource(app,ctx,request,tx,parent=await resolve(tx,request.arguments['parent']),
|
|
249
|
+
type='topic',name=request.arguments['name'])
|
|
250
|
+
tx.execute('INSERT INTO topic_settings (topic,membership_policy) VALUES (?,?)',
|
|
251
|
+
(resource.id,'open'),write=True)
|
|
252
|
+
tx.execute('''INSERT INTO topic_memberships
|
|
253
|
+
(topic,subject,role,status,joined_at,invited_by) VALUES (?,?,?,?,?,?)''',
|
|
254
|
+
(resource.id,ctx.principal.subject,'admin','active',wire(ctx.now),None),write=True)
|
|
255
|
+
await topic_governance_event(tx,ctx,request,resource.id,'create',ctx.principal.subject)
|
|
256
|
+
return output_for(resource)
|
|
257
|
+
|
|
258
|
+
async def governed_topic(ctx,request,tx, *, admin=False):
|
|
259
|
+
topic=await resolve(tx,request.arguments['id'])
|
|
260
|
+
resource=await tx.resource(topic)
|
|
261
|
+
require(resource.type=='topic' and resource.state=='active','topic_not_active')
|
|
262
|
+
await require_unmanaged_personal(tx,resource)
|
|
263
|
+
require(ctx.principal.subject is not None and ctx.principal.actor==ctx.principal.subject,
|
|
264
|
+
'topic_subject_required')
|
|
265
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),topic,tx)
|
|
266
|
+
await check_access(app,ctx,request,tx,topic,'read')
|
|
267
|
+
if admin:
|
|
268
|
+
require(topic_admin(tx,topic,ctx.principal.subject),'topic_admin_required')
|
|
269
|
+
return topic
|
|
270
|
+
|
|
271
|
+
@op('content.topic_join',obj({'id':IDENTIFIER},('id',)),signature=True)
|
|
272
|
+
async def topic_join(ctx,request,tx):
|
|
273
|
+
topic=await governed_topic(ctx,request,tx)
|
|
274
|
+
subject=ctx.principal.subject
|
|
275
|
+
require(not active_topic_ban(tx,topic,subject,ctx.now),'topic_banned')
|
|
276
|
+
previous=topic_member(tx,topic,subject)
|
|
277
|
+
require(previous is None or previous[1] not in {'active','pending'},'already_topic_member')
|
|
278
|
+
policy=tx.one('SELECT membership_policy FROM topic_settings WHERE topic=?',(topic,))
|
|
279
|
+
membership_policy=policy[0] if policy else 'open'
|
|
280
|
+
require(membership_policy in {'open','approval'} or
|
|
281
|
+
(membership_policy=='invite' and previous is not None and previous[1]=='invited'),
|
|
282
|
+
'topic_join_closed')
|
|
283
|
+
status='pending' if membership_policy=='approval' and (previous is None or previous[1]!='invited') else 'active'
|
|
284
|
+
if previous is None:
|
|
285
|
+
tx.execute('INSERT INTO topic_memberships VALUES (?,?,?,?,?,?)',
|
|
286
|
+
(topic,subject,'member',status,wire(ctx.now) if status=='active' else None,None),write=True)
|
|
287
|
+
else:
|
|
288
|
+
tx.execute('''UPDATE topic_memberships SET role='member',status=?,joined_at=?
|
|
289
|
+
WHERE topic=? AND subject=?''',(status,wire(ctx.now) if status=='active' else None,
|
|
290
|
+
topic,subject),write=True)
|
|
291
|
+
await topic_governance_event(tx,ctx,request,topic,'member.join' if status=='active' else 'member.request',subject)
|
|
292
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,'status':status,'role':'member'})
|
|
293
|
+
|
|
294
|
+
@op('content.topic_leave',obj({'id':IDENTIFIER},('id',)),signature=True)
|
|
295
|
+
async def topic_leave(ctx,request,tx):
|
|
296
|
+
topic=await governed_topic(ctx,request,tx)
|
|
297
|
+
subject=ctx.principal.subject
|
|
298
|
+
previous=topic_member(tx,topic,subject)
|
|
299
|
+
require(previous is not None and previous[1] in {'active','pending','invited'},'not_topic_member')
|
|
300
|
+
if previous[0]=='admin' and previous[1]=='active':
|
|
301
|
+
count=tx.one("SELECT COUNT(*) FROM topic_memberships WHERE topic=? AND role='admin' AND status='active'",(topic,))[0]
|
|
302
|
+
require(count>1,'last_topic_admin')
|
|
303
|
+
tx.execute("UPDATE topic_memberships SET role='member',status='left' WHERE topic=? AND subject=?",
|
|
304
|
+
(topic,subject),write=True)
|
|
305
|
+
await topic_governance_event(tx,ctx,request,topic,'member.leave',subject)
|
|
306
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,'status':'left'})
|
|
307
|
+
|
|
308
|
+
member_target=obj({'id':IDENTIFIER,'subject_id':IDENTIFIER},('id','subject_id'))
|
|
309
|
+
|
|
310
|
+
@op('content.topic_invite',member_target,signature=True)
|
|
311
|
+
async def topic_invite(ctx,request,tx):
|
|
312
|
+
topic=await governed_topic(ctx,request,tx,admin=True)
|
|
313
|
+
target=await resolve(tx,request.arguments['subject_id'])
|
|
314
|
+
await tx.subject(target)
|
|
315
|
+
require(not active_topic_ban(tx,topic,target,ctx.now),'topic_banned')
|
|
316
|
+
prior=topic_member(tx,topic,target)
|
|
317
|
+
require(prior is None or prior[1] not in {'active','pending','invited'},'already_topic_member')
|
|
318
|
+
if prior is None:
|
|
319
|
+
tx.execute('INSERT INTO topic_memberships VALUES (?,?,?,?,?,?)',
|
|
320
|
+
(topic,target,'member','invited',None,ctx.principal.subject),write=True)
|
|
321
|
+
else:
|
|
322
|
+
tx.execute('''UPDATE topic_memberships SET role='member',status='invited',joined_at=NULL,
|
|
323
|
+
invited_by=? WHERE topic=? AND subject=?''',(ctx.principal.subject,topic,target),write=True)
|
|
324
|
+
await topic_governance_event(tx,ctx,request,topic,'member.invite',target)
|
|
325
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,'status':'invited'})
|
|
326
|
+
|
|
327
|
+
@op('content.topic_approve',member_target,signature=True)
|
|
328
|
+
async def topic_approve(ctx,request,tx):
|
|
329
|
+
topic=await governed_topic(ctx,request,tx,admin=True)
|
|
330
|
+
target=await resolve(tx,request.arguments['subject_id'])
|
|
331
|
+
previous=topic_member(tx,topic,target)
|
|
332
|
+
require(previous is not None and previous[1]=='pending','topic_request_not_pending')
|
|
333
|
+
require(not active_topic_ban(tx,topic,target,ctx.now),'topic_banned')
|
|
334
|
+
tx.execute("UPDATE topic_memberships SET status='active',joined_at=? WHERE topic=? AND subject=?",
|
|
335
|
+
(wire(ctx.now),topic,target),write=True)
|
|
336
|
+
await topic_governance_event(tx,ctx,request,topic,'member.approve',target)
|
|
337
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,'status':'active'})
|
|
338
|
+
|
|
339
|
+
async def update_membership(ctx,request,tx):
|
|
340
|
+
topic=await governed_topic(ctx,request,tx,admin=True)
|
|
341
|
+
target=await resolve(tx,request.arguments['subject_id'])
|
|
342
|
+
previous=topic_member(tx,topic,target)
|
|
343
|
+
require(previous is not None and previous[1]=='active','not_topic_member')
|
|
344
|
+
action=request.operation.removeprefix('content.topic_')
|
|
345
|
+
if action in {'remove','demote'} and previous[0]=='admin':
|
|
346
|
+
count=tx.one("SELECT COUNT(*) FROM topic_memberships WHERE topic=? AND role='admin' AND status='active'",(topic,))[0]
|
|
347
|
+
require(count>1,'last_topic_admin')
|
|
348
|
+
if action=='remove':
|
|
349
|
+
tx.execute("UPDATE topic_memberships SET role='member',status='removed' WHERE topic=? AND subject=?",
|
|
350
|
+
(topic,target),write=True)
|
|
351
|
+
data={'status':'removed'}
|
|
352
|
+
else:
|
|
353
|
+
role='admin' if action=='promote' else 'member'
|
|
354
|
+
require(previous[0]!=role,'topic_role_unchanged')
|
|
355
|
+
tx.execute('UPDATE topic_memberships SET role=? WHERE topic=? AND subject=?',
|
|
356
|
+
(role,topic,target),write=True)
|
|
357
|
+
data={'role':role}
|
|
358
|
+
await topic_governance_event(tx,ctx,request,topic,'member.'+action,target)
|
|
359
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,**data})
|
|
360
|
+
for name in ('content.topic_remove','content.topic_promote','content.topic_demote'):
|
|
361
|
+
op(name,member_target,signature=True)(update_membership)
|
|
362
|
+
|
|
363
|
+
@op('content.topic_ban',obj({'id':IDENTIFIER,'subject_id':IDENTIFIER,
|
|
364
|
+
'reason':{'type':'string','maxLength':500},'expires_at':STRING},('id','subject_id')),signature=True)
|
|
365
|
+
async def topic_ban(ctx,request,tx):
|
|
366
|
+
topic=await governed_topic(ctx,request,tx,admin=True)
|
|
367
|
+
target=await resolve(tx,request.arguments['subject_id'])
|
|
368
|
+
await tx.subject(target)
|
|
369
|
+
previous=topic_member(tx,topic,target)
|
|
370
|
+
if previous is not None and previous[:2]==('admin','active'):
|
|
371
|
+
count=tx.one("SELECT COUNT(*) FROM topic_memberships WHERE topic=? AND role='admin' AND status='active'",(topic,))[0]
|
|
372
|
+
require(count>1,'last_topic_admin')
|
|
373
|
+
expires=request.arguments.get('expires_at')
|
|
374
|
+
if expires:
|
|
375
|
+
require(parse_time(expires)>ctx.now,'topic_ban_expired')
|
|
376
|
+
tx.execute('''INSERT INTO topic_bans (topic,subject,actor,created_at,expires_at,reason,status)
|
|
377
|
+
VALUES (?,?,?,?,?,?,?) ON CONFLICT(topic,subject) DO UPDATE SET
|
|
378
|
+
actor=excluded.actor,created_at=excluded.created_at,expires_at=excluded.expires_at,
|
|
379
|
+
reason=excluded.reason,status='active' ''',
|
|
380
|
+
(topic,target,ctx.principal.subject,wire(ctx.now),expires,request.arguments.get('reason'),'active'),write=True)
|
|
381
|
+
if previous is not None:
|
|
382
|
+
tx.execute("UPDATE topic_memberships SET role='member',status='removed' WHERE topic=? AND subject=?",
|
|
383
|
+
(topic,target),write=True)
|
|
384
|
+
await topic_governance_event(tx,ctx,request,topic,'member.ban',target,
|
|
385
|
+
reason=request.arguments.get('reason'))
|
|
386
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,'banned_subject':target})
|
|
387
|
+
|
|
388
|
+
@op('content.topic_unban',member_target,signature=True)
|
|
389
|
+
async def topic_unban(ctx,request,tx):
|
|
390
|
+
topic=await governed_topic(ctx,request,tx,admin=True)
|
|
391
|
+
target=await resolve(tx,request.arguments['subject_id'])
|
|
392
|
+
changed=tx.execute("UPDATE topic_bans SET status='lifted' WHERE topic=? AND subject=? AND status='active'",
|
|
393
|
+
(topic,target),write=True).rowcount
|
|
394
|
+
require(changed==1,'topic_ban_not_found')
|
|
395
|
+
await topic_governance_event(tx,ctx,request,topic,'member.unban',target)
|
|
396
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,'unbanned_subject':target,
|
|
397
|
+
'membership_restored':False})
|
|
398
|
+
|
|
399
|
+
@op('content.topic_policy_set',obj({'id':IDENTIFIER,
|
|
400
|
+
'membership_policy':{'enum':['open','approval','invite','closed']}},
|
|
401
|
+
('id','membership_policy')),signature=True)
|
|
402
|
+
async def topic_policy_set(ctx,request,tx):
|
|
403
|
+
topic=await governed_topic(ctx,request,tx,admin=True)
|
|
404
|
+
policy=request.arguments['membership_policy']
|
|
405
|
+
tx.execute('''INSERT INTO topic_settings (topic,membership_policy) VALUES (?,?)
|
|
406
|
+
ON CONFLICT(topic) DO UPDATE SET membership_policy=excluded.membership_policy''',
|
|
407
|
+
(topic,policy),write=True)
|
|
408
|
+
await topic_governance_event(tx,ctx,request,topic,'policy.change',ctx.principal.subject)
|
|
409
|
+
return HandlerOutput(resources=(ResourceRef(id=topic),),data={'topic_id':topic,
|
|
410
|
+
'membership_policy':policy})
|
|
411
|
+
|
|
412
|
+
@op('content.topic_members',obj({'id':IDENTIFIER},('id',)),effect='read')
|
|
413
|
+
async def topic_members(ctx,request,tx):
|
|
414
|
+
topic=await resolve(tx,request.arguments['id'])
|
|
415
|
+
await check_access(app,ctx,request,tx,topic,'read')
|
|
416
|
+
require((await tx.resource(topic)).type=='topic','not_a_topic')
|
|
417
|
+
rows=tx.rows("SELECT subject,role,status,joined_at FROM topic_memberships WHERE topic=? AND status='active' ORDER BY subject",
|
|
418
|
+
(topic,))
|
|
419
|
+
policy=tx.one('SELECT membership_policy FROM topic_settings WHERE topic=?',(topic,))
|
|
420
|
+
return HandlerOutput(data={'topic_id':topic,'membership_policy':policy[0] if policy else 'open',
|
|
421
|
+
'items':[{'subject_id':subject,'role':role,'status':status,'joined_at':joined}
|
|
422
|
+
for subject,role,status,joined in rows]})
|
|
423
|
+
|
|
424
|
+
@op('content.topic_events',obj({'id':IDENTIFIER,
|
|
425
|
+
'view':{'enum':['compact','normal','proof']},'cursor':STRING,
|
|
426
|
+
'limit':{'type':'integer','minimum':1,'maximum':50}},('id',)),effect='read')
|
|
427
|
+
async def topic_events(ctx,request,tx):
|
|
428
|
+
from msg.plugins.discovery import next_link
|
|
429
|
+
topic=await resolve(tx,request.arguments['id'])
|
|
430
|
+
await check_access(app,ctx,request,tx,topic,'read')
|
|
431
|
+
require((await tx.resource(topic)).type=='topic','not_a_topic')
|
|
432
|
+
view=request.arguments.get('view','compact')
|
|
433
|
+
admin=topic_admin(tx,topic,ctx.principal.subject)
|
|
434
|
+
binding=digest({'topic':topic,'view':view,'subject':ctx.principal.subject,'admin':admin})
|
|
435
|
+
position=app.cursors.decode(request.arguments['cursor'],'topic_events',binding) if request.arguments.get('cursor') else 9223372036854775807
|
|
436
|
+
limit=request.arguments.get('limit',10)
|
|
437
|
+
found=[]
|
|
438
|
+
# Limit the indexed projection BEFORE joining immutable event bodies.
|
|
439
|
+
rows=tx.execute('SELECT p.seq,e.body FROM '
|
|
440
|
+
'(SELECT seq FROM topic_event_projection WHERE topic=? AND seq<? '
|
|
441
|
+
'ORDER BY seq DESC LIMIT ?) p JOIN events e ON e.seq=p.seq ORDER BY p.seq DESC',
|
|
442
|
+
(topic,position,limit+1))
|
|
443
|
+
for seq,raw in rows:
|
|
444
|
+
found.append((seq,loads(raw)))
|
|
445
|
+
items=[]
|
|
446
|
+
for seq,event in found[:limit]:
|
|
447
|
+
data=dict(event['data'])
|
|
448
|
+
if not admin:
|
|
449
|
+
data.pop('reason',None)
|
|
450
|
+
if view=='compact':
|
|
451
|
+
items.append({'s':seq,'c':TOPIC_EVENT_CODES[event['type']],
|
|
452
|
+
'a':event['actor'],'u':data.get('target_subject'),'t':event['time']})
|
|
453
|
+
elif view=='normal':
|
|
454
|
+
line=f"{event['time']} {event['actor']} {data['action']} {data.get('target_subject') or ''}"
|
|
455
|
+
if admin and data.get('reason'):
|
|
456
|
+
line+=' — '+data['reason']
|
|
457
|
+
items.append({'seq':seq,'text':line})
|
|
458
|
+
else:
|
|
459
|
+
proof=dict(event,data=data)
|
|
460
|
+
result=tx.one('SELECT body FROM results WHERE subject=? AND request_id=?',
|
|
461
|
+
(event['subject'],event['request_id']))
|
|
462
|
+
if result is not None:
|
|
463
|
+
proof['receipt']=loads(result[0]).get('receipt')
|
|
464
|
+
items.append({'seq':seq,'event':proof})
|
|
465
|
+
response={'topic_id':topic,'view':view,'items':items}
|
|
466
|
+
if view=='compact':
|
|
467
|
+
response.update(schema_version=1,event_codes=TOPIC_EVENT_CODES,
|
|
468
|
+
base_time=found[0][1]['time'] if found else None)
|
|
469
|
+
if len(found)>limit:
|
|
470
|
+
cursor=app.cursors.encode('topic_events',binding,found[limit-1][0])
|
|
471
|
+
response.update(cursor=cursor,
|
|
472
|
+
next=next_link(app,'content.topic_events',{**request.arguments,'cursor':cursor}),
|
|
473
|
+
next_requires_auth=ctx.principal.subject is not None)
|
|
474
|
+
return HandlerOutput(data=response)
|
|
475
|
+
|
|
476
|
+
@op('content.post_create',obj(post_fields,('parent',)),requirements=requirement('parent','create'))
|
|
477
|
+
async def post_create(ctx,request,tx):
|
|
478
|
+
resource,meta=await create_post(app,ctx,request,tx,parent=await resolve(tx,request.arguments['parent']))
|
|
479
|
+
return output_for(resource,**meta)
|
|
480
|
+
|
|
481
|
+
@op('content.tags_set',obj({'id':IDENTIFIER,'tags':{'type':'array','items':STRING,
|
|
482
|
+
'maxItems':16}},('id','tags')))
|
|
483
|
+
async def tags_set(ctx,request,tx):
|
|
484
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
485
|
+
await require_unmanaged_personal(tx,resource)
|
|
486
|
+
check='manage' if resource.type in {'topic','repo'} else 'write'
|
|
487
|
+
await check_access(app,ctx,request,tx,resource.id,check)
|
|
488
|
+
require(app.registry.resource_type(resource.type,resource.type_version).taggable,
|
|
489
|
+
'resource_not_taggable')
|
|
490
|
+
require(resource.state=='active','resource_inactive')
|
|
491
|
+
await assert_generation(request,resource)
|
|
492
|
+
tags=normalize_tags(request.arguments['tags'])
|
|
493
|
+
if tags==resource.tags:
|
|
494
|
+
return output_for(resource,tags=tags)
|
|
495
|
+
updated=replace(resource,tags=tags,generation=resource.generation+1,
|
|
496
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
497
|
+
await tx.replace(updated,resource.generation)
|
|
498
|
+
return output_for(updated,tags=tags)
|
|
499
|
+
|
|
500
|
+
post_write_schema=obj({'id':IDENTIFIER,'expected_revision':IDENTIFIER,'body':STRING,
|
|
501
|
+
'template':post_fields['template'],'values':{'type':'object'},'source':REF,'content_created_at':STRING,'revision_id':IDENTIFIER,'content_signature':SIGNATURE},
|
|
502
|
+
('id','expected_revision'))
|
|
503
|
+
@op('content.post_write',post_write_schema,requirements=requirement('id','write'))
|
|
504
|
+
@op('content.post_edit',post_write_schema,requirements=requirement('id','write'))
|
|
505
|
+
async def post_edit(ctx,request,tx):
|
|
506
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
507
|
+
require(resource.type=='post' and resource.state=='active','not_editable')
|
|
508
|
+
await assert_generation(request,resource)
|
|
509
|
+
require(resource.revision==request.arguments['expected_revision'],'revision_conflict',details={'revision':resource.revision})
|
|
510
|
+
require((await topic_policy(tx,resource)).get('editable',True),'content_frozen')
|
|
511
|
+
old=await tx.revision(ResourceRef(id=resource.id))
|
|
512
|
+
relations=tuple(r for r in old.relations if r.type!='template')
|
|
513
|
+
if request.arguments.get('template') is not None:
|
|
514
|
+
require('body' not in request.arguments and 'source' not in request.arguments,'ambiguous_content')
|
|
515
|
+
body,media,meta,relation=await template_content(app,ctx,request,tx,resource.parent,
|
|
516
|
+
request.arguments['template'],request.arguments.get('values',{}))
|
|
517
|
+
relations=(*relations,relation)
|
|
518
|
+
elif request.arguments.get('source') is not None:
|
|
519
|
+
require('body' not in request.arguments,'ambiguous_content')
|
|
520
|
+
body,media=await source_content(app,ctx,request,tx,request.arguments['source'])
|
|
521
|
+
else:
|
|
522
|
+
require('body' in request.arguments,'body_required')
|
|
523
|
+
body,media=request.arguments['body'],'text/markdown'
|
|
524
|
+
updated=await revise_resource(app,ctx,request,tx,resource,body,media,relations=relations,author=old.author,
|
|
525
|
+
signature=request.arguments.get('content_signature'),revision_id=request.arguments.get('revision_id'))
|
|
526
|
+
return output_for(updated)
|
|
527
|
+
|
|
528
|
+
@op('content.post_edit_metadata',obj({'id':IDENTIFIER,'name':STRING,
|
|
529
|
+
'tags':{'type':'array','items':STRING,'maxItems':16}},('id',)),
|
|
530
|
+
requirements=requirement('id','write'))
|
|
531
|
+
async def post_edit_metadata(ctx,request,tx):
|
|
532
|
+
a=request.arguments
|
|
533
|
+
require('name' in a or 'tags' in a,'metadata_change_required')
|
|
534
|
+
resource=await tx.resource(await resolve(tx,a['id']))
|
|
535
|
+
require(resource.type=='post' and resource.state=='active','not_editable')
|
|
536
|
+
await require_unmanaged_personal(tx,resource)
|
|
537
|
+
await assert_generation(request,resource)
|
|
538
|
+
require((await topic_policy(tx,resource)).get('editable',True),'content_frozen')
|
|
539
|
+
name=resource.name
|
|
540
|
+
if 'name' in a:
|
|
541
|
+
# Names are path metadata. Keep the canonical .md post path and the
|
|
542
|
+
# same protected-namespace/sticky checks as an explicit move.
|
|
543
|
+
await removable(app,ctx,request,tx,resource)
|
|
544
|
+
name=validate_name(a['name'] if a['name'].endswith('.md') else a['name']+'.md')
|
|
545
|
+
await protect_namespace(app,ctx,request,tx,resource.parent,name)
|
|
546
|
+
tags=normalize_tags(a['tags']) if 'tags' in a else resource.tags
|
|
547
|
+
if name==resource.name and tags==resource.tags:
|
|
548
|
+
return output_for(resource,name=name,tags=tags)
|
|
549
|
+
updated=replace(resource,name=name,tags=tags,generation=resource.generation+1,
|
|
550
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
551
|
+
await tx.replace(updated,resource.generation)
|
|
552
|
+
return output_for(updated,name=name,tags=tags)
|
|
553
|
+
|
|
554
|
+
@op('content.post_rollback',obj({'id':IDENTIFIER,'base_revision':IDENTIFIER,
|
|
555
|
+
'target_revision':IDENTIFIER,'content_created_at':STRING,
|
|
556
|
+
'revision_id':IDENTIFIER,'content_signature':SIGNATURE},
|
|
557
|
+
('id','base_revision','target_revision')),requirements=requirement('id','write'))
|
|
558
|
+
async def post_rollback(ctx,request,tx):
|
|
559
|
+
a=request.arguments
|
|
560
|
+
resource=await tx.resource(await resolve(tx,a['id']))
|
|
561
|
+
require(resource.type=='post' and resource.state=='active','not_editable')
|
|
562
|
+
await require_unmanaged_personal(tx,resource)
|
|
563
|
+
await assert_generation(request,resource)
|
|
564
|
+
require(resource.revision==a['base_revision'],'revision_conflict',
|
|
565
|
+
details={'revision':resource.revision})
|
|
566
|
+
require(a['target_revision']!=resource.revision,'rollback_target_current')
|
|
567
|
+
require((await topic_policy(tx,resource)).get('editable',True),'content_frozen')
|
|
568
|
+
current=await tx.revision(ResourceRef(id=resource.id,revision=resource.revision))
|
|
569
|
+
historical=await tx.revision(ResourceRef(id=resource.id,revision=a['target_revision']))
|
|
570
|
+
require(historical.author==current.author,'rollback_author_mismatch')
|
|
571
|
+
manifest={k:v for k,v in wire(historical).items() if k not in {'manifest_digest','signature'}}
|
|
572
|
+
require(digest(manifest)==historical.manifest_digest,'revision_manifest_mismatch')
|
|
573
|
+
if historical.signature is not None:
|
|
574
|
+
credential=await tx.credential(historical.signature.key_id)
|
|
575
|
+
require(credential.subject_id==historical.subject and credential.kind=='signing_key',
|
|
576
|
+
'revision_signer_mismatch')
|
|
577
|
+
verify(credential.verifier,canonical(manifest),historical.signature,purpose='revision')
|
|
578
|
+
hasher=sha256()
|
|
579
|
+
size=0
|
|
580
|
+
async for chunk in app.contents.read(historical.content):
|
|
581
|
+
size+=len(chunk)
|
|
582
|
+
hasher.update(chunk)
|
|
583
|
+
require(size==historical.content.size and
|
|
584
|
+
'sha256:'+hasher.hexdigest()==historical.content.digest,
|
|
585
|
+
'content_digest_mismatch')
|
|
586
|
+
# Revalidate every historical relation under present-day authorization.
|
|
587
|
+
# revise_resource creates a new manifest and signature; it never edits
|
|
588
|
+
# or adopts the historical signature as proof of the new revision.
|
|
589
|
+
updated=await revise_resource(app,ctx,request,tx,resource,historical.content,
|
|
590
|
+
historical.content.media_type,relations=historical.relations,author=current.author,
|
|
591
|
+
signature=a.get('content_signature'),revision_id=a.get('revision_id'))
|
|
592
|
+
return output_for(updated,rolled_back_from=historical.id)
|
|
593
|
+
|
|
594
|
+
patch_fields={'id':IDENTIFIER,'base_revision':IDENTIFIER,
|
|
595
|
+
'exact':{'type':'string','minLength':1,'maxLength':PATCH_LIMIT},
|
|
596
|
+
'replacement':{'type':'string','maxLength':PATCH_LIMIT},
|
|
597
|
+
'before':{'type':'string','maxLength':PATCH_CONTEXT_LIMIT},
|
|
598
|
+
'after':{'type':'string','maxLength':PATCH_CONTEXT_LIMIT}}
|
|
599
|
+
post_patch_schema=obj(patch_fields,('id','base_revision','exact','replacement'))
|
|
600
|
+
rebase_patch_fields={**patch_fields,'base_generation':INTEGER,'rebase':BOOLEAN}
|
|
601
|
+
rebase_patch_schema=obj(rebase_patch_fields,('id','base_revision','base_generation','exact','replacement'))
|
|
602
|
+
structured_patch_schema=obj({'id':IDENTIFIER,'base_revision':IDENTIFIER,
|
|
603
|
+
'base_generation':INTEGER,'rebase':BOOLEAN,'patch':PATCH_SCHEMA,
|
|
604
|
+
'change_note':{'type':'string','maxLength':2048},
|
|
605
|
+
'content_created_at':STRING,'revision_id':IDENTIFIER,'content_signature':SIGNATURE},
|
|
606
|
+
('id','base_revision','base_generation','patch'))
|
|
607
|
+
|
|
608
|
+
async def publish_patch(ctx,request,tx,args,prepared):
|
|
609
|
+
resource,old,body,media=prepared
|
|
610
|
+
extra={}
|
|
611
|
+
if 'patch' in args:
|
|
612
|
+
# Bind provenance to the precise patch, not to the signed request
|
|
613
|
+
# containing this signature (which would create a digest cycle).
|
|
614
|
+
extra={'change_note':args.get('change_note'),'source_kind':'user',
|
|
615
|
+
'source_version':1,'source_digest':digest(args['patch']),
|
|
616
|
+
'signature':args.get('content_signature'),'revision_id':args.get('revision_id'),
|
|
617
|
+
'content_created_at':args.get('content_created_at')}
|
|
618
|
+
return await revise_resource(app,ctx,request,tx,resource,body,media,
|
|
619
|
+
relations=old.relations,author=old.author,**extra)
|
|
620
|
+
|
|
621
|
+
@op('content.post_patch',post_patch_schema,requirements=requirement('id','write'))
|
|
622
|
+
@op('content.post_patch',structured_patch_schema,requirements=requirement('id','write'),version=2)
|
|
623
|
+
@op('content.text_patch',post_patch_schema,requirements=requirement('id','write'))
|
|
624
|
+
@op('content.text_patch',rebase_patch_schema,requirements=requirement('id','write'),version=2)
|
|
625
|
+
@op('content.text_patch',structured_patch_schema,requirements=requirement('id','write'),version=3)
|
|
626
|
+
async def text_patch(ctx,request,tx):
|
|
627
|
+
args=request.arguments
|
|
628
|
+
prepared=await prepare_text_patch(app,ctx,request,tx,args,
|
|
629
|
+
post_only=request.operation=='content.post_patch')
|
|
630
|
+
updated=await publish_patch(ctx,request,tx,args,prepared)
|
|
631
|
+
return output_for(updated)
|
|
632
|
+
|
|
633
|
+
@op('content.text_patch_batch',obj({'patches':{'type':'array','minItems':1,'maxItems':16,
|
|
634
|
+
'items':rebase_patch_schema}},('patches',)))
|
|
635
|
+
@op('content.text_patch_batch',obj({'patches':{'type':'array','minItems':1,'maxItems':16,
|
|
636
|
+
'items':structured_patch_schema}},('patches',)),version=2)
|
|
637
|
+
async def text_patch_batch(ctx,request,tx):
|
|
638
|
+
patches=request.arguments['patches']
|
|
639
|
+
resolved=[await resolve(tx,item['id']) for item in patches]
|
|
640
|
+
require(len(set(resolved))==len(resolved),'duplicate_patch_target')
|
|
641
|
+
# Validate every target before the first content put. The executor owns
|
|
642
|
+
# the surrounding SQL transaction; a later signature/storage failure
|
|
643
|
+
# rolls back all published references, leaving only reclaimable blobs.
|
|
644
|
+
prepared=[]
|
|
645
|
+
for item,rid in zip(patches,resolved):
|
|
646
|
+
prepared.append(await prepare_text_patch(app,ctx,request,tx,{**item,'id':rid}))
|
|
647
|
+
updated=[]
|
|
648
|
+
for args,item in zip(patches,prepared):
|
|
649
|
+
updated.append(await publish_patch(ctx,request,tx,args,item))
|
|
650
|
+
data={'generations':{r.id:r.generation for r in updated}}
|
|
651
|
+
if request.contract_version>=2:
|
|
652
|
+
data['items']=[{'id':r.id,'revision':r.revision,'generation':r.generation} for r in updated]
|
|
653
|
+
return HandlerOutput(resources=tuple(ResourceRef(id=r.id,revision=r.revision) for r in updated),
|
|
654
|
+
data=data)
|
|
655
|
+
|
|
656
|
+
async def lifecycle(ctx,request,tx):
|
|
657
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
658
|
+
require(resource.type in {'post','file','attachment','topic','template','skill','repo','website','keystore'},'controlled_resource')
|
|
659
|
+
await assert_generation(request,resource)
|
|
660
|
+
await removable(app,ctx,request,tx,resource)
|
|
661
|
+
require(resource.state!='purged','resource_purged')
|
|
662
|
+
state='archived' if request.operation=='content.archive' else 'active'
|
|
663
|
+
if state=='active' and resource.type=='website':
|
|
664
|
+
from msg.plugins.hosting_capacity import manifest_size,require_capacity
|
|
665
|
+
await require_capacity(app,tx,resource,await manifest_size(app,tx,resource),ctx.now)
|
|
666
|
+
if state=='active' and resource.type=='repo':
|
|
667
|
+
await ensure_public_repositories(tx,resource,mode=resource.mode,parent=resource.parent)
|
|
668
|
+
updated=replace(resource,state=state,generation=resource.generation+1,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
669
|
+
await tx.replace(updated,resource.generation)
|
|
670
|
+
if resource.type=='topic':
|
|
671
|
+
await topic_governance_event(tx,ctx,request,resource.id,
|
|
672
|
+
'archive' if state=='archived' else 'restore',ctx.principal.subject)
|
|
673
|
+
return output_for(updated,state=state)
|
|
674
|
+
for name in ('content.archive','content.restore'):
|
|
675
|
+
op(name,obj({'id':IDENTIFIER},('id',)))(lifecycle)
|
|
676
|
+
|
|
677
|
+
@op('content.move',obj({'id':IDENTIFIER,'parent':IDENTIFIER,'name':STRING},('id','parent')))
|
|
678
|
+
async def move(ctx,request,tx):
|
|
679
|
+
from msg.plugins.communication import direct_ancestor
|
|
680
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
681
|
+
require(resource.type in {'post','file','attachment','topic','template','skill','repo','website','keystore'},'controlled_resource')
|
|
682
|
+
await assert_generation(request,resource)
|
|
683
|
+
await removable(app,ctx,request,tx,resource)
|
|
684
|
+
require(await direct_ancestor(tx,resource.id) is None,'dm_controlled_resource')
|
|
685
|
+
target=await resolve(tx,request.arguments['parent'])
|
|
686
|
+
require(target!='t_store','store_controlled_resource')
|
|
687
|
+
await check_access(app,ctx,request,tx,target,'create')
|
|
688
|
+
parent=await tx.resource(target)
|
|
689
|
+
require(app.registry.resource_type(parent.type,1).container,'not_a_container')
|
|
690
|
+
await ensure_public_repositories(tx,resource,parent=target)
|
|
691
|
+
name=validate_name(request.arguments.get('name',resource.name))
|
|
692
|
+
if parent.type=='user' and name in {'SOUL.md','AGENTS.md','notes'}:
|
|
693
|
+
require(False,'personal_managed_resource')
|
|
694
|
+
if (parent.name=='notes' and parent.parent is not None and
|
|
695
|
+
(await tx.resource(parent.parent)).type=='user'):
|
|
696
|
+
require(False,'personal_managed_resource')
|
|
697
|
+
await protect_namespace(app,ctx,request,tx,parent,name)
|
|
698
|
+
updated=replace(resource,parent=target,name=name,generation=resource.generation+1,
|
|
699
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
700
|
+
await tx.replace(updated,resource.generation)
|
|
701
|
+
if resource.type=='topic':
|
|
702
|
+
await topic_governance_event(tx,ctx,request,resource.id,'move',ctx.principal.subject)
|
|
703
|
+
return output_for(updated)
|
|
704
|
+
|
|
705
|
+
@op('content.chmod',obj({'id':IDENTIFIER,'mode':{'type':'string','pattern':'^[0-7]{4}$'}},('id','mode')),
|
|
706
|
+
requirements=requirement('id','chmod'),signature=True)
|
|
707
|
+
async def chmod(ctx,request,tx):
|
|
708
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
709
|
+
await require_unmanaged_personal(tx,resource)
|
|
710
|
+
await assert_generation(request,resource)
|
|
711
|
+
require(resource.type not in {'csr','certificate','tool','delegation','listing'} and
|
|
712
|
+
resource.id!='t_store','controlled_resource')
|
|
713
|
+
mode=int(request.arguments['mode'],8)
|
|
714
|
+
await ensure_public_repositories(tx,resource,mode=mode)
|
|
715
|
+
updated=replace(resource,mode=mode,generation=resource.generation+1,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
716
|
+
await tx.replace(updated,resource.generation)
|
|
717
|
+
if resource.type=='topic':
|
|
718
|
+
await topic_governance_event(tx,ctx,request,resource.id,'chmod',ctx.principal.subject)
|
|
719
|
+
return output_for(updated,mode=f'{mode:04o}')
|
|
720
|
+
|
|
721
|
+
@op('content.chgrp',obj({'id':IDENTIFIER,'group':IDENTIFIER},('id','group')),
|
|
722
|
+
requirements=requirement('id','chgrp'),signature=True)
|
|
723
|
+
async def chgrp(ctx,request,tx):
|
|
724
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
725
|
+
await require_unmanaged_personal(tx,resource)
|
|
726
|
+
await assert_generation(request,resource)
|
|
727
|
+
require(resource.type!='listing' and resource.id!='t_store','controlled_resource')
|
|
728
|
+
group=await resolve(tx,request.arguments['group'])
|
|
729
|
+
await tx.organization(group)
|
|
730
|
+
member=any(m.organization_id==group for m in await tx.memberships(ctx.principal.subject))
|
|
731
|
+
require(member or await app.authorizer.has(ctx.principal,'resource.chgrp_override',operation_id(request),resource.id,tx),
|
|
732
|
+
'group_membership_required')
|
|
733
|
+
updated=replace(resource,group=group,generation=resource.generation+1,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
734
|
+
await tx.replace(updated,resource.generation)
|
|
735
|
+
if resource.type=='topic':
|
|
736
|
+
await topic_governance_event(tx,ctx,request,resource.id,'chgrp',ctx.principal.subject)
|
|
737
|
+
return output_for(updated,group=group)
|
|
738
|
+
|
|
739
|
+
@op('content.chown',obj({'id':IDENTIFIER,'owner':IDENTIFIER},('id','owner')),
|
|
740
|
+
requirements=requirement('id','chown'),signature=True)
|
|
741
|
+
async def chown(ctx,request,tx):
|
|
742
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
743
|
+
await require_unmanaged_personal(tx,resource)
|
|
744
|
+
await assert_generation(request,resource)
|
|
745
|
+
require(resource.type not in {'user','organization','certificate','csr','tool','delegation','listing'} and
|
|
746
|
+
resource.id!='t_store','controlled_resource')
|
|
747
|
+
owner=await resolve(tx,request.arguments['owner'])
|
|
748
|
+
subject=await tx.subject(owner)
|
|
749
|
+
require(not subject.local_only,'local_only')
|
|
750
|
+
if resource.type=='website' and resource.state=='active':
|
|
751
|
+
from msg.plugins.hosting_capacity import manifest_size,require_capacity
|
|
752
|
+
await require_capacity(app,tx,resource,await manifest_size(app,tx,resource),ctx.now,owner=owner)
|
|
753
|
+
updated=replace(resource,owner=owner,generation=resource.generation+1,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
754
|
+
await tx.replace(updated,resource.generation)
|
|
755
|
+
if resource.type=='topic':
|
|
756
|
+
await topic_governance_event(tx,ctx,request,resource.id,'chown',ctx.principal.subject)
|
|
757
|
+
return output_for(updated,owner=owner)
|
|
758
|
+
|
|
759
|
+
@op('content.purge',obj({'id':IDENTIFIER,'reason':STRING},('id','reason')),
|
|
760
|
+
requirements=requirement('id','purge'),signature=True)
|
|
761
|
+
async def purge(ctx,request,tx):
|
|
762
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
763
|
+
await require_unmanaged_personal(tx,resource)
|
|
764
|
+
await assert_generation(request,resource)
|
|
765
|
+
require(resource.type in {'post','file','attachment','template','skill','keystore'},'purge_leaf_only')
|
|
766
|
+
revisions=[decode(__import__('msg.core.models',fromlist=['Revision']).Revision,loads(r[0]))
|
|
767
|
+
for r in tx.rows('SELECT body FROM revisions WHERE resource_id=?',(resource.id,))]
|
|
768
|
+
updated=replace(resource,state='purged',revision=None,generation=resource.generation+1,
|
|
769
|
+
modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
770
|
+
await tx.replace(updated,resource.generation)
|
|
771
|
+
tx.execute('DELETE FROM revisions WHERE resource_id=?',(resource.id,),write=True)
|
|
772
|
+
tx.execute('DELETE FROM projections WHERE resource_id=?',(resource.id,),write=True)
|
|
773
|
+
from msg.plugins.communication import event_id
|
|
774
|
+
job=EffectJob(id=new_id('job'),event_id=event_id(request,ctx.principal.subject),kind='gc.resource',dedupe_key='purge:'+ctx.principal.subject+':'+request.request_id,
|
|
775
|
+
principal=ctx.principal,operation=request.operation,arguments={'id':resource.id,'revisions':wire(revisions)},
|
|
776
|
+
state='pending',attempts=0,next_attempt_at=ctx.now,lease_until=None)
|
|
777
|
+
await tx.enqueue(job)
|
|
778
|
+
return output_for(updated,state='purged',physical_cleanup=job.id,backup_scope='backups_require_separate_retention')
|
|
779
|
+
|
|
780
|
+
@op('content.topic_configure',obj({'id':IDENTIFIER,'policy':obj({
|
|
781
|
+
'reply_open':BOOLEAN,'editable':BOOLEAN,'post_mode':{'type':'string','pattern':'^[0-7]{4}$'},
|
|
782
|
+
'file_mode':{'type':'string','pattern':'^[0-7]{4}$'},'topic_mode':{'type':'string','pattern':'^[0-7]{4}$'},
|
|
783
|
+
'recommended_template':STRING,'retention_seconds':{'type':'integer','minimum':60},
|
|
784
|
+
'cleanup_interval_seconds':{'type':'integer','minimum':60}})},('id','policy')),
|
|
785
|
+
requirements=requirement('id','manage'),signature=True)
|
|
786
|
+
async def configure(ctx,request,tx):
|
|
787
|
+
resource=await tx.resource(await resolve(tx,request.arguments['id']))
|
|
788
|
+
await require_unmanaged_personal(tx,resource)
|
|
789
|
+
require(resource.type=='topic','not_a_topic')
|
|
790
|
+
await assert_generation(request,resource)
|
|
791
|
+
tx.set_setting('policy:'+resource.id,request.arguments['policy'])
|
|
792
|
+
updated=replace(resource,generation=resource.generation+1,modified_at=ctx.now,modified_by=ctx.principal.actor)
|
|
793
|
+
await tx.replace(updated,resource.generation)
|
|
794
|
+
await topic_governance_event(tx,ctx,request,resource.id,'configure',ctx.principal.subject)
|
|
795
|
+
return output_for(updated)
|
|
796
|
+
|
|
797
|
+
@op('content.template_put',obj({'parent':IDENTIFIER,'source':STRING},('parent','source')),
|
|
798
|
+
requirements=requirement('parent','create'),signature=True)
|
|
799
|
+
async def template_put(ctx,request,tx):
|
|
800
|
+
parent=await resolve(tx,request.arguments['parent'])
|
|
801
|
+
source=request.arguments['source']
|
|
802
|
+
template=parse_template(source)
|
|
803
|
+
existing=tx.one('SELECT id FROM resources WHERE parent=? AND name=?',(parent,template.name))
|
|
804
|
+
if existing:
|
|
805
|
+
resource=await tx.resource(existing[0])
|
|
806
|
+
require(resource.type=='template','name_conflict')
|
|
807
|
+
await check_access(app,ctx,request,tx,resource.id,'write')
|
|
808
|
+
await assert_generation(request,resource)
|
|
809
|
+
require(tx.setting(f'template_version:{resource.id}:{template.version}') is None,'template_version_exists')
|
|
810
|
+
resource=await revise_resource(app,ctx,request,tx,resource,source,'application/msg-template')
|
|
811
|
+
else:
|
|
812
|
+
resource=await create_resource(app,ctx,request,tx,parent=parent,type='template',name=template.name,
|
|
813
|
+
body=source,media_type='application/msg-template')
|
|
814
|
+
tx.set_setting(f'template_version:{resource.id}:{template.version}',resource.revision)
|
|
815
|
+
rev=await tx.revision(ResourceRef(id=resource.id))
|
|
816
|
+
return output_for(resource,template_id=resource.id,template_version=template.version,template_digest=rev.content.digest)
|
|
817
|
+
|
|
818
|
+
@op('content.file_put',obj({'parent':IDENTIFIER,'name':STRING,'data':BYTES,'source':REF,
|
|
819
|
+
'media_type':STRING},('parent','name')),requirements=requirement('parent','create'))
|
|
820
|
+
async def file_put(ctx,request,tx):
|
|
821
|
+
a=request.arguments
|
|
822
|
+
require(('data' in a)!=('source' in a),'one_content_source_required')
|
|
823
|
+
media=a.get('media_type','application/octet-stream')
|
|
824
|
+
require('\r' not in media and '\n' not in media,'invalid_media_type')
|
|
825
|
+
if 'source' in a:
|
|
826
|
+
ref=decode(ResourceRef,a['source'])
|
|
827
|
+
await check_access(app,ctx,request,tx,ref.id,'read')
|
|
828
|
+
body=(await tx.revision(ref)).content
|
|
829
|
+
media=body.media_type
|
|
830
|
+
else:
|
|
831
|
+
body=unb64(a['data'],limit=app.settings.server.limits.max_request_bytes)
|
|
832
|
+
resource=await create_resource(app,ctx,request,tx,parent=await resolve(tx,a['parent']),type='file',
|
|
833
|
+
name=a['name'],body=body,media_type=media)
|
|
834
|
+
return output_for(resource)
|
|
835
|
+
|
|
836
|
+
@op('content.attach',obj({'post':IDENTIFIER,'source':REF,'name':STRING},('post','source')))
|
|
837
|
+
async def attach(ctx,request,tx):
|
|
838
|
+
post=await tx.resource(await resolve(tx,request.arguments['post']))
|
|
839
|
+
require(post.type=='post' and post.state=='active','not_a_post')
|
|
840
|
+
await assert_generation(request,post)
|
|
841
|
+
await check_access(app,ctx,request,tx,post.id,'write')
|
|
842
|
+
ref=decode(ResourceRef,request.arguments['source'])
|
|
843
|
+
await check_access(app,ctx,request,tx,ref.id,'read')
|
|
844
|
+
source=await tx.revision(ref)
|
|
845
|
+
await check_access(app,ctx,request,tx,post.parent,'create')
|
|
846
|
+
attached=await create_resource(app,ctx,request,tx,parent=post.parent,type='attachment',
|
|
847
|
+
name=request.arguments.get('name',new_id('attachment')),body=source.content,media_type=source.content.media_type,
|
|
848
|
+
mode=post.mode&0o777)
|
|
849
|
+
tx.set_setting('publication:'+attached.id,{'source':wire(ref),'post':post.id,'publisher':ctx.principal.subject})
|
|
850
|
+
old=await tx.revision(ResourceRef(id=post.id))
|
|
851
|
+
updated=await revise_resource(app,ctx,request,tx,post,old.content,old.content.media_type,
|
|
852
|
+
relations=(*old.relations,Relation(type='attachment',target=ResourceRef(id=attached.id,revision=attached.revision))),
|
|
853
|
+
author=old.author)
|
|
854
|
+
return output_for(updated,attachment=wire(ResourceRef(id=attached.id,revision=attached.revision)))
|
|
855
|
+
finish()
|