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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Replies are posts; likes and ACK are explicit authenticated facts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from msg.core.codec import canonical,decode,wire,digest,loads,b64
|
|
4
|
+
from msg.core.errors import Failure,require
|
|
5
|
+
from msg.core.models import ResourceRef,Relation,HandlerOutput
|
|
6
|
+
from msg.core.requests import signing_bytes
|
|
7
|
+
from msg.plugins.common import *
|
|
8
|
+
from msg.plugins.schemas import *
|
|
9
|
+
from msg.plugins.content import create_post
|
|
10
|
+
from msg.plugins.discovery import visible,read_projection
|
|
11
|
+
from msg.security.policy import CERTGATE
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def gates(app,ctx,request,tx,rid):
|
|
15
|
+
await app.authorizer._ceiling(ctx.principal,operation_id(request),rid,tx)
|
|
16
|
+
chain=(*await tx.ancestors(rid),await tx.resource(rid))
|
|
17
|
+
if any(r.mode&CERTGATE for r in chain):
|
|
18
|
+
require(await app.authorizer.has(ctx.principal,'resource.certified_write',operation_id(request),rid,tx),'certificate_gate')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def install(app):
|
|
22
|
+
op,finish=registration(app,'discussion',('identity','content'))
|
|
23
|
+
fields={k:v for k,v in app.post_fields.items() if k!='parent'}
|
|
24
|
+
|
|
25
|
+
@op('discussion.reply',obj({**fields,'target':REF},('target',)))
|
|
26
|
+
async def reply(ctx,request,tx):
|
|
27
|
+
target=decode(ResourceRef,request.arguments['target'])
|
|
28
|
+
original=await tx.resource(target.id)
|
|
29
|
+
require(original.type=='post' and original.state=='active','not_a_post')
|
|
30
|
+
await check_access(app,ctx,request,tx,target.id,'read')
|
|
31
|
+
revision=await tx.revision(target)
|
|
32
|
+
parent=original.parent
|
|
33
|
+
require(tx.setting('policy:'+parent,{}).get('reply_open',True),'replies_closed')
|
|
34
|
+
await check_access(app,ctx,request,tx,parent,'create')
|
|
35
|
+
thread=next((r.target for r in revision.relations if r.type=='thread_root'),ResourceRef(id=original.id,revision=revision.id))
|
|
36
|
+
require((await tx.resource(thread.id)).parent==parent,'cross_thread_reply')
|
|
37
|
+
relations=(Relation(type='reply_to',target=ResourceRef(id=original.id,revision=revision.id)),Relation(type='thread_root',target=thread))
|
|
38
|
+
resource,meta=await create_post(app,ctx,request,tx,parent=parent,relations=relations)
|
|
39
|
+
return output_for(resource,**meta)
|
|
40
|
+
|
|
41
|
+
async def quote(ctx,request,tx):
|
|
42
|
+
from msg.plugins.communication import direct_ancestor
|
|
43
|
+
target=decode(ResourceRef,request.arguments['target'])
|
|
44
|
+
await check_access(app,ctx,request,tx,target.id,'read')
|
|
45
|
+
require((await tx.resource(target.id)).type!='legacy_directive',
|
|
46
|
+
'legacy_directive_not_shareable')
|
|
47
|
+
require(await direct_ancestor(tx,target.id) is None,'dm_reference_private')
|
|
48
|
+
source=await tx.revision(target)
|
|
49
|
+
parent=await resolve(tx,request.arguments['parent'])
|
|
50
|
+
await check_access(app,ctx,request,tx,parent,'create')
|
|
51
|
+
kind='quote' if request.operation=='discussion.quote' else 'repost'
|
|
52
|
+
relation=Relation(type=kind,target=ResourceRef(id=target.id,revision=source.id))
|
|
53
|
+
resource,meta=await create_post(app,ctx,request,tx,parent=parent,relations=(relation,))
|
|
54
|
+
return output_for(resource,**meta)
|
|
55
|
+
for name in ('discussion.quote','discussion.repost'):
|
|
56
|
+
op(name,obj({**app.post_fields,'target':REF},('parent','target','body')))(quote)
|
|
57
|
+
|
|
58
|
+
async def reaction(ctx,request,tx):
|
|
59
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
60
|
+
resource=await tx.resource(rid)
|
|
61
|
+
require(resource.type=='post' and resource.state=='active','not_a_post')
|
|
62
|
+
await check_access(app,ctx,request,tx,rid,'read')
|
|
63
|
+
await gates(app,ctx,request,tx,rid)
|
|
64
|
+
if request.operation=='discussion.like':
|
|
65
|
+
tx.execute("INSERT INTO reactions VALUES (?,?,?,? ,?) ON CONFLICT(subject,resource,kind,revision) DO NOTHING",
|
|
66
|
+
(ctx.principal.subject,rid,'like','',canonical({'actor':ctx.principal.actor,'time':wire(ctx.now)}).decode()),write=True)
|
|
67
|
+
else:
|
|
68
|
+
tx.execute("DELETE FROM reactions WHERE subject=? AND resource=? AND kind='like'",(ctx.principal.subject,rid),write=True)
|
|
69
|
+
count=tx.one("SELECT COUNT(*) FROM reactions WHERE resource=? AND kind='like'",(rid,))[0]
|
|
70
|
+
return HandlerOutput(resources=(ResourceRef(id=rid),),data={'likes':count,'liked':request.operation=='discussion.like'})
|
|
71
|
+
for name in ('discussion.like','discussion.unlike'):
|
|
72
|
+
op(name,obj({'id':IDENTIFIER},('id',)))(reaction)
|
|
73
|
+
|
|
74
|
+
@op('discussion.ack',obj({'target':REF,'digest':STRING},('target','digest')))
|
|
75
|
+
async def ack(ctx,request,tx):
|
|
76
|
+
target=decode(ResourceRef,request.arguments['target'])
|
|
77
|
+
require(target.revision is not None,'ack_revision_required')
|
|
78
|
+
await check_access(app,ctx,request,tx,target.id,'read')
|
|
79
|
+
await gates(app,ctx,request,tx,target.id)
|
|
80
|
+
rev=await tx.revision(target)
|
|
81
|
+
require(request.arguments['digest'] in {rev.content.digest,rev.manifest_digest},'ack_digest_mismatch')
|
|
82
|
+
require(ctx.principal.method in {'signature','token'},'ack_signature_or_token_required')
|
|
83
|
+
method=ctx.principal.method
|
|
84
|
+
record={'actor':ctx.principal.actor,'subject':ctx.principal.subject,'auth':method,'revision':rev.id,
|
|
85
|
+
'digest':request.arguments['digest'],'time':wire(ctx.now)}
|
|
86
|
+
if method=='signature':
|
|
87
|
+
record.update(signature=wire(request.proof.signature),signed_envelope=b64(signing_bytes(request)))
|
|
88
|
+
kind='ack.'+method
|
|
89
|
+
if tx.one('SELECT 1 FROM reactions WHERE subject=? AND resource=? AND kind=? AND revision=?',
|
|
90
|
+
(ctx.principal.subject,target.id,kind,rev.id)) is None:
|
|
91
|
+
from msg.storage.capacity import require_reaction_capacity
|
|
92
|
+
require_reaction_capacity(tx)
|
|
93
|
+
tx.execute('INSERT INTO reactions VALUES (?,?,?,?,?) ON CONFLICT(subject,resource,kind,revision) DO NOTHING',
|
|
94
|
+
(ctx.principal.subject,target.id,kind,rev.id,canonical(record).decode()),write=True)
|
|
95
|
+
return HandlerOutput(resources=(target,),data={'auth':method,'acknowledged_revision':rev.id})
|
|
96
|
+
|
|
97
|
+
@op('discussion.acks',obj({'id':IDENTIFIER,'revision':IDENTIFIER,'cursor':STRING,
|
|
98
|
+
'limit':{'type':'integer','minimum':1,'maximum':200}},('id',)),effect='read')
|
|
99
|
+
async def acks(ctx,request,tx):
|
|
100
|
+
from msg.plugins.discovery import next_link
|
|
101
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
102
|
+
await check_access(app,ctx,request,tx,rid,'read')
|
|
103
|
+
filters="resource=? AND kind IN ('ack.signature','ack.token')"
|
|
104
|
+
params=[rid]
|
|
105
|
+
if request.arguments.get('revision'):
|
|
106
|
+
filters+=' AND revision=?'; params.append(request.arguments['revision'])
|
|
107
|
+
binding=digest({'id':rid,'revision':request.arguments.get('revision')})
|
|
108
|
+
position=app.cursors.decode(request.arguments['cursor'],'acks',binding) if request.arguments.get('cursor') else ['','','']
|
|
109
|
+
counts={kind:count for kind,count in tx.rows(f'SELECT kind,COUNT(DISTINCT subject) FROM reactions WHERE {filters} GROUP BY kind',params)}
|
|
110
|
+
limit=request.arguments.get('limit',50)
|
|
111
|
+
rows=tx.rows(f'SELECT body,subject,revision,kind FROM reactions WHERE {filters} AND (subject,revision,kind)>(?,?,?) ORDER BY subject,revision,kind LIMIT ?',
|
|
112
|
+
(*params,*position,limit+1))
|
|
113
|
+
data={'signed':counts.get('ack.signature',0),'token':counts.get('ack.token',0),
|
|
114
|
+
'items':[loads(row[0]) for row in rows[:limit]]}
|
|
115
|
+
if len(rows)>limit:
|
|
116
|
+
cursor=app.cursors.encode('acks',binding,list(rows[limit-1][1:]))
|
|
117
|
+
data.update(cursor=cursor,next=next_link(app,request.operation,{**request.arguments,'cursor':cursor}),
|
|
118
|
+
next_requires_auth=ctx.principal.subject is not None)
|
|
119
|
+
return HandlerOutput(data=data)
|
|
120
|
+
|
|
121
|
+
@op('discussion.thread',obj({'id':IDENTIFIER,'cursor':STRING,
|
|
122
|
+
'limit':{'type':'integer','minimum':1,'maximum':200}},('id',)),effect='read')
|
|
123
|
+
async def thread(ctx,request,tx):
|
|
124
|
+
from msg.plugins.discovery import next_link
|
|
125
|
+
rid=await resolve(tx,request.arguments['id'])
|
|
126
|
+
await check_access(app,ctx,request,tx,rid,'read')
|
|
127
|
+
revision=await tx.revision(ResourceRef(id=rid))
|
|
128
|
+
root=next((r.target.id for r in revision.relations if r.type=='thread_root'),rid)
|
|
129
|
+
binding=digest({'root':root,'focus':rid})
|
|
130
|
+
position=app.cursors.decode(request.arguments['cursor'],'thread',binding) if request.arguments.get('cursor') else [-1,'','']
|
|
131
|
+
items=[];limit=request.arguments.get('limit',50);more=False
|
|
132
|
+
sql="""SELECT r.id,r.created_at,CASE WHEN r.id=? THEN 0 ELSE 1 END AS priority
|
|
133
|
+
FROM resources r WHERE r.state='active' AND
|
|
134
|
+
(r.id=? OR EXISTS(SELECT 1 FROM relations rel WHERE rel.source_id=r.id
|
|
135
|
+
AND rel.revision_id=r.revision AND rel.type='thread_root' AND rel.target_id=?))
|
|
136
|
+
AND (CASE WHEN r.id=? THEN 0 ELSE 1 END,r.created_at,r.id)>(?,?,?)
|
|
137
|
+
ORDER BY priority,r.created_at,r.id"""
|
|
138
|
+
for id,created_at,priority in tx.execute(sql,(root,root,root,root,*position)):
|
|
139
|
+
if not await visible(app,ctx,request,tx,id):continue
|
|
140
|
+
if len(items)==limit:more=True;break
|
|
141
|
+
items.append(await read_projection(app,ctx,request,tx,id))
|
|
142
|
+
position=[priority,created_at,id]
|
|
143
|
+
data={'root':root,'items':items}
|
|
144
|
+
if more:
|
|
145
|
+
cursor=app.cursors.encode('thread',binding,position)
|
|
146
|
+
data.update(cursor=cursor,next=next_link(app,request.operation,{**request.arguments,'cursor':cursor}),
|
|
147
|
+
next_requires_auth=ctx.principal.subject is not None)
|
|
148
|
+
# Exact ancestors of a requested reply are references, not duplicated
|
|
149
|
+
# posts/authors/certificate chains. The root has its own normal page item.
|
|
150
|
+
if not request.arguments.get('cursor') and rid!=root:
|
|
151
|
+
ancestors=[];seen={rid};current=revision
|
|
152
|
+
for _ in range(128):
|
|
153
|
+
parent=next((r.target for r in current.relations if r.type=='reply_to'),None)
|
|
154
|
+
if parent is None or parent.id in seen:break
|
|
155
|
+
seen.add(parent.id)
|
|
156
|
+
if not await visible(app,ctx,request,tx,parent.id):break
|
|
157
|
+
ancestors.append(wire(parent))
|
|
158
|
+
if parent.id==root:break
|
|
159
|
+
current=await tx.revision(parent)
|
|
160
|
+
data['ancestors']=ancestors
|
|
161
|
+
return HandlerOutput(data=data)
|
|
162
|
+
finish()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Packaged extensions register ordinary operations on the common executor."""
|
|
2
|
+
from msg.plugins.common import registration
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def install(app):
|
|
6
|
+
op,finish=registration(app,'extensions',('identity','content','discovery','transfer'))
|
|
7
|
+
from msg.extensions import tools,keystore,hosting,repositories,ssh,rss
|
|
8
|
+
for extension in (tools,keystore,hosting,repositories,ssh,rss):
|
|
9
|
+
extension.register(app,op)
|
|
10
|
+
finish()
|
msg/plugins/following.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""A private, bounded read projection of existing watch rows, never a new feed."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
from msg.core.codec import wire
|
|
5
|
+
from msg.core.errors import require
|
|
6
|
+
from msg.core.models import HandlerOutput
|
|
7
|
+
from msg.core.read_query import ReadBudget
|
|
8
|
+
from msg.plugins.common import operation_id
|
|
9
|
+
from msg.plugins.discovery import short_subject_path, visible
|
|
10
|
+
from msg.plugins.schemas import obj
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
FOLLOWING_SCHEMA = obj({
|
|
14
|
+
'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
|
|
15
|
+
'cursor': {'type': 'string', 'minLength': 1, 'maxLength': 8192},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def install(app, op):
|
|
20
|
+
@op('communication.following', FOLLOWING_SCHEMA, effect='read')
|
|
21
|
+
async def following(ctx, request, tx):
|
|
22
|
+
subject = ctx.principal.subject
|
|
23
|
+
require(subject is not None, 'authentication_required')
|
|
24
|
+
await app.authorizer.require_base(ctx.principal, operation_id(request), subject, tx)
|
|
25
|
+
budget = ReadBudget(ctx.deadline_monotonic, app.settings.server.limits.max_response_bytes)
|
|
26
|
+
principal = {'actor': ctx.principal.actor, 'subject': subject,
|
|
27
|
+
'credential_id': ctx.principal.credential_id}
|
|
28
|
+
arguments = dict(request.arguments)
|
|
29
|
+
after, snapshot = '', ctx.now
|
|
30
|
+
if 'cursor' in arguments:
|
|
31
|
+
require(set(arguments) == {'cursor'}, 'cursor_query_mismatch')
|
|
32
|
+
token = arguments['cursor']
|
|
33
|
+
query, _ = app.cursors.inspect_page(token, ctx.now)
|
|
34
|
+
require(query.get('operation') == request.operation, 'cursor_query_mismatch')
|
|
35
|
+
arguments = query['arguments']
|
|
36
|
+
app.registry.validate(app.registry.operation(request.operation).input_schema, arguments)
|
|
37
|
+
require(set(arguments) == {'limit'}, 'cursor_query_mismatch')
|
|
38
|
+
after, snapshot = app.cursors.decode_page(token, request.operation, arguments,
|
|
39
|
+
principal, ctx.now)
|
|
40
|
+
else:
|
|
41
|
+
arguments = {'limit': arguments.get('limit', 20)}
|
|
42
|
+
limit = arguments['limit']
|
|
43
|
+
items = []
|
|
44
|
+
more = False
|
|
45
|
+
# Watches have no historical membership snapshot. The cursor bounds
|
|
46
|
+
# Resource creation and stable ID order; removals/revocation always win.
|
|
47
|
+
# Keyset scanning remains bounded even if most rows are now invisible.
|
|
48
|
+
while True:
|
|
49
|
+
budget.check()
|
|
50
|
+
rows = list(tx.execute(
|
|
51
|
+
'SELECT r.id FROM watches AS w JOIN resources AS r ON r.id=w.resource '
|
|
52
|
+
"WHERE w.subject=? AND r.state='active' AND r.created_at<=? AND r.id>? "
|
|
53
|
+
'ORDER BY r.id LIMIT 128', (subject, wire(snapshot), after)))
|
|
54
|
+
for (rid,) in rows:
|
|
55
|
+
budget.scan()
|
|
56
|
+
after = rid
|
|
57
|
+
if not await visible(app, ctx, request, tx, rid):
|
|
58
|
+
continue
|
|
59
|
+
if len(items) == limit:
|
|
60
|
+
more = True
|
|
61
|
+
break
|
|
62
|
+
resource = await tx.resource(rid)
|
|
63
|
+
budget.node(5)
|
|
64
|
+
items.append({'id': rid, 'type': resource.type, 'name': resource.name,
|
|
65
|
+
'path': short_subject_path(await tx.path(rid)),
|
|
66
|
+
'revision': resource.revision})
|
|
67
|
+
if more or len(rows) < 128:
|
|
68
|
+
break
|
|
69
|
+
# Never encode a hidden/scanned resource ID or a private count in the
|
|
70
|
+
# cursor. A cursor authenticates position, not access to any resource.
|
|
71
|
+
token = (app.cursors.encode_page(request.operation, arguments, items[-1]['id'],
|
|
72
|
+
snapshot, principal, ctx.now + timedelta(minutes=15))
|
|
73
|
+
if more else None)
|
|
74
|
+
data = {'items': items, 'pageInfo': {'hasNextPage': more, 'endCursor': token}}
|
|
75
|
+
if token:
|
|
76
|
+
data.update(cursor=token, next='/_r/c/' + token, next_requires_auth=True)
|
|
77
|
+
budget.output(data)
|
|
78
|
+
return HandlerOutput(data=data)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Concrete byte-capacity entitlement consumer; never an authorization source.
|
|
2
|
+
|
|
3
|
+
The quota is the logical size of current website deployments owned by a subject.
|
|
4
|
+
Expired capacity does not delete published content; it only limits future writes.
|
|
5
|
+
"""
|
|
6
|
+
from msg.core.codec import decode, loads, wire
|
|
7
|
+
from msg.core.errors import require
|
|
8
|
+
from msg.core.models import ResourceRef
|
|
9
|
+
|
|
10
|
+
ENTITLEMENT_KIND = 'hosting_bytes_v1'
|
|
11
|
+
PROVIDER_VERSION = 1
|
|
12
|
+
BASE_CAPACITY_BYTES = 10 * 1024 * 1024
|
|
13
|
+
MAX_CAPACITY_BYTES = 1024 ** 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extra_capacity(tx, subject, now):
|
|
17
|
+
return int(tx.one('''SELECT COALESCE(SUM(quantity),0) FROM resource_entitlements
|
|
18
|
+
WHERE subject_id=? AND entitlement_kind=? AND
|
|
19
|
+
(expires_at IS NULL OR expires_at>?)''',
|
|
20
|
+
(subject, ENTITLEMENT_KIND, wire(now)))[0])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def manifest_size(app, tx, resource, revision_id=None):
|
|
24
|
+
revision_id = revision_id or resource.revision
|
|
25
|
+
if revision_id is None:
|
|
26
|
+
return 0
|
|
27
|
+
revision = await tx.revision(ResourceRef(id=resource.id, revision=revision_id))
|
|
28
|
+
value = loads(await app.contents.read_bytes(revision.content))
|
|
29
|
+
require(isinstance(value, dict) and isinstance(value.get('entries'), dict),
|
|
30
|
+
'invalid_hosting_manifest')
|
|
31
|
+
size = 0
|
|
32
|
+
for ref in value['entries'].values():
|
|
33
|
+
item = await tx.revision(decode(ResourceRef, ref))
|
|
34
|
+
size += item.content.size
|
|
35
|
+
require(size <= MAX_CAPACITY_BYTES, 'hosting_capacity_exceeded')
|
|
36
|
+
return size
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def used_capacity(app, tx, owner, *, excluding=None):
|
|
40
|
+
total = 0
|
|
41
|
+
for (rid,) in tx.rows("SELECT id FROM resources WHERE type='website' AND owner=? AND state='active'", (owner,)):
|
|
42
|
+
if rid != excluding:
|
|
43
|
+
total += await manifest_size(app, tx, await tx.resource(rid))
|
|
44
|
+
return total
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def require_capacity(app, tx, resource, proposed_size, now, *, owner=None):
|
|
48
|
+
owner = owner or resource.owner
|
|
49
|
+
used = await used_capacity(app, tx, owner, excluding=resource.id)
|
|
50
|
+
capacity = app.settings.hosting_base_capacity_bytes + extra_capacity(tx, owner, now)
|
|
51
|
+
require(used + proposed_size <= min(capacity, MAX_CAPACITY_BYTES),
|
|
52
|
+
'hosting_capacity_exceeded')
|