- commit
- 7456cee9607132d5b92f5bc67170c354ff808148
- parent
- 8867da128f1648c5fd19070f2b144ab6ebefc238
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 10 | ++-- |
| M | internal/ | 91 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 1 | + |
| M | internal/ | 73 | ++++++++++++++++++++++++- |
| M | internal/ | 2 | +- |
@@ -801,11 +801,8 @@ func (s *Service) uploadTerms(ctx context.Context, uploader string) (UploadTerms
801801 if s.config.UploadTerms == nil || uploader == "" {802802 return UploadTerms{}, nil803803 }804- terms, err := s.config.UploadTerms(ctx, uploader)805- if err != nil {806- return UploadTerms{}, fmt.Errorf("check upload terms: %w", err)807- }808- return terms, nil804+ // A refusal keeps its reason so the door answers with the right status.805+ return s.config.UploadTerms(ctx, uploader)809806 }810807 811808 // SweepExpired removes the blobs whose every claim has lapsed and releases@@ -1408,6 +1405,9 @@ func statusFor(err error) int {
14081405 if strings.HasPrefix(err.Error(), "invalid:") {14091406 return http.StatusBadRequest14101407 }1408+ if strings.HasPrefix(err.Error(), "restricted:") {1409+ return http.StatusForbidden1410+ }14111411 return http.StatusInternalServerError14121412 }14131413 @@ -18,8 +18,99 @@ import (
1818 1919 "github.com/FelineStateMachine/tinyrelay/internal/blob"2020 "github.com/FelineStateMachine/tinyrelay/internal/event"21+ "github.com/FelineStateMachine/tinyrelay/internal/sites"2122 )2223 24+// agentUpload sends one Blossom PUT /upload signed by secret through the25+// tenant and returns the response.26+func agentUpload(t *testing.T, tenant *Tenant, secret, body, typ string) *httptest.ResponseRecorder {27+ t.Helper()28+ sum := sha256.Sum256([]byte(body))29+ token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", "upload"}, {"x", hex.EncodeToString(sum[:])}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)}}, Content: "Authorize file storage"}30+ if err := event.Sign(&token, secret); err != nil {31+ t.Fatal(err)32+ }33+ raw, err := json.Marshal(token)34+ if err != nil {35+ t.Fatal(err)36+ }37+ r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload", strings.NewReader(body))38+ r.Header.Set("Authorization", "Nostr "+base64.RawURLEncoding.EncodeToString(raw))39+ r.Header.Set("Content-Type", typ)40+ w := httptest.NewRecorder()41+ tenant.ServeHTTP(w, r)42+ return w43+}44+45+// TestAgentGrantGovernsUploads covers the blob door for agent keys: an46+// active grant uploads as a member, a paused or revoked one is refused, a47+// sites ttl stamps the claim and the maintenance sweep retires it, and an48+// encrypted grant refuses plain files.49+func TestAgentGrantGovernsUploads(t *testing.T) {50+ _, tenant := testTenant(t)51+ ctx := context.Background()52+ owner := tenant.Policy().Owner53+ agent, _ := event.PublicKey(testAgentSecret)54+ if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{json.RawMessage(`{"writes":"allowlist"}`)}); err != nil {55+ t.Fatal(err)56+ }57+ if w := agentUpload(t, tenant, testAgentSecret, "stranger", "text/plain"); w.Code == http.StatusOK {58+ t.Fatal("a key without a grant uploaded under allowlist writes")59+ }60+ now := time.Now().Unix()61+ own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: agent})62+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"sites", own, "ttl=2"})); err != nil {63+ t.Fatal(err)64+ }65+ if w := agentUpload(t, tenant, testAgentSecret, "site file", "text/html"); w.Code != http.StatusOK {66+ t.Fatalf("granted agent upload = %d %s", w.Code, w.Body.String())67+ }68+ sum := sha256.Sum256([]byte("site file"))69+ hash := hex.EncodeToString(sum[:])70+ var expires int6471+ if err := tenant.store.DB().QueryRow("SELECT expires FROM blob_claims WHERE sha256=? AND uploader=?", hash, agent).Scan(&expires); err != nil || expires < now+2*86400 || expires > now+2*86400+60 {72+ t.Fatalf("claim expires=%d err=%v, want about %d", expires, err, now+2*86400)73+ }74+ if entries, err := tenant.blobs.ListMetadata(ctx, agent); err != nil || len(entries) != 1 {75+ t.Fatalf("agent list = %d entries, err=%v", len(entries), err)76+ }77+ if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {78+ t.Fatal(err)79+ }80+ if w := agentUpload(t, tenant, testAgentSecret, "while paused", "text/html"); w.Code == http.StatusOK || !strings.Contains(w.Body.String(), "paused") {81+ t.Fatalf("paused agent upload = %d %s", w.Code, w.Body.String())82+ }83+ if _, err := tenant.Execute(ctx, owner, "resumeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {84+ t.Fatal(err)85+ }86+ if w := agentUpload(t, tenant, testAgentSecret, "after resume", "text/html"); w.Code != http.StatusOK {87+ t.Fatalf("resumed agent upload = %d %s", w.Code, w.Body.String())88+ }89+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {90+ t.Fatal(err)91+ }92+ if w := agentUpload(t, tenant, testAgentSecret, "after revoke", "text/html"); w.Code == http.StatusOK {93+ t.Fatalf("revoked agent upload = %d %s", w.Code, w.Body.String())94+ }95+ // The sweep removes the agent's files once the ttl lapses.96+ if err := tenant.sweepExpiredBlobs(ctx, now+2*86400+61); err != nil {97+ t.Fatal(err)98+ }99+ if _, _, err := tenant.blobs.Get(ctx, hash); err == nil {100+ t.Fatal("expired agent upload still stored")101+ }102+ // A fresh grant with the encrypted flag refuses plain uploads.103+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+1, now+3600, []string{"sites", "*", "ttl=1", "encrypted"})); err != nil {104+ t.Fatal(err)105+ }106+ if w := agentUpload(t, tenant, testAgentSecret, "<html>plain</html>", "text/html"); w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "requires encrypted uploads") {107+ t.Fatalf("plain upload under encrypted grant = %d %s", w.Code, w.Body.String())108+ }109+ if w := agentUpload(t, tenant, testAgentSecret, "\x8f\x1a\x9c\x03"+strings.Repeat("\xe2\x71\x05\x9b\x4c", 20), "application/octet-stream"); w.Code != http.StatusOK {110+ t.Fatalf("ciphertext upload under encrypted grant = %d %s", w.Code, w.Body.String())111+ }112+}113+23114 type blobOriginTransport func(*http.Request) (*http.Response, error)24115 25116 func (f blobOriginTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }@@ -48,6 +48,7 @@ func (t *Tenant) runScheduler() {
4848 if t.blobs != nil {4949 report("partial uploads", t.blobs.CleanupMultipart(ctx))5050 }51+ report("expired uploads", t.sweepExpiredBlobs(ctx, now.Unix()))5152 report("retention", t.sweep(ctx, now.Unix()))5253 report("restore-state", t.sweepReplicationState(ctx, now.Unix()))5354 nextMaintenance = now.Add(time.Minute)@@ -47,7 +47,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
4747 return err4848 }4949 t.blobs, err = blob.New(ctx, blob.Config{50- Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob,50+ Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob, UploadTerms: t.blobUploadTerms,5151 Limits: func() blob.Limits {5252 limits := t.Policy().FileLimits5353 return blob.Limits{MaxFileBytes: limits.MaxFileBytes, UserStorageBytes: limits.UserStorageBytes}@@ -310,10 +310,81 @@ func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, err
310310 if !allowed {311311 return "", errors.New("restricted: file upload is not allowed")312312 }313+ // An agent's standing is its grant: an active one makes it a member314+ // for uploads, a paused, revoked or expired one stops them.315+ if role == "agent" {316+ if _, err := t.agentUploadGrant(r.Context(), pubkey); err != nil {317+ return "", err318+ }319+ }313320 }314321 return pubkey, nil315322 }316323 324+// agentUploadGrant returns the grant behind an agent key's uploads when it325+// currently allows publishing.326+func (t *Tenant) agentUploadGrant(ctx context.Context, pubkey string) (community.AgentGrant, error) {327+ grant, ok, err := t.community.AgentGrant(ctx, pubkey)328+ if err != nil {329+ return community.AgentGrant{}, fmt.Errorf("check agent upload grant: %w", err)330+ }331+ if !ok {332+ return community.AgentGrant{}, errors.New("restricted: agent grant is missing")333+ }334+ switch {335+ case grant.RevokedAt > 0:336+ return community.AgentGrant{}, errors.New("restricted: agent grant is revoked")337+ case grant.Paused:338+ return community.AgentGrant{}, errors.New("restricted: agent grant is paused")339+ case grant.ExpiresAt <= time.Now().Unix():340+ return community.AgentGrant{}, errors.New("restricted: agent grant has expired")341+ }342+ return grant, nil343+}344+345+// blobUploadTerms is what the blob service asks before it stores an upload:346+// a key whose only standing is an agent grant takes the grant's sites ttl as347+// the expiry of its claims and its encrypted flag as a requirement. Keys348+// with a human role, and agents without a sites tag, upload on no terms.349+func (t *Tenant) blobUploadTerms(ctx context.Context, pubkey string) (blob.UploadTerms, error) {350+ role, err := t.community.Role(ctx, pubkey)351+ if err != nil {352+ return blob.UploadTerms{}, fmt.Errorf("check uploader role: %w", err)353+ }354+ if role != "agent" {355+ return blob.UploadTerms{}, nil356+ }357+ grant, err := t.agentUploadGrant(ctx, pubkey)358+ if err != nil {359+ return blob.UploadTerms{}, err360+ }361+ terms := blob.UploadTerms{Encrypted: grant.UploadsEncrypted()}362+ if days := grant.UploadTTLDays(); days > 0 {363+ terms.ExpiresAt = time.Now().Unix() + int64(days)*86400364+ }365+ return terms, nil366+}367+368+// sweepExpiredBlobs retires uploads whose agent ttl has lapsed. The step is369+// recorded as the blob-expiry storage operation with a bounded outcome and370+// a count, never a hash or a key.371+func (t *Tenant) sweepExpiredBlobs(ctx context.Context, now int64) error {372+ if t.blobs == nil {373+ return nil374+ }375+ started := time.Now()376+ removed, err := t.blobs.SweepExpired(ctx, now)377+ outcome := "ok"378+ if err != nil {379+ outcome = "error"380+ }381+ t.app.telemetry.ObserveStorage("blob-expiry", outcome, time.Since(started))382+ if removed > 0 {383+ t.app.telemetry.Logger().Info("expired uploads removed", "tenant", t.meta.Name, "blobs", removed)384+ }385+ return err386+}387+317388 func (t *Tenant) validateBlobUpload(r *http.Request, hash string) error {318389 // PATCH carries only one chunk. Blossom scopes the final path hash, while319390 // NIP-98 binds the bytes in this individual request body.@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}239+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}, "blob-expiry": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}, "paused": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}}