1package gates23// Long tasks use the six job kinds Buzz reserves. A member or agent4// publishes a request (kind 43001) in a room, naming the keys it asks in p5// tags. An asked key answers with accepted (43002), progress (43003) and6// finally a result (43004) or an error (43006); the requester may cancel7// (43005). The relay keeps every event signed by its author; it checks8// that requests, answers and cancels carry the tags the protocol needs,9// that they come from members or agents, and that every answer or cancel10// names a request the relay holds, from a key the request allows.1112import (13 "context"14 "errors"15 "fmt"1617 "github.com/FelineStateMachine/tinyrelay/internal/community"18 "github.com/FelineStateMachine/tinyrelay/internal/event"19 "github.com/FelineStateMachine/tinyrelay/internal/policy"20 "github.com/FelineStateMachine/tinyrelay/internal/relay"21 "github.com/FelineStateMachine/tinyrelay/internal/storage"22)2324// JobShape checks the tags of a job request, answer or cancel event. It25// returns nil for other event kinds.26func JobShape(e event.Event) error {27 switch {28 case event.IsJobRequest(e.Kind):29 return jobRequestShape(e)30 case event.IsJobAnswer(e.Kind):31 return jobAnswerShape(e, JobKindName(e.Kind))32 case event.IsJobCancel(e.Kind):33 return jobCancelShape(e)34 }35 return nil36}3738// JobKindName names a job kind the way messages and cards refer to it.39func JobKindName(kind int) string {40 switch kind {41 case event.KIND_JOB_REQUEST:42 return "job request"43 case event.KIND_JOB_ACCEPTED:44 return "job accepted"45 case event.KIND_JOB_PROGRESS:46 return "job progress"47 case event.KIND_JOB_RESULT:48 return "job result"49 case event.KIND_JOB_CANCEL:50 return "job cancel"51 case event.KIND_JOB_ERROR:52 return "job error"53 }54 return "job event"55}5657// JobRequestID returns the request an answer or cancel names: its first e58// tag. Later e tags on a result reference artifacts.59func JobRequestID(e event.Event) string {60 return event.Tag(e, "e")61}6263func jobRequestShape(e event.Event) error {64 if !community.ValidRoomID(event.Tag(e, "h")) {65 return errors.New("invalid: job request must name its room in an h tag")66 }67 asked := 068 for _, tag := range e.Tags {69 if len(tag) < 2 {70 continue71 }72 switch tag[0] {73 case "p":74 if !jobID(tag[1]) {75 return errors.New("invalid: job request p tags must be hex public keys")76 }77 asked++78 case "e":79 if !jobID(tag[1]) {80 return errors.New("invalid: job request e tag must name the thread root event id")81 }82 }83 }84 if asked == 0 {85 return errors.New("invalid: job request must name at least one asked key in a p tag")86 }87 return nil88}8990func jobAnswerShape(e event.Event, what string) error {91 if !jobID(JobRequestID(e)) {92 return fmt.Errorf("invalid: %s must name the job request in an e tag", what)93 }94 if !jobID(event.Tag(e, "p")) {95 return fmt.Errorf("invalid: %s must name the requester in a p tag", what)96 }97 if !community.ValidRoomID(event.Tag(e, "h")) {98 return fmt.Errorf("invalid: %s must keep the request's room in an h tag", what)99 }100 return nil101}102103func jobCancelShape(e event.Event) error {104 if !jobID(JobRequestID(e)) {105 return errors.New("invalid: job cancel must name the job request in an e tag")106 }107 if !community.ValidRoomID(event.Tag(e, "h")) {108 return errors.New("invalid: job cancel must keep the request's room in an h tag")109 }110 if p := event.Tag(e, "p"); p != "" && !jobID(p) {111 return errors.New("invalid: job cancel p tag must be a hex public key")112 }113 return nil114}115116// jobFeature refuses job kinds while the owner has long tasks switched off.117func jobFeature(p policy.Policy, e event.Event) error {118 if event.IsJobKind(e.Kind) && !p.Features.Jobs {119 return errors.New("restricted: long tasks are switched off on this relay")120 }121 return nil122}123124// jobWriter admits job kinds from members and agents only. An agent is a125// member with the agent role, so one membership check covers both.126func jobWriter(e event.Event, a policy.Access) error {127 if event.IsJobKind(e.Kind) && !a.Member && !a.Owner {128 return errors.New("restricted: long tasks are accepted from members and agents")129 }130 return nil131}132133// jobReply checks an answer or cancel against the request it names. The134// relay must hold the request and the publisher must be able to read it.135// An answer must come from a key the request asked, name the requester in136// p and keep the request's room; a cancel must come from the requester.137func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64) error {138 if !event.IsJobAnswer(e.Kind) && !event.IsJobCancel(e.Kind) {139 return nil140 }141 what := JobKindName(e.Kind)142 missing := fmt.Errorf("restricted: %s must name a request this relay holds", what)143 if g.cfg.Store == nil {144 return missing145 }146 result, err := g.cfg.Store.Query(ctx, event.Filter{IDs: []string{JobRequestID(e)}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 1})147 if err != nil {148 return fmt.Errorf("check job request: %w", err)149 }150 if len(result.Events) == 0 || !event.IsJobRequest(result.Events[0].Kind) {151 return missing152 }153 request := result.Events[0]154 if !g.CanSee(ctx, request, relay.Session{PubKeys: []string{e.PubKey}}, nil) {155 return missing156 }157 if event.Tag(e, "h") != event.Tag(request, "h") {158 return fmt.Errorf("invalid: %s must keep the request's room h tag", what)159 }160 if event.IsJobCancel(e.Kind) {161 if e.PubKey != request.PubKey {162 return errors.New("restricted: only the requester may cancel a long task")163 }164 return nil165 }166 if event.Tag(e, "p") != request.PubKey {167 return fmt.Errorf("invalid: %s p tag must name the requester", what)168 }169 if !containsValue(event.TagValues(request, "p"), e.PubKey) {170 return fmt.Errorf("restricted: %s must come from a key the request asked", what)171 }172 return nil173}174175func containsValue(values []string, want string) bool {176 for _, value := range values {177 if value == want {178 return true179 }180 }181 return false182}183184func jobID(value string) bool {185 return len(value) == 64 && isLowerHex(value)186}187188func isLowerHex(value string) bool {189 for _, ch := range value {190 if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {191 return false192 }193 }194 return true195}