Monorepo for Tangled

appview: remove validator

- RBAC should be enforced on service logic.
- We should not check for referenced records existence from db due to
the nature of atproto.
- Comment depth validation is not necessary. We can accept them and just
don't render replies with deeper depth.

Move markdown sanitizer to dedicated package to avoid import cycle

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me 4d6a6a61 e11751cd

verified
+399 -517
+17 -7
appview/ingester.go
··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/serververify" 22 - "tangled.org/core/appview/validator" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/orm" 25 "tangled.org/core/rbac" ··· 31 IdResolver *idresolver.Resolver 32 Config *config.Config 33 Logger *slog.Logger 34 - Validator *validator.Validator 35 } 36 37 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 613 614 string := models.StringFromRecord(did, rkey, record) 615 616 - if err = i.Validator.ValidateString(&string); err != nil { 617 l.Error("invalid record", "err", err) 618 return err 619 } ··· 822 823 issue := models.IssueFromRecord(did, rkey, record) 824 825 - if err := i.Validator.ValidateIssue(&issue); err != nil { 826 return fmt.Errorf("failed to validate issue: %w", err) 827 } 828 ··· 902 return fmt.Errorf("failed to parse comment from record: %w", err) 903 } 904 905 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 906 return fmt.Errorf("failed to validate comment: %w", err) 907 } 908 ··· 962 return fmt.Errorf("failed to parse labeldef from record: %w", err) 963 } 964 965 - if err := i.Validator.ValidateLabelDefinition(def); err != nil { 966 return fmt.Errorf("failed to validate labeldef: %w", err) 967 } 968 ··· 1038 if !ok { 1039 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1040 } 1041 - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1042 return fmt.Errorf("failed to validate labelop: %w", err) 1043 } 1044 }
··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/serververify" 22 "tangled.org/core/idresolver" 23 "tangled.org/core/orm" 24 "tangled.org/core/rbac" ··· 30 IdResolver *idresolver.Resolver 31 Config *config.Config 32 Logger *slog.Logger 33 } 34 35 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 611 612 string := models.StringFromRecord(did, rkey, record) 613 614 + if err = string.Validate(); err != nil { 615 l.Error("invalid record", "err", err) 616 return err 617 } ··· 820 821 issue := models.IssueFromRecord(did, rkey, record) 822 823 + if err := issue.Validate(); err != nil { 824 return fmt.Errorf("failed to validate issue: %w", err) 825 } 826 ··· 900 return fmt.Errorf("failed to parse comment from record: %w", err) 901 } 902 903 + if err := comment.Validate(); err != nil { 904 return fmt.Errorf("failed to validate comment: %w", err) 905 } 906 ··· 960 return fmt.Errorf("failed to parse labeldef from record: %w", err) 961 } 962 963 + if err := def.Validate(); err != nil { 964 return fmt.Errorf("failed to validate labeldef: %w", err) 965 } 966 ··· 1036 if !ok { 1037 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1038 } 1039 + 1040 + // validate permissions: only collaborators can apply labels currently 1041 + // 1042 + // TODO: introduce a repo:triage permission 1043 + ok, err := i.Enforcer.IsPushAllowed(o.Did, repo.Knot, repo.DidSlashRepo()) 1044 + if err != nil { 1045 + return fmt.Errorf("enforcing permission: %w", err) 1046 + } 1047 + if !ok { 1048 + return fmt.Errorf("unauthorized label operation") 1049 + } 1050 + 1051 + if err := def.ValidateOperandValue(&o); err != nil { 1052 return fmt.Errorf("failed to validate labelop: %w", err) 1053 } 1054 }
+3 -7
appview/issues/issues.go
··· 27 "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 - "tangled.org/core/appview/validator" 31 "tangled.org/core/idresolver" 32 "tangled.org/core/orm" 33 "tangled.org/core/rbac" ··· 45 config *config.Config 46 notifier notify.Notifier 47 logger *slog.Logger 48 - validator *validator.Validator 49 indexer *issues_indexer.Indexer 50 } 51 ··· 59 db *db.DB, 60 config *config.Config, 61 notifier notify.Notifier, 62 - validator *validator.Validator, 63 indexer *issues_indexer.Indexer, 64 logger *slog.Logger, 65 ) *Issues { ··· 74 config: config, 75 notifier: notifier, 76 logger: logger, 77 - validator: validator, 78 indexer: indexer, 79 } 80 } ··· 165 newIssue.Body = r.FormValue("body") 166 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 167 168 - if err := rp.validator.ValidateIssue(newIssue); err != nil { 169 l.Error("validation error", "err", err) 170 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 171 return ··· 424 Mentions: mentions, 425 References: references, 426 } 427 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 428 l.Error("failed to validate comment", "err", err) 429 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 430 return ··· 944 Repo: f, 945 } 946 947 - if err := rp.validator.ValidateIssue(issue); err != nil { 948 l.Error("validation error", "err", err) 949 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 950 return
··· 27 "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/idresolver" 31 "tangled.org/core/orm" 32 "tangled.org/core/rbac" ··· 44 config *config.Config 45 notifier notify.Notifier 46 logger *slog.Logger 47 indexer *issues_indexer.Indexer 48 } 49 ··· 57 db *db.DB, 58 config *config.Config, 59 notifier notify.Notifier, 60 indexer *issues_indexer.Indexer, 61 logger *slog.Logger, 62 ) *Issues { ··· 71 config: config, 72 notifier: notifier, 73 logger: logger, 74 indexer: indexer, 75 } 76 } ··· 161 newIssue.Body = r.FormValue("body") 162 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 163 164 + if err := newIssue.Validate(); err != nil { 165 l.Error("validation error", "err", err) 166 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 167 return ··· 420 Mentions: mentions, 421 References: references, 422 } 423 + if err = comment.Validate(); err != nil { 424 l.Error("failed to validate comment", "err", err) 425 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 426 return ··· 940 Repo: f, 941 } 942 943 + if err := issue.Validate(); err != nil { 944 l.Error("validation error", "err", err) 945 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 946 return
+27 -15
appview/labels/labels.go
··· 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 - "tangled.org/core/appview/validator" 19 "tangled.org/core/orm" 20 "tangled.org/core/rbac" 21 "tangled.org/core/tid" ··· 28 ) 29 30 type Labels struct { 31 - oauth *oauth.OAuth 32 - pages *pages.Pages 33 - db *db.DB 34 - logger *slog.Logger 35 - validator *validator.Validator 36 - enforcer *rbac.Enforcer 37 } 38 39 func New( 40 oauth *oauth.OAuth, 41 pages *pages.Pages, 42 db *db.DB, 43 - validator *validator.Validator, 44 enforcer *rbac.Enforcer, 45 logger *slog.Logger, 46 ) *Labels { 47 return &Labels{ 48 - oauth: oauth, 49 - pages: pages, 50 - db: db, 51 - logger: logger, 52 - validator: validator, 53 - enforcer: enforcer, 54 } 55 } 56 ··· 163 164 for i := range labelOps { 165 def := actx.Defs[labelOps[i].OperandKey] 166 - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 167 fail(fmt.Sprintf("Invalid form data: %s", err), err) 168 return 169 } 170 } 171 172 // reduce the opset
··· 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/orm" 19 "tangled.org/core/rbac" 20 "tangled.org/core/tid" ··· 27 ) 28 29 type Labels struct { 30 + oauth *oauth.OAuth 31 + pages *pages.Pages 32 + db *db.DB 33 + logger *slog.Logger 34 + enforcer *rbac.Enforcer 35 } 36 37 func New( 38 oauth *oauth.OAuth, 39 pages *pages.Pages, 40 db *db.DB, 41 enforcer *rbac.Enforcer, 42 logger *slog.Logger, 43 ) *Labels { 44 return &Labels{ 45 + oauth: oauth, 46 + pages: pages, 47 + db: db, 48 + logger: logger, 49 + enforcer: enforcer, 50 } 51 } 52 ··· 159 160 for i := range labelOps { 161 def := actx.Defs[labelOps[i].OperandKey] 162 + op := labelOps[i] 163 + 164 + // validate permissions: only collaborators can apply labels currently 165 + // 166 + // TODO: introduce a repo:triage permission 167 + ok, err := l.enforcer.IsPushAllowed(op.Did, repo.Knot, repo.DidSlashRepo()) 168 + if err != nil { 169 + fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) 170 + return 171 + } 172 + if !ok { 173 + fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) 174 + return 175 + } 176 + 177 + if err := def.ValidateOperandValue(&op); err != nil { 178 fail(fmt.Sprintf("Invalid form data: %s", err), err) 179 return 180 } 181 + labelOps[i] = op 182 } 183 184 // reduce the opset
+32
appview/models/issue.go
··· 3 import ( 4 "fmt" 5 "sort" 6 "time" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/api/tangled" 10 ) 11 12 type Issue struct { ··· 59 return "open" 60 } 61 return "closed" 62 } 63 64 type CommentListItem struct { ··· 215 216 func (i *IssueComment) IsReply() bool { 217 return i.ReplyTo != nil 218 } 219 220 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
··· 3 import ( 4 "fmt" 5 "sort" 6 + "strings" 7 "time" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages/markup/sanitizer" 12 ) 13 14 type Issue struct { ··· 61 return "open" 62 } 63 return "closed" 64 + } 65 + 66 + var _ Validator = new(Issue) 67 + 68 + func (i *Issue) Validate() error { 69 + if i.Title == "" { 70 + return fmt.Errorf("issue title is empty") 71 + } 72 + if i.Body == "" { 73 + return fmt.Errorf("issue body is empty") 74 + } 75 + 76 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(i.Title)); st == "" { 77 + return fmt.Errorf("title is empty after HTML sanitization") 78 + } 79 + 80 + if st := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); st == "" { 81 + return fmt.Errorf("body is empty after HTML sanitization") 82 + } 83 + return nil 84 } 85 86 type CommentListItem struct { ··· 237 238 func (i *IssueComment) IsReply() bool { 239 return i.ReplyTo != nil 240 + } 241 + 242 + var _ Validator = new(IssueComment) 243 + 244 + func (i *IssueComment) Validate() error { 245 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); sb == "" { 246 + return fmt.Errorf("body is empty after HTML sanitization") 247 + } 248 + 249 + return nil 250 } 251 252 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+183 -4
appview/models/label.go
··· 7 "encoding/json" 8 "errors" 9 "fmt" 10 "slices" 11 "time" 12 13 "github.com/bluesky-social/indigo/api/atproto" ··· 120 } 121 } 122 123 // random color for a given seed 124 func randomColor(seed string) string { 125 hash := sha1.Sum([]byte(seed)) ··· 131 return fmt.Sprintf("#%s%s%s", r, g, b) 132 } 133 134 - func (ld LabelDefinition) GetColor() string { 135 - if ld.Color == nil { 136 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 137 color := randomColor(seed) 138 return color 139 } 140 141 - return *ld.Color 142 } 143 144 func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { ··· 203 204 // otherwise, createdat is in the future relative to indexedat -> use indexedat 205 return indexedAt 206 } 207 208 type LabelOperation string
··· 7 "encoding/json" 8 "errors" 9 "fmt" 10 + "regexp" 11 "slices" 12 + "strings" 13 "time" 14 15 "github.com/bluesky-social/indigo/api/atproto" ··· 122 } 123 } 124 125 + var ( 126 + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 127 + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 128 + // Color should be a valid hex color 129 + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 130 + // You can only label issues and pulls presently 131 + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 132 + ) 133 + 134 + var _ Validator = new(LabelDefinition) 135 + 136 + func (l *LabelDefinition) Validate() error { 137 + if l.Name == "" { 138 + return fmt.Errorf("label name is empty") 139 + } 140 + if len(l.Name) > 40 { 141 + return fmt.Errorf("label name too long (max 40 graphemes)") 142 + } 143 + if len(l.Name) < 1 { 144 + return fmt.Errorf("label name too short (min 1 grapheme)") 145 + } 146 + if !labelNameRegex.MatchString(l.Name) { 147 + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 148 + } 149 + 150 + if !l.ValueType.IsConcreteType() { 151 + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type) 152 + } 153 + 154 + // null type checks: cannot be enums, multiple or explicit format 155 + if l.ValueType.IsNull() && l.ValueType.IsEnum() { 156 + return fmt.Errorf("null type cannot be used in conjunction with enum type") 157 + } 158 + if l.ValueType.IsNull() && l.Multiple { 159 + return fmt.Errorf("null type labels cannot be multiple") 160 + } 161 + if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() { 162 + return fmt.Errorf("format cannot be used in conjunction with null type") 163 + } 164 + 165 + // format checks: cannot be used with enum, or integers 166 + if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() { 167 + return fmt.Errorf("enum types cannot be used in conjunction with format specification") 168 + } 169 + 170 + if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() { 171 + return fmt.Errorf("format specifications are only permitted on string types") 172 + } 173 + 174 + // validate scope (nsid format) 175 + if l.Scope == nil { 176 + return fmt.Errorf("scope is required") 177 + } 178 + for _, s := range l.Scope { 179 + if _, err := syntax.ParseNSID(s); err != nil { 180 + return fmt.Errorf("failed to parse scope: %w", err) 181 + } 182 + if !slices.Contains(validScopes, s) { 183 + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 184 + } 185 + } 186 + 187 + // validate color if provided 188 + if l.Color != nil { 189 + color := strings.TrimSpace(*l.Color) 190 + if color == "" { 191 + // empty color is fine, set to nil 192 + l.Color = nil 193 + } else { 194 + if !colorRegex.MatchString(color) { 195 + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 196 + } 197 + // expand 3-digit hex to 6-digit hex 198 + if len(color) == 4 { // #ABC 199 + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 200 + } 201 + // convert to uppercase for consistency 202 + color = strings.ToUpper(color) 203 + l.Color = &color 204 + } 205 + } 206 + 207 + return nil 208 + } 209 + 210 + // ValidateOperandValue validates the label operation operand value based on 211 + // label definition. 212 + // 213 + // NOTE: This can modify the [LabelOp] 214 + func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error { 215 + expectedKey := def.AtUri().String() 216 + if op.OperandKey != def.AtUri().String() { 217 + return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey) 218 + } 219 + 220 + valueType := def.ValueType 221 + 222 + // this is permitted, it "unsets" a label 223 + if op.OperandValue == "" { 224 + op.Operation = LabelOperationDel 225 + return nil 226 + } 227 + 228 + switch valueType.Type { 229 + case ConcreteTypeNull: 230 + // For null type, value should be empty 231 + if op.OperandValue != "null" { 232 + return fmt.Errorf("null type requires empty value, got %q", op.OperandValue) 233 + } 234 + 235 + case ConcreteTypeString: 236 + // For string type, validate enum constraints if present 237 + if valueType.IsEnum() { 238 + if !slices.Contains(valueType.Enum, op.OperandValue) { 239 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 240 + } 241 + } 242 + 243 + switch valueType.Format { 244 + case ValueTypeFormatDid: 245 + if _, err := syntax.ParseDID(op.OperandValue); err != nil { 246 + return fmt.Errorf("failed to resolve did/handle: %w", err) 247 + } 248 + case ValueTypeFormatAny, "": 249 + default: 250 + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 251 + } 252 + 253 + case ConcreteTypeInt: 254 + if op.OperandValue == "" { 255 + return fmt.Errorf("integer type requires non-empty value") 256 + } 257 + if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil { 258 + return fmt.Errorf("value %q is not a valid integer", op.OperandValue) 259 + } 260 + 261 + if valueType.IsEnum() { 262 + if !slices.Contains(valueType.Enum, op.OperandValue) { 263 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 264 + } 265 + } 266 + 267 + case ConcreteTypeBool: 268 + if op.OperandValue != "true" && op.OperandValue != "false" { 269 + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue) 270 + } 271 + 272 + // validate enum constraints if present (though uncommon for booleans) 273 + if valueType.IsEnum() { 274 + if !slices.Contains(valueType.Enum, op.OperandValue) { 275 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 276 + } 277 + } 278 + 279 + default: 280 + return fmt.Errorf("unsupported value type: %q", valueType.Type) 281 + } 282 + 283 + return nil 284 + } 285 + 286 // random color for a given seed 287 func randomColor(seed string) string { 288 hash := sha1.Sum([]byte(seed)) ··· 294 return fmt.Sprintf("#%s%s%s", r, g, b) 295 } 296 297 + func (l LabelDefinition) GetColor() string { 298 + if l.Color == nil { 299 + seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) 300 color := randomColor(seed) 301 return color 302 } 303 304 + return *l.Color 305 } 306 307 func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { ··· 366 367 // otherwise, createdat is in the future relative to indexedat -> use indexedat 368 return indexedAt 369 + } 370 + 371 + var _ Validator = new(LabelOp) 372 + 373 + func (l *LabelOp) Validate() error { 374 + if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { 375 + return fmt.Errorf("invalid subject URI: %w", err) 376 + } 377 + if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { 378 + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) 379 + } 380 + // Validate performed time is not zero/invalid 381 + if l.PerformedAt.IsZero() { 382 + return fmt.Errorf("performed_at timestamp is required") 383 + } 384 + return nil 385 } 386 387 type LabelOperation string
+21
appview/models/string.go
··· 2 3 import ( 4 "bytes" 5 "fmt" 6 "io" 7 "strings" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "tangled.org/core/api/tangled" ··· 33 Contents: s.Contents, 34 CreatedAt: s.Created.Format(time.RFC3339), 35 } 36 } 37 38 func StringFromRecord(did, rkey string, record tangled.String) String {
··· 2 3 import ( 4 "bytes" 5 + "errors" 6 "fmt" 7 "io" 8 "strings" 9 "time" 10 + "unicode/utf8" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/api/tangled" ··· 35 Contents: s.Contents, 36 CreatedAt: s.Created.Format(time.RFC3339), 37 } 38 + } 39 + 40 + var _ Validator = new(String) 41 + 42 + func (s *String) Validate() error { 43 + var err error 44 + if utf8.RuneCountInString(s.Filename) > 140 { 45 + err = errors.Join(err, fmt.Errorf("filename too long")) 46 + } 47 + 48 + if utf8.RuneCountInString(s.Description) > 280 { 49 + err = errors.Join(err, fmt.Errorf("description too long")) 50 + } 51 + 52 + if len(s.Contents) == 0 { 53 + err = errors.Join(err, fmt.Errorf("contents is empty")) 54 + } 55 + 56 + return err 57 } 58 59 func StringFromRecord(did, rkey string, record tangled.String) String {
+6
appview/models/validator.go
···
··· 1 + package models 2 + 3 + type Validator interface { 4 + // Validate checks the object and returns any error. 5 + Validate() error 6 + }
+4 -3
appview/pages/funcmap.go
··· 30 "tangled.org/core/appview/models" 31 "tangled.org/core/appview/oauth" 32 "tangled.org/core/appview/pages/markup" 33 "tangled.org/core/crypto" 34 ) 35 ··· 260 "markdown": func(text string) template.HTML { 261 p.rctx.RendererType = markup.RendererTypeDefault 262 htmlString := p.rctx.RenderMarkdown(text) 263 - sanitized := p.rctx.SanitizeDefault(htmlString) 264 return template.HTML(sanitized) 265 }, 266 "description": func(text string) template.HTML { ··· 270 emoji.Emoji, 271 ), 272 )) 273 - sanitized := p.rctx.SanitizeDescription(htmlString) 274 return template.HTML(sanitized) 275 }, 276 "readme": func(text string) template.HTML { 277 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 278 htmlString := p.rctx.RenderMarkdown(text) 279 - sanitized := p.rctx.SanitizeDefault(htmlString) 280 return template.HTML(sanitized) 281 }, 282 "code": func(content, path string) string {
··· 30 "tangled.org/core/appview/models" 31 "tangled.org/core/appview/oauth" 32 "tangled.org/core/appview/pages/markup" 33 + "tangled.org/core/appview/pages/markup/sanitizer" 34 "tangled.org/core/crypto" 35 ) 36 ··· 261 "markdown": func(text string) template.HTML { 262 p.rctx.RendererType = markup.RendererTypeDefault 263 htmlString := p.rctx.RenderMarkdown(text) 264 + sanitized := sanitizer.SanitizeDefault(htmlString) 265 return template.HTML(sanitized) 266 }, 267 "description": func(text string) template.HTML { ··· 271 emoji.Emoji, 272 ), 273 )) 274 + sanitized := sanitizer.SanitizeDescription(htmlString) 275 return template.HTML(sanitized) 276 }, 277 "readme": func(text string) template.HTML { 278 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 279 htmlString := p.rctx.RenderMarkdown(text) 280 + sanitized := sanitizer.SanitizeDefault(htmlString) 281 return template.HTML(sanitized) 282 }, 283 "code": func(content, path string) string {
-9
appview/pages/markup/markdown.go
··· 48 IsDev bool 49 Hostname string 50 RendererType RendererType 51 - Sanitizer Sanitizer 52 Files fs.FS 53 } 54 ··· 177 } 178 default: 179 } 180 - } 181 - 182 - func (rctx *RenderContext) SanitizeDefault(html string) string { 183 - return rctx.Sanitizer.SanitizeDefault(html) 184 - } 185 - 186 - func (rctx *RenderContext) SanitizeDescription(html string) string { 187 - return rctx.Sanitizer.SanitizeDescription(html) 188 } 189 190 type MarkdownTransformer struct {
··· 48 IsDev bool 49 Hostname string 50 RendererType RendererType 51 Files fs.FS 52 } 53 ··· 176 } 177 default: 178 } 179 } 180 181 type MarkdownTransformer struct {
+11 -18
appview/pages/markup/sanitizer.go appview/pages/markup/sanitizer/sanitizer.go
··· 1 - package markup 2 3 import ( 4 "maps" ··· 10 "github.com/microcosm-cc/bluemonday" 11 ) 12 13 - type Sanitizer struct { 14 - defaultPolicy *bluemonday.Policy 15 - descriptionPolicy *bluemonday.Policy 16 - } 17 18 - func NewSanitizer() Sanitizer { 19 - return Sanitizer{ 20 - defaultPolicy: defaultPolicy(), 21 - descriptionPolicy: descriptionPolicy(), 22 - } 23 } 24 - 25 - func (s *Sanitizer) SanitizeDefault(html string) string { 26 - return s.defaultPolicy.Sanitize(html) 27 - } 28 - func (s *Sanitizer) SanitizeDescription(html string) string { 29 - return s.descriptionPolicy.Sanitize(html) 30 } 31 32 - func defaultPolicy() *bluemonday.Policy { 33 policy := bluemonday.UGCPolicy() 34 35 // Allow generally safe attributes ··· 123 return policy 124 } 125 126 - func descriptionPolicy() *bluemonday.Policy { 127 policy := bluemonday.NewPolicy() 128 policy.AllowStandardURLs() 129
··· 1 + package sanitizer 2 3 import ( 4 "maps" ··· 10 "github.com/microcosm-cc/bluemonday" 11 ) 12 13 + var ( 14 + defaultPolicy = newDefaultPolicy() 15 + descriptionPolicy = newDescriptionPolicy() 16 + ) 17 18 + func SanitizeDefault(html string) string { 19 + return defaultPolicy.Sanitize(html) 20 } 21 + func SanitizeDescription(html string) string { 22 + return descriptionPolicy.Sanitize(html) 23 } 24 25 + func newDefaultPolicy() *bluemonday.Policy { 26 policy := bluemonday.UGCPolicy() 27 28 // Allow generally safe attributes ··· 116 return policy 117 } 118 119 + func newDescriptionPolicy() *bluemonday.Policy { 120 policy := bluemonday.NewPolicy() 121 policy.AllowStandardURLs() 122
+5 -5
appview/pages/pages.go
··· 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/oauth" 25 "tangled.org/core/appview/pages/markup" 26 "tangled.org/core/appview/pages/repoinfo" 27 "tangled.org/core/appview/pagination" 28 "tangled.org/core/idresolver" ··· 58 Hostname: config.Core.AppviewHost, 59 CamoUrl: config.Camo.Host, 60 CamoSecret: config.Camo.SharedSecret, 61 - Sanitizer: markup.NewSanitizer(), 62 Files: Files, 63 } 64 ··· 292 293 p.rctx.RendererType = markup.RendererTypeDefault 294 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 295 - sanitized := p.rctx.SanitizeDefault(htmlString) 296 params.Content = template.HTML(sanitized) 297 298 return p.execute("legal/terms", w, params) ··· 320 321 p.rctx.RendererType = markup.RendererTypeDefault 322 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 323 - sanitized := p.rctx.SanitizeDefault(htmlString) 324 params.Content = template.HTML(sanitized) 325 326 return p.execute("legal/privacy", w, params) ··· 718 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 719 params.Raw = false 720 htmlString := p.rctx.RenderMarkdown(params.Readme) 721 - sanitized := p.rctx.SanitizeDefault(htmlString) 722 params.HTMLReadme = template.HTML(sanitized) 723 default: 724 params.Raw = true ··· 811 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 812 params.Raw = false 813 htmlString := p.rctx.RenderMarkdown(params.Readme) 814 - sanitized := p.rctx.SanitizeDefault(htmlString) 815 params.HTMLReadme = template.HTML(sanitized) 816 default: 817 params.Raw = true
··· 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/oauth" 25 "tangled.org/core/appview/pages/markup" 26 + "tangled.org/core/appview/pages/markup/sanitizer" 27 "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/idresolver" ··· 59 Hostname: config.Core.AppviewHost, 60 CamoUrl: config.Camo.Host, 61 CamoSecret: config.Camo.SharedSecret, 62 Files: Files, 63 } 64 ··· 292 293 p.rctx.RendererType = markup.RendererTypeDefault 294 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 295 + sanitized := sanitizer.SanitizeDefault(htmlString) 296 params.Content = template.HTML(sanitized) 297 298 return p.execute("legal/terms", w, params) ··· 320 321 p.rctx.RendererType = markup.RendererTypeDefault 322 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 323 + sanitized := sanitizer.SanitizeDefault(htmlString) 324 params.Content = template.HTML(sanitized) 325 326 return p.execute("legal/privacy", w, params) ··· 718 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 719 params.Raw = false 720 htmlString := p.rctx.RenderMarkdown(params.Readme) 721 + sanitized := sanitizer.SanitizeDefault(htmlString) 722 params.HTMLReadme = template.HTML(sanitized) 723 default: 724 params.Raw = true ··· 811 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 812 params.Raw = false 813 htmlString := p.rctx.RenderMarkdown(params.Readme) 814 + sanitized := sanitizer.SanitizeDefault(htmlString) 815 params.HTMLReadme = template.HTML(sanitized) 816 default: 817 params.Raw = true
+23 -11
appview/pulls/pulls.go
··· 27 "tangled.org/core/appview/notify" 28 "tangled.org/core/appview/oauth" 29 "tangled.org/core/appview/pages" 30 - "tangled.org/core/appview/pages/markup" 31 "tangled.org/core/appview/pages/repoinfo" 32 "tangled.org/core/appview/pagination" 33 "tangled.org/core/appview/reporesolver" 34 - "tangled.org/core/appview/validator" 35 "tangled.org/core/appview/xrpcclient" 36 "tangled.org/core/idresolver" 37 "tangled.org/core/orm" ··· 62 notifier notify.Notifier 63 enforcer *rbac.Enforcer 64 logger *slog.Logger 65 - validator *validator.Validator 66 indexer *pulls_indexer.Indexer 67 } 68 ··· 76 config *config.Config, 77 notifier notify.Notifier, 78 enforcer *rbac.Enforcer, 79 - validator *validator.Validator, 80 indexer *pulls_indexer.Indexer, 81 logger *slog.Logger, 82 ) *Pulls { ··· 91 notifier: notifier, 92 enforcer: enforcer, 93 logger: logger, 94 - validator: validator, 95 indexer: indexer, 96 } 97 } ··· 903 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 904 return 905 } 906 - sanitizer := markup.NewSanitizer() 907 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 908 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 909 return ··· 1031 patch := comparison.FormatPatchRaw 1032 combined := comparison.CombinedPatchRaw 1033 1034 - if err := s.validator.ValidatePatch(&patch); err != nil { 1035 s.logger.Error("failed to validate patch", "err", err) 1036 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1037 return ··· 1049 } 1050 1051 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1052 - if err := s.validator.ValidatePatch(&patch); err != nil { 1053 s.logger.Error("patch validation failed", "err", err) 1054 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1055 return ··· 1141 patch := comparison.FormatPatchRaw 1142 combined := comparison.CombinedPatchRaw 1143 1144 - if err := s.validator.ValidatePatch(&patch); err != nil { 1145 s.logger.Error("failed to validate patch", "err", err) 1146 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1147 return ··· 1434 return 1435 } 1436 1437 - if err := s.validator.ValidatePatch(&patch); err != nil { 1438 s.logger.Error("faield to validate patch", "err", err) 1439 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1440 return ··· 1855 return 1856 } 1857 1858 - if err := s.validator.ValidatePatch(&patch); err != nil { 1859 s.pages.Notice(w, "resubmit-error", err.Error()) 1860 return 1861 } ··· 2482 w.Close() 2483 return &b 2484 }
··· 27 "tangled.org/core/appview/notify" 28 "tangled.org/core/appview/oauth" 29 "tangled.org/core/appview/pages" 30 + "tangled.org/core/appview/pages/markup/sanitizer" 31 "tangled.org/core/appview/pages/repoinfo" 32 "tangled.org/core/appview/pagination" 33 "tangled.org/core/appview/reporesolver" 34 "tangled.org/core/appview/xrpcclient" 35 "tangled.org/core/idresolver" 36 "tangled.org/core/orm" ··· 61 notifier notify.Notifier 62 enforcer *rbac.Enforcer 63 logger *slog.Logger 64 indexer *pulls_indexer.Indexer 65 } 66 ··· 74 config *config.Config, 75 notifier notify.Notifier, 76 enforcer *rbac.Enforcer, 77 indexer *pulls_indexer.Indexer, 78 logger *slog.Logger, 79 ) *Pulls { ··· 88 notifier: notifier, 89 enforcer: enforcer, 90 logger: logger, 91 indexer: indexer, 92 } 93 } ··· 899 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 900 return 901 } 902 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 903 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 904 return ··· 1026 patch := comparison.FormatPatchRaw 1027 combined := comparison.CombinedPatchRaw 1028 1029 + if err := validatePatch(&patch); err != nil { 1030 s.logger.Error("failed to validate patch", "err", err) 1031 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1032 return ··· 1044 } 1045 1046 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1047 + if err := validatePatch(&patch); err != nil { 1048 s.logger.Error("patch validation failed", "err", err) 1049 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1050 return ··· 1136 patch := comparison.FormatPatchRaw 1137 combined := comparison.CombinedPatchRaw 1138 1139 + if err := validatePatch(&patch); err != nil { 1140 s.logger.Error("failed to validate patch", "err", err) 1141 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1142 return ··· 1429 return 1430 } 1431 1432 + if err := validatePatch(&patch); err != nil { 1433 s.logger.Error("faield to validate patch", "err", err) 1434 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1435 return ··· 1850 return 1851 } 1852 1853 + if err := validatePatch(&patch); err != nil { 1854 s.pages.Notice(w, "resubmit-error", err.Error()) 1855 return 1856 } ··· 2477 w.Close() 2478 return &b 2479 } 2480 + 2481 + func validatePatch(patch *string) error { 2482 + if patch == nil || *patch == "" { 2483 + return fmt.Errorf("patch is empty") 2484 + } 2485 + 2486 + // add newline if not present to diff style patches 2487 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 2488 + *patch = *patch + "\n" 2489 + } 2490 + 2491 + if err := patchutil.IsPatchValid(*patch); err != nil { 2492 + return err 2493 + } 2494 + 2495 + return nil 2496 + }
+1 -5
appview/repo/repo.go
··· 20 "tangled.org/core/appview/oauth" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/reporesolver" 23 - "tangled.org/core/appview/validator" 24 xrpcclient "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/eventconsumer" 26 "tangled.org/core/idresolver" ··· 49 notifier notify.Notifier 50 logger *slog.Logger 51 serviceAuth *serviceauth.ServiceAuth 52 - validator *validator.Validator 53 } 54 55 func New( ··· 63 notifier notify.Notifier, 64 enforcer *rbac.Enforcer, 65 logger *slog.Logger, 66 - validator *validator.Validator, 67 ) *Repo { 68 return &Repo{oauth: oauth, 69 repoResolver: repoResolver, ··· 75 notifier: notifier, 76 enforcer: enforcer, 77 logger: logger, 78 - validator: validator, 79 } 80 } 81 ··· 225 Multiple: multiple, 226 Created: time.Now(), 227 } 228 - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 229 fail(err.Error(), err) 230 return 231 }
··· 20 "tangled.org/core/appview/oauth" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/reporesolver" 23 xrpcclient "tangled.org/core/appview/xrpcclient" 24 "tangled.org/core/eventconsumer" 25 "tangled.org/core/idresolver" ··· 48 notifier notify.Notifier 49 logger *slog.Logger 50 serviceAuth *serviceauth.ServiceAuth 51 } 52 53 func New( ··· 61 notifier notify.Notifier, 62 enforcer *rbac.Enforcer, 63 logger *slog.Logger, 64 ) *Repo { 65 return &Repo{oauth: oauth, 66 repoResolver: repoResolver, ··· 72 notifier: notifier, 73 enforcer: enforcer, 74 logger: logger, 75 } 76 } 77 ··· 221 Multiple: multiple, 222 Created: time.Now(), 223 } 224 + if err := label.Validate(); err != nil { 225 fail(err.Error(), err) 226 return 227 }
+66 -6
appview/repo/settings.go
··· 4 "encoding/json" 5 "fmt" 6 "net/http" 7 "slices" 8 "strings" 9 "time" ··· 15 "tangled.org/core/appview/pages" 16 xrpcclient "tangled.org/core/appview/xrpcclient" 17 "tangled.org/core/orm" 18 "tangled.org/core/types" 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 385 topicStr = r.FormValue("topics") 386 ) 387 388 - err = rp.validator.ValidateURI(website) 389 - if website != "" && err != nil { 390 - l.Error("invalid uri", "err", err) 391 - rp.pages.Notice(w, noticeId, err.Error()) 392 - return 393 } 394 395 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 396 if err != nil { 397 l.Error("invalid topics", "err", err) 398 rp.pages.Notice(w, noticeId, err.Error()) ··· 452 453 rp.pages.HxRefresh(w) 454 }
··· 4 "encoding/json" 5 "fmt" 6 "net/http" 7 + "net/url" 8 + "regexp" 9 "slices" 10 "strings" 11 "time" ··· 17 "tangled.org/core/appview/pages" 18 xrpcclient "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/orm" 20 + "tangled.org/core/sets" 21 "tangled.org/core/types" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 388 topicStr = r.FormValue("topics") 389 ) 390 391 + if website != "" { 392 + if err := validateURI(website); err != nil { 393 + l.Error("invalid uri", "err", err) 394 + rp.pages.Notice(w, noticeId, err.Error()) 395 + return 396 + } 397 } 398 399 + topics, err := parseRepoTopicStr(topicStr) 400 if err != nil { 401 l.Error("invalid topics", "err", err) 402 rp.pages.Notice(w, noticeId, err.Error()) ··· 456 457 rp.pages.HxRefresh(w) 458 } 459 + 460 + const ( 461 + maxTopicLen = 50 462 + maxTopics = 20 463 + ) 464 + 465 + var ( 466 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 467 + ) 468 + 469 + // parseRepoTopicStr parses and validates whitespace-separated topic string. 470 + // 471 + // Rules: 472 + // - topics are separated by whitespace 473 + // - each topic may contain lowercase letters, digits, and hyphens only 474 + // - each topic must be <= 50 characters long 475 + // - no more than 20 topics allowed 476 + // - duplicates are removed 477 + func parseRepoTopicStr(topicStr string) ([]string, error) { 478 + topicStr = strings.TrimSpace(topicStr) 479 + if topicStr == "" { 480 + return nil, nil 481 + } 482 + parts := strings.Fields(topicStr) 483 + if len(parts) > maxTopics { 484 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 485 + } 486 + 487 + topicSet := sets.New[string]() 488 + 489 + for _, t := range parts { 490 + if topicSet.Contains(t) { 491 + continue 492 + } 493 + if len(t) > maxTopicLen { 494 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 495 + } 496 + if !topicRE.MatchString(t) { 497 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 498 + } 499 + topicSet.Insert(t) 500 + } 501 + return slices.Collect(topicSet.All()), nil 502 + } 503 + 504 + // TODO(boltless): move this to models.Repo instead 505 + func validateURI(uri string) error { 506 + parsed, err := url.Parse(uri) 507 + if err != nil { 508 + return fmt.Errorf("invalid uri format") 509 + } 510 + if parsed.Scheme == "" { 511 + return fmt.Errorf("uri scheme missing") 512 + } 513 + return nil 514 + }
-4
appview/state/router.go
··· 276 s.db, 277 s.config, 278 s.notifier, 279 - s.validator, 280 s.indexer.Issues, 281 log.SubLogger(s.logger, "issues"), 282 ) ··· 294 s.config, 295 s.notifier, 296 s.enforcer, 297 - s.validator, 298 s.indexer.Pulls, 299 log.SubLogger(s.logger, "pulls"), 300 ) ··· 313 s.notifier, 314 s.enforcer, 315 log.SubLogger(s.logger, "repo"), 316 - s.validator, 317 ) 318 return repo.Router(mw) 319 } ··· 338 s.oauth, 339 s.pages, 340 s.db, 341 - s.validator, 342 s.enforcer, 343 log.SubLogger(s.logger, "labels"), 344 )
··· 276 s.db, 277 s.config, 278 s.notifier, 279 s.indexer.Issues, 280 log.SubLogger(s.logger, "issues"), 281 ) ··· 293 s.config, 294 s.notifier, 295 s.enforcer, 296 s.indexer.Pulls, 297 log.SubLogger(s.logger, "pulls"), 298 ) ··· 311 s.notifier, 312 s.enforcer, 313 log.SubLogger(s.logger, "repo"), 314 ) 315 return repo.Router(mw) 316 } ··· 335 s.oauth, 336 s.pages, 337 s.db, 338 s.enforcer, 339 log.SubLogger(s.logger, "labels"), 340 )
-5
appview/state/state.go
··· 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/reporesolver" 26 - "tangled.org/core/appview/validator" 27 xrpcclient "tangled.org/core/appview/xrpcclient" 28 "tangled.org/core/eventconsumer" 29 "tangled.org/core/idresolver" ··· 59 knotstream *eventconsumer.Consumer 60 spindlestream *eventconsumer.Consumer 61 logger *slog.Logger 62 - validator *validator.Validator 63 } 64 65 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 97 if err != nil { 98 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 99 } 100 - validator := validator.New(d, res, enforcer) 101 102 repoResolver := reporesolver.New(config, enforcer, d) 103 ··· 144 IdResolver: res, 145 Config: config, 146 Logger: log.SubLogger(logger, "ingester"), 147 - Validator: validator, 148 } 149 err = jc.StartJetstream(ctx, ingester.Ingest()) 150 if err != nil { ··· 192 knotstream, 193 spindlestream, 194 logger, 195 - validator, 196 } 197 198 return state, nil
··· 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/reporesolver" 26 xrpcclient "tangled.org/core/appview/xrpcclient" 27 "tangled.org/core/eventconsumer" 28 "tangled.org/core/idresolver" ··· 58 knotstream *eventconsumer.Consumer 59 spindlestream *eventconsumer.Consumer 60 logger *slog.Logger 61 } 62 63 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 95 if err != nil { 96 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 97 } 98 99 repoResolver := reporesolver.New(config, enforcer, d) 100 ··· 141 IdResolver: res, 142 Config: config, 143 Logger: log.SubLogger(logger, "ingester"), 144 } 145 err = jc.StartJetstream(ctx, ingester.Ingest()) 146 if err != nil { ··· 188 knotstream, 189 spindlestream, 190 logger, 191 } 192 193 return state, nil
-55
appview/validator/issue.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 - ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 - 37 - func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 - if issue.Title == "" { 39 - return fmt.Errorf("issue title is empty") 40 - } 41 - 42 - if issue.Body == "" { 43 - return fmt.Errorf("issue body is empty") 44 - } 45 - 46 - if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 47 - return fmt.Errorf("title is empty after HTML sanitization") 48 - } 49 - 50 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 51 - return fmt.Errorf("body is empty after HTML sanitization") 52 - } 53 - 54 - return nil 55 - }
···
-217
appview/validator/label.go
··· 1 - package validator 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "regexp" 7 - "slices" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 - "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/models" 13 - ) 14 - 15 - var ( 16 - // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 17 - labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 18 - // Color should be a valid hex color 19 - colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 20 - // You can only label issues and pulls presently 21 - validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 - ) 23 - 24 - func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 - if label.Name == "" { 26 - return fmt.Errorf("label name is empty") 27 - } 28 - if len(label.Name) > 40 { 29 - return fmt.Errorf("label name too long (max 40 graphemes)") 30 - } 31 - if len(label.Name) < 1 { 32 - return fmt.Errorf("label name too short (min 1 grapheme)") 33 - } 34 - if !labelNameRegex.MatchString(label.Name) { 35 - return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 36 - } 37 - 38 - if !label.ValueType.IsConcreteType() { 39 - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) 40 - } 41 - 42 - // null type checks: cannot be enums, multiple or explicit format 43 - if label.ValueType.IsNull() && label.ValueType.IsEnum() { 44 - return fmt.Errorf("null type cannot be used in conjunction with enum type") 45 - } 46 - if label.ValueType.IsNull() && label.Multiple { 47 - return fmt.Errorf("null type labels cannot be multiple") 48 - } 49 - if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { 50 - return fmt.Errorf("format cannot be used in conjunction with null type") 51 - } 52 - 53 - // format checks: cannot be used with enum, or integers 54 - if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { 55 - return fmt.Errorf("enum types cannot be used in conjunction with format specification") 56 - } 57 - 58 - if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { 59 - return fmt.Errorf("format specifications are only permitted on string types") 60 - } 61 - 62 - // validate scope (nsid format) 63 - if label.Scope == nil { 64 - return fmt.Errorf("scope is required") 65 - } 66 - for _, s := range label.Scope { 67 - if _, err := syntax.ParseNSID(s); err != nil { 68 - return fmt.Errorf("failed to parse scope: %w", err) 69 - } 70 - if !slices.Contains(validScopes, s) { 71 - return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 72 - } 73 - } 74 - 75 - // validate color if provided 76 - if label.Color != nil { 77 - color := strings.TrimSpace(*label.Color) 78 - if color == "" { 79 - // empty color is fine, set to nil 80 - label.Color = nil 81 - } else { 82 - if !colorRegex.MatchString(color) { 83 - return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 84 - } 85 - // expand 3-digit hex to 6-digit hex 86 - if len(color) == 4 { // #ABC 87 - color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 88 - } 89 - // convert to uppercase for consistency 90 - color = strings.ToUpper(color) 91 - label.Color = &color 92 - } 93 - } 94 - 95 - return nil 96 - } 97 - 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 - if labelDef == nil { 100 - return fmt.Errorf("label definition is required") 101 - } 102 - if repo == nil { 103 - return fmt.Errorf("repo is required") 104 - } 105 - if labelOp == nil { 106 - return fmt.Errorf("label operation is required") 107 - } 108 - 109 - // validate permissions: only collaborators can apply labels currently 110 - // 111 - // TODO: introduce a repo:triage permission 112 - ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 - if err != nil { 114 - return fmt.Errorf("failed to enforce permissions: %w", err) 115 - } 116 - if !ok { 117 - return fmt.Errorf("unauhtorized label operation") 118 - } 119 - 120 - expectedKey := labelDef.AtUri().String() 121 - if labelOp.OperandKey != expectedKey { 122 - return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 123 - } 124 - 125 - if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 126 - return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 127 - } 128 - 129 - if labelOp.Subject == "" { 130 - return fmt.Errorf("subject URI is required") 131 - } 132 - if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 133 - return fmt.Errorf("invalid subject URI: %w", err) 134 - } 135 - 136 - if err := v.validateOperandValue(labelDef, labelOp); err != nil { 137 - return fmt.Errorf("invalid operand value: %w", err) 138 - } 139 - 140 - // Validate performed time is not zero/invalid 141 - if labelOp.PerformedAt.IsZero() { 142 - return fmt.Errorf("performed_at timestamp is required") 143 - } 144 - 145 - return nil 146 - } 147 - 148 - func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 149 - valueType := labelDef.ValueType 150 - 151 - // this is permitted, it "unsets" a label 152 - if labelOp.OperandValue == "" { 153 - labelOp.Operation = models.LabelOperationDel 154 - return nil 155 - } 156 - 157 - switch valueType.Type { 158 - case models.ConcreteTypeNull: 159 - // For null type, value should be empty 160 - if labelOp.OperandValue != "null" { 161 - return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 162 - } 163 - 164 - case models.ConcreteTypeString: 165 - // For string type, validate enum constraints if present 166 - if valueType.IsEnum() { 167 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 168 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 169 - } 170 - } 171 - 172 - switch valueType.Format { 173 - case models.ValueTypeFormatDid: 174 - id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 175 - if err != nil { 176 - return fmt.Errorf("failed to resolve did/handle: %w", err) 177 - } 178 - 179 - labelOp.OperandValue = id.DID.String() 180 - 181 - case models.ValueTypeFormatAny, "": 182 - default: 183 - return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 184 - } 185 - 186 - case models.ConcreteTypeInt: 187 - if labelOp.OperandValue == "" { 188 - return fmt.Errorf("integer type requires non-empty value") 189 - } 190 - if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { 191 - return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) 192 - } 193 - 194 - if valueType.IsEnum() { 195 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 196 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 197 - } 198 - } 199 - 200 - case models.ConcreteTypeBool: 201 - if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 202 - return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 203 - } 204 - 205 - // validate enum constraints if present (though uncommon for booleans) 206 - if valueType.IsEnum() { 207 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 208 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 209 - } 210 - } 211 - 212 - default: 213 - return fmt.Errorf("unsupported value type: %q", valueType.Type) 214 - } 215 - 216 - return nil 217 - }
···
-25
appview/validator/patch.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/patchutil" 8 - ) 9 - 10 - func (v *Validator) ValidatePatch(patch *string) error { 11 - if patch == nil || *patch == "" { 12 - return fmt.Errorf("patch is empty") 13 - } 14 - 15 - // add newline if not present to diff style patches 16 - if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 - *patch = *patch + "\n" 18 - } 19 - 20 - if err := patchutil.IsPatchValid(*patch); err != nil { 21 - return err 22 - } 23 - 24 - return nil 25 - }
···
-53
appview/validator/repo_topics.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "maps" 6 - "regexp" 7 - "slices" 8 - "strings" 9 - ) 10 - 11 - const ( 12 - maxTopicLen = 50 13 - maxTopics = 20 14 - ) 15 - 16 - var ( 17 - topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 18 - ) 19 - 20 - // ValidateRepoTopicStr parses and validates whitespace-separated topic string. 21 - // 22 - // Rules: 23 - // - topics are separated by whitespace 24 - // - each topic may contain lowercase letters, digits, and hyphens only 25 - // - each topic must be <= 50 characters long 26 - // - no more than 20 topics allowed 27 - // - duplicates are removed 28 - func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { 29 - topicsStr = strings.TrimSpace(topicsStr) 30 - if topicsStr == "" { 31 - return nil, nil 32 - } 33 - parts := strings.Fields(topicsStr) 34 - if len(parts) > maxTopics { 35 - return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 36 - } 37 - 38 - topicSet := make(map[string]struct{}) 39 - 40 - for _, t := range parts { 41 - if _, exists := topicSet[t]; exists { 42 - continue 43 - } 44 - if len(t) > maxTopicLen { 45 - return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 46 - } 47 - if !topicRE.MatchString(t) { 48 - return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 49 - } 50 - topicSet[t] = struct{}{} 51 - } 52 - return slices.Collect(maps.Keys(topicSet)), nil 53 - }
···
-27
appview/validator/string.go
··· 1 - package validator 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "unicode/utf8" 7 - 8 - "tangled.org/core/appview/models" 9 - ) 10 - 11 - func (v *Validator) ValidateString(s *models.String) error { 12 - var err error 13 - 14 - if utf8.RuneCountInString(s.Filename) > 140 { 15 - err = errors.Join(err, fmt.Errorf("filename too long")) 16 - } 17 - 18 - if utf8.RuneCountInString(s.Description) > 280 { 19 - err = errors.Join(err, fmt.Errorf("description too long")) 20 - } 21 - 22 - if len(s.Contents) == 0 { 23 - err = errors.Join(err, fmt.Errorf("contents is empty")) 24 - } 25 - 26 - return err 27 - }
···
-17
appview/validator/uri.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "net/url" 6 - ) 7 - 8 - func (v *Validator) ValidateURI(uri string) error { 9 - parsed, err := url.Parse(uri) 10 - if err != nil { 11 - return fmt.Errorf("invalid uri format") 12 - } 13 - if parsed.Scheme == "" { 14 - return fmt.Errorf("uri scheme missing") 15 - } 16 - return nil 17 - }
···
-24
appview/validator/validator.go
··· 1 - package validator 2 - 3 - import ( 4 - "tangled.org/core/appview/db" 5 - "tangled.org/core/appview/pages/markup" 6 - "tangled.org/core/idresolver" 7 - "tangled.org/core/rbac" 8 - ) 9 - 10 - type Validator struct { 11 - db *db.DB 12 - sanitizer markup.Sanitizer 13 - resolver *idresolver.Resolver 14 - enforcer *rbac.Enforcer 15 - } 16 - 17 - func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 - return &Validator{ 19 - db: db, 20 - sanitizer: markup.NewSanitizer(), 21 - resolver: res, 22 - enforcer: enforcer, 23 - } 24 - }
···