Monorepo for Tangled tangled.org

appview: remove validator #993

open opened by boltless.me targeting master from sl/sqkrqopzkvoo
  • 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

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:xasnlahkri4ewmbuzly2rlc5/sh.tangled.repo.pull/3mcsfwwhtdr22
+399 -517
Diff #0
+17 -7
appview/ingester.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/serververify" 22 - "tangled.org/core/appview/validator" 23 22 "tangled.org/core/idresolver" 24 23 "tangled.org/core/orm" 25 24 "tangled.org/core/rbac" ··· 31 30 IdResolver *idresolver.Resolver 32 31 Config *config.Config 33 32 Logger *slog.Logger 34 - Validator *validator.Validator 35 33 } 36 34 37 35 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 607 605 608 606 string := models.StringFromRecord(did, rkey, record) 609 607 610 - if err = i.Validator.ValidateString(&string); err != nil { 608 + if err = string.Validate(); err != nil { 611 609 l.Error("invalid record", "err", err) 612 610 return err 613 611 } ··· 816 814 817 815 issue := models.IssueFromRecord(did, rkey, record) 818 816 819 - if err := i.Validator.ValidateIssue(&issue); err != nil { 817 + if err := issue.Validate(); err != nil { 820 818 return fmt.Errorf("failed to validate issue: %w", err) 821 819 } 822 820 ··· 896 894 return fmt.Errorf("failed to parse comment from record: %w", err) 897 895 } 898 896 899 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 897 + if err := comment.Validate(); err != nil { 900 898 return fmt.Errorf("failed to validate comment: %w", err) 901 899 } 902 900 ··· 956 954 return fmt.Errorf("failed to parse labeldef from record: %w", err) 957 955 } 958 956 959 - if err := i.Validator.ValidateLabelDefinition(def); err != nil { 957 + if err := def.Validate(); err != nil { 960 958 return fmt.Errorf("failed to validate labeldef: %w", err) 961 959 } 962 960 ··· 1032 1030 if !ok { 1033 1031 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1034 1032 } 1035 - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1033 + 1034 + // validate permissions: only collaborators can apply labels currently 1035 + // 1036 + // TODO: introduce a repo:triage permission 1037 + ok, err := i.Enforcer.IsPushAllowed(o.Did, repo.Knot, repo.DidSlashRepo()) 1038 + if err != nil { 1039 + return fmt.Errorf("enforcing permission: %w", err) 1040 + } 1041 + if !ok { 1042 + return fmt.Errorf("unauthorized label operation") 1043 + } 1044 + 1045 + if err := def.ValidateOperandValue(&o); err != nil { 1036 1046 return fmt.Errorf("failed to validate labelop: %w", err) 1037 1047 } 1038 1048 }
+3 -7
appview/issues/issues.go
··· 27 27 "tangled.org/core/appview/pages/repoinfo" 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 - "tangled.org/core/appview/validator" 31 30 "tangled.org/core/idresolver" 32 31 "tangled.org/core/orm" 33 32 "tangled.org/core/rbac" ··· 45 44 config *config.Config 46 45 notifier notify.Notifier 47 46 logger *slog.Logger 48 - validator *validator.Validator 49 47 indexer *issues_indexer.Indexer 50 48 } 51 49 ··· 59 57 db *db.DB, 60 58 config *config.Config, 61 59 notifier notify.Notifier, 62 - validator *validator.Validator, 63 60 indexer *issues_indexer.Indexer, 64 61 logger *slog.Logger, 65 62 ) *Issues { ··· 74 71 config: config, 75 72 notifier: notifier, 76 73 logger: logger, 77 - validator: validator, 78 74 indexer: indexer, 79 75 } 80 76 } ··· 166 162 newIssue.Body = r.FormValue("body") 167 163 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 168 164 169 - if err := rp.validator.ValidateIssue(newIssue); err != nil { 165 + if err := newIssue.Validate(); err != nil { 170 166 l.Error("validation error", "err", err) 171 167 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 172 168 return ··· 425 421 Mentions: mentions, 426 422 References: references, 427 423 } 428 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 424 + if err = comment.Validate(); err != nil { 429 425 l.Error("failed to validate comment", "err", err) 430 426 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 431 427 return ··· 928 924 Repo: f, 929 925 } 930 926 931 - if err := rp.validator.ValidateIssue(issue); err != nil { 927 + if err := issue.Validate(); err != nil { 932 928 l.Error("validation error", "err", err) 933 929 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 934 930 return
+27 -15
appview/labels/labels.go
··· 15 15 "tangled.org/core/appview/models" 16 16 "tangled.org/core/appview/oauth" 17 17 "tangled.org/core/appview/pages" 18 - "tangled.org/core/appview/validator" 19 18 "tangled.org/core/orm" 20 19 "tangled.org/core/rbac" 21 20 "tangled.org/core/tid" ··· 28 27 ) 29 28 30 29 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 30 + oauth *oauth.OAuth 31 + pages *pages.Pages 32 + db *db.DB 33 + logger *slog.Logger 34 + enforcer *rbac.Enforcer 37 35 } 38 36 39 37 func New( 40 38 oauth *oauth.OAuth, 41 39 pages *pages.Pages, 42 40 db *db.DB, 43 - validator *validator.Validator, 44 41 enforcer *rbac.Enforcer, 45 42 logger *slog.Logger, 46 43 ) *Labels { 47 44 return &Labels{ 48 - oauth: oauth, 49 - pages: pages, 50 - db: db, 51 - logger: logger, 52 - validator: validator, 53 - enforcer: enforcer, 45 + oauth: oauth, 46 + pages: pages, 47 + db: db, 48 + logger: logger, 49 + enforcer: enforcer, 54 50 } 55 51 } 56 52 ··· 163 159 164 160 for i := range labelOps { 165 161 def := actx.Defs[labelOps[i].OperandKey] 166 - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 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 { 167 178 fail(fmt.Sprintf("Invalid form data: %s", err), err) 168 179 return 169 180 } 181 + labelOps[i] = op 170 182 } 171 183 172 184 // reduce the opset
+32
appview/models/issue.go
··· 3 3 import ( 4 4 "fmt" 5 5 "sort" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages/markup/sanitizer" 10 12 ) 11 13 12 14 type Issue struct { ··· 61 63 return "closed" 62 64 } 63 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 + 64 86 type CommentListItem struct { 65 87 Self *IssueComment 66 88 Replies []*IssueComment ··· 217 239 return i.ReplyTo != nil 218 240 } 219 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 + 220 252 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 221 253 created, err := time.Parse(time.RFC3339, record.CreatedAt) 222 254 if err != nil {
+183 -4
appview/models/label.go
··· 7 7 "encoding/json" 8 8 "errors" 9 9 "fmt" 10 + "regexp" 10 11 "slices" 12 + "strings" 11 13 "time" 12 14 13 15 "github.com/bluesky-social/indigo/api/atproto" ··· 120 122 } 121 123 } 122 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 + 123 286 // random color for a given seed 124 287 func randomColor(seed string) string { 125 288 hash := sha1.Sum([]byte(seed)) ··· 131 294 return fmt.Sprintf("#%s%s%s", r, g, b) 132 295 } 133 296 134 - func (ld LabelDefinition) GetColor() string { 135 - if ld.Color == nil { 136 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 297 + func (l LabelDefinition) GetColor() string { 298 + if l.Color == nil { 299 + seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) 137 300 color := randomColor(seed) 138 301 return color 139 302 } 140 303 141 - return *ld.Color 304 + return *l.Color 142 305 } 143 306 144 307 func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { ··· 205 368 return indexedAt 206 369 } 207 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 + 208 387 type LabelOperation string 209 388 210 389 const (
+21
appview/models/string.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "errors" 5 6 "fmt" 6 7 "io" 7 8 "strings" 8 9 "time" 10 + "unicode/utf8" 9 11 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 11 13 "tangled.org/core/api/tangled" ··· 35 37 } 36 38 } 37 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 + 38 59 func StringFromRecord(did, rkey string, record tangled.String) String { 39 60 created, err := time.Parse(record.CreatedAt, time.RFC3339) 40 61 if err != nil {
+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
··· 29 29 "tangled.org/core/appview/models" 30 30 "tangled.org/core/appview/oauth" 31 31 "tangled.org/core/appview/pages/markup" 32 + "tangled.org/core/appview/pages/markup/sanitizer" 32 33 "tangled.org/core/crypto" 33 34 ) 34 35 ··· 257 258 "markdown": func(text string) template.HTML { 258 259 p.rctx.RendererType = markup.RendererTypeDefault 259 260 htmlString := p.rctx.RenderMarkdown(text) 260 - sanitized := p.rctx.SanitizeDefault(htmlString) 261 + sanitized := sanitizer.SanitizeDefault(htmlString) 261 262 return template.HTML(sanitized) 262 263 }, 263 264 "description": func(text string) template.HTML { ··· 267 268 emoji.Emoji, 268 269 ), 269 270 )) 270 - sanitized := p.rctx.SanitizeDescription(htmlString) 271 + sanitized := sanitizer.SanitizeDescription(htmlString) 271 272 return template.HTML(sanitized) 272 273 }, 273 274 "readme": func(text string) template.HTML { 274 275 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 275 276 htmlString := p.rctx.RenderMarkdown(text) 276 - sanitized := p.rctx.SanitizeDefault(htmlString) 277 + sanitized := sanitizer.SanitizeDefault(htmlString) 277 278 return template.HTML(sanitized) 278 279 }, 279 280 "code": func(content, path string) string {
-9
appview/pages/markup/markdown.go
··· 47 47 repoinfo.RepoInfo 48 48 IsDev bool 49 49 RendererType RendererType 50 - Sanitizer Sanitizer 51 50 Files fs.FS 52 51 } 53 52 ··· 177 176 } 178 177 } 179 178 180 - func (rctx *RenderContext) SanitizeDefault(html string) string { 181 - return rctx.Sanitizer.SanitizeDefault(html) 182 - } 183 - 184 - func (rctx *RenderContext) SanitizeDescription(html string) string { 185 - return rctx.Sanitizer.SanitizeDescription(html) 186 - } 187 - 188 179 type MarkdownTransformer struct { 189 180 rctx *RenderContext 190 181 }
+11 -18
appview/pages/markup/sanitizer.go appview/pages/markup/sanitizer/sanitizer.go
··· 1 - package markup 1 + package sanitizer 2 2 3 3 import ( 4 4 "maps" ··· 10 10 "github.com/microcosm-cc/bluemonday" 11 11 ) 12 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 - } 13 + var ( 14 + defaultPolicy = newDefaultPolicy() 15 + descriptionPolicy = newDescriptionPolicy() 16 + ) 24 17 25 - func (s *Sanitizer) SanitizeDefault(html string) string { 26 - return s.defaultPolicy.Sanitize(html) 18 + func SanitizeDefault(html string) string { 19 + return defaultPolicy.Sanitize(html) 27 20 } 28 - func (s *Sanitizer) SanitizeDescription(html string) string { 29 - return s.descriptionPolicy.Sanitize(html) 21 + func SanitizeDescription(html string) string { 22 + return descriptionPolicy.Sanitize(html) 30 23 } 31 24 32 - func defaultPolicy() *bluemonday.Policy { 25 + func newDefaultPolicy() *bluemonday.Policy { 33 26 policy := bluemonday.UGCPolicy() 34 27 35 28 // Allow generally safe attributes ··· 123 116 return policy 124 117 } 125 118 126 - func descriptionPolicy() *bluemonday.Policy { 119 + func newDescriptionPolicy() *bluemonday.Policy { 127 120 policy := bluemonday.NewPolicy() 128 121 policy.AllowStandardURLs() 129 122
+5 -5
appview/pages/pages.go
··· 22 22 "tangled.org/core/appview/models" 23 23 "tangled.org/core/appview/oauth" 24 24 "tangled.org/core/appview/pages/markup" 25 + "tangled.org/core/appview/pages/markup/sanitizer" 25 26 "tangled.org/core/appview/pages/repoinfo" 26 27 "tangled.org/core/appview/pagination" 27 28 "tangled.org/core/idresolver" ··· 55 56 IsDev: config.Core.Dev, 56 57 CamoUrl: config.Camo.Host, 57 58 CamoSecret: config.Camo.SharedSecret, 58 - Sanitizer: markup.NewSanitizer(), 59 59 Files: Files, 60 60 } 61 61 ··· 270 270 271 271 p.rctx.RendererType = markup.RendererTypeDefault 272 272 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 273 - sanitized := p.rctx.SanitizeDefault(htmlString) 273 + sanitized := sanitizer.SanitizeDefault(htmlString) 274 274 params.Content = template.HTML(sanitized) 275 275 276 276 return p.execute("legal/terms", w, params) ··· 298 298 299 299 p.rctx.RendererType = markup.RendererTypeDefault 300 300 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 301 - sanitized := p.rctx.SanitizeDefault(htmlString) 301 + sanitized := sanitizer.SanitizeDefault(htmlString) 302 302 params.Content = template.HTML(sanitized) 303 303 304 304 return p.execute("legal/privacy", w, params) ··· 698 698 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 699 699 params.Raw = false 700 700 htmlString := p.rctx.RenderMarkdown(params.Readme) 701 - sanitized := p.rctx.SanitizeDefault(htmlString) 701 + sanitized := sanitizer.SanitizeDefault(htmlString) 702 702 params.HTMLReadme = template.HTML(sanitized) 703 703 default: 704 704 params.Raw = true ··· 789 789 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 790 790 params.Raw = false 791 791 htmlString := p.rctx.RenderMarkdown(params.Readme) 792 - sanitized := p.rctx.SanitizeDefault(htmlString) 792 + sanitized := sanitizer.SanitizeDefault(htmlString) 793 793 params.HTMLReadme = template.HTML(sanitized) 794 794 default: 795 795 params.Raw = true
+23 -11
appview/pulls/pulls.go
··· 27 27 "tangled.org/core/appview/notify" 28 28 "tangled.org/core/appview/oauth" 29 29 "tangled.org/core/appview/pages" 30 - "tangled.org/core/appview/pages/markup" 30 + "tangled.org/core/appview/pages/markup/sanitizer" 31 31 "tangled.org/core/appview/pages/repoinfo" 32 32 "tangled.org/core/appview/pagination" 33 33 "tangled.org/core/appview/reporesolver" 34 - "tangled.org/core/appview/validator" 35 34 "tangled.org/core/appview/xrpcclient" 36 35 "tangled.org/core/idresolver" 37 36 "tangled.org/core/orm" ··· 59 58 notifier notify.Notifier 60 59 enforcer *rbac.Enforcer 61 60 logger *slog.Logger 62 - validator *validator.Validator 63 61 indexer *pulls_indexer.Indexer 64 62 } 65 63 ··· 73 71 config *config.Config, 74 72 notifier notify.Notifier, 75 73 enforcer *rbac.Enforcer, 76 - validator *validator.Validator, 77 74 indexer *pulls_indexer.Indexer, 78 75 logger *slog.Logger, 79 76 ) *Pulls { ··· 88 85 notifier: notifier, 89 86 enforcer: enforcer, 90 87 logger: logger, 91 - validator: validator, 92 88 indexer: indexer, 93 89 } 94 90 } ··· 863 859 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 864 860 return 865 861 } 866 - sanitizer := markup.NewSanitizer() 867 862 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 868 863 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 869 864 return ··· 991 986 patch := comparison.FormatPatchRaw 992 987 combined := comparison.CombinedPatchRaw 993 988 994 - if err := s.validator.ValidatePatch(&patch); err != nil { 989 + if err := validatePatch(&patch); err != nil { 995 990 s.logger.Error("failed to validate patch", "err", err) 996 991 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 997 992 return ··· 1009 1004 } 1010 1005 1011 1006 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1012 - if err := s.validator.ValidatePatch(&patch); err != nil { 1007 + if err := validatePatch(&patch); err != nil { 1013 1008 s.logger.Error("patch validation failed", "err", err) 1014 1009 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1015 1010 return ··· 1101 1096 patch := comparison.FormatPatchRaw 1102 1097 combined := comparison.CombinedPatchRaw 1103 1098 1104 - if err := s.validator.ValidatePatch(&patch); err != nil { 1099 + if err := validatePatch(&patch); err != nil { 1105 1100 s.logger.Error("failed to validate patch", "err", err) 1106 1101 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1107 1102 return ··· 1394 1389 return 1395 1390 } 1396 1391 1397 - if err := s.validator.ValidatePatch(&patch); err != nil { 1392 + if err := validatePatch(&patch); err != nil { 1398 1393 s.logger.Error("faield to validate patch", "err", err) 1399 1394 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1400 1395 return ··· 1815 1810 return 1816 1811 } 1817 1812 1818 - if err := s.validator.ValidatePatch(&patch); err != nil { 1813 + if err := validatePatch(&patch); err != nil { 1819 1814 s.pages.Notice(w, "resubmit-error", err.Error()) 1820 1815 return 1821 1816 } ··· 2441 2436 w.Close() 2442 2437 return &b 2443 2438 } 2439 + 2440 + func validatePatch(patch *string) error { 2441 + if patch == nil || *patch == "" { 2442 + return fmt.Errorf("patch is empty") 2443 + } 2444 + 2445 + // add newline if not present to diff style patches 2446 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 2447 + *patch = *patch + "\n" 2448 + } 2449 + 2450 + if err := patchutil.IsPatchValid(*patch); err != nil { 2451 + return err 2452 + } 2453 + 2454 + return nil 2455 + }
+1 -5
appview/repo/repo.go
··· 20 20 "tangled.org/core/appview/oauth" 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/appview/reporesolver" 23 - "tangled.org/core/appview/validator" 24 23 xrpcclient "tangled.org/core/appview/xrpcclient" 25 24 "tangled.org/core/eventconsumer" 26 25 "tangled.org/core/idresolver" ··· 49 48 notifier notify.Notifier 50 49 logger *slog.Logger 51 50 serviceAuth *serviceauth.ServiceAuth 52 - validator *validator.Validator 53 51 } 54 52 55 53 func New( ··· 63 61 notifier notify.Notifier, 64 62 enforcer *rbac.Enforcer, 65 63 logger *slog.Logger, 66 - validator *validator.Validator, 67 64 ) *Repo { 68 65 return &Repo{oauth: oauth, 69 66 repoResolver: repoResolver, ··· 75 72 notifier: notifier, 76 73 enforcer: enforcer, 77 74 logger: logger, 78 - validator: validator, 79 75 } 80 76 } 81 77 ··· 225 221 Multiple: multiple, 226 222 Created: time.Now(), 227 223 } 228 - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 224 + if err := label.Validate(); err != nil { 229 225 fail(err.Error(), err) 230 226 return 231 227 }
+66 -6
appview/repo/settings.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 8 + "regexp" 7 9 "slices" 8 10 "strings" 9 11 "time" ··· 15 17 "tangled.org/core/appview/pages" 16 18 xrpcclient "tangled.org/core/appview/xrpcclient" 17 19 "tangled.org/core/orm" 20 + "tangled.org/core/sets" 18 21 "tangled.org/core/types" 19 22 20 23 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 402 405 topicStr = r.FormValue("topics") 403 406 ) 404 407 405 - err = rp.validator.ValidateURI(website) 406 - if website != "" && err != nil { 407 - l.Error("invalid uri", "err", err) 408 - rp.pages.Notice(w, noticeId, err.Error()) 409 - return 408 + if website != "" { 409 + if err := validateURI(website); err != nil { 410 + l.Error("invalid uri", "err", err) 411 + rp.pages.Notice(w, noticeId, err.Error()) 412 + return 413 + } 410 414 } 411 415 412 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 416 + topics, err := parseRepoTopicStr(topicStr) 413 417 if err != nil { 414 418 l.Error("invalid topics", "err", err) 415 419 rp.pages.Notice(w, noticeId, err.Error()) ··· 469 473 470 474 rp.pages.HxRefresh(w) 471 475 } 476 + 477 + const ( 478 + maxTopicLen = 50 479 + maxTopics = 20 480 + ) 481 + 482 + var ( 483 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 484 + ) 485 + 486 + // parseRepoTopicStr parses and validates whitespace-separated topic string. 487 + // 488 + // Rules: 489 + // - topics are separated by whitespace 490 + // - each topic may contain lowercase letters, digits, and hyphens only 491 + // - each topic must be <= 50 characters long 492 + // - no more than 20 topics allowed 493 + // - duplicates are removed 494 + func parseRepoTopicStr(topicStr string) ([]string, error) { 495 + topicStr = strings.TrimSpace(topicStr) 496 + if topicStr == "" { 497 + return nil, nil 498 + } 499 + parts := strings.Fields(topicStr) 500 + if len(parts) > maxTopics { 501 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 502 + } 503 + 504 + topicSet := sets.New[string]() 505 + 506 + for _, t := range parts { 507 + if topicSet.Contains(t) { 508 + continue 509 + } 510 + if len(t) > maxTopicLen { 511 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 512 + } 513 + if !topicRE.MatchString(t) { 514 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 515 + } 516 + topicSet.Insert(t) 517 + } 518 + return slices.Collect(topicSet.All()), nil 519 + } 520 + 521 + // TODO(boltless): move this to models.Repo instead 522 + func validateURI(uri string) error { 523 + parsed, err := url.Parse(uri) 524 + if err != nil { 525 + return fmt.Errorf("invalid uri format") 526 + } 527 + if parsed.Scheme == "" { 528 + return fmt.Errorf("uri scheme missing") 529 + } 530 + return nil 531 + }
-4
appview/state/router.go
··· 274 274 s.db, 275 275 s.config, 276 276 s.notifier, 277 - s.validator, 278 277 s.indexer.Issues, 279 278 log.SubLogger(s.logger, "issues"), 280 279 ) ··· 292 291 s.config, 293 292 s.notifier, 294 293 s.enforcer, 295 - s.validator, 296 294 s.indexer.Pulls, 297 295 log.SubLogger(s.logger, "pulls"), 298 296 ) ··· 311 309 s.notifier, 312 310 s.enforcer, 313 311 log.SubLogger(s.logger, "repo"), 314 - s.validator, 315 312 ) 316 313 return repo.Router(mw) 317 314 } ··· 336 333 s.oauth, 337 334 s.pages, 338 335 s.db, 339 - s.validator, 340 336 s.enforcer, 341 337 log.SubLogger(s.logger, "labels"), 342 338 )
-5
appview/state/state.go
··· 23 23 "tangled.org/core/appview/oauth" 24 24 "tangled.org/core/appview/pages" 25 25 "tangled.org/core/appview/reporesolver" 26 - "tangled.org/core/appview/validator" 27 26 xrpcclient "tangled.org/core/appview/xrpcclient" 28 27 "tangled.org/core/eventconsumer" 29 28 "tangled.org/core/idresolver" ··· 59 58 knotstream *eventconsumer.Consumer 60 59 spindlestream *eventconsumer.Consumer 61 60 logger *slog.Logger 62 - validator *validator.Validator 63 61 } 64 62 65 63 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 97 95 if err != nil { 98 96 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 99 97 } 100 - validator := validator.New(d, res, enforcer) 101 98 102 99 repoResolver := reporesolver.New(config, enforcer, d) 103 100 ··· 144 141 IdResolver: res, 145 142 Config: config, 146 143 Logger: log.SubLogger(logger, "ingester"), 147 - Validator: validator, 148 144 } 149 145 err = jc.StartJetstream(ctx, ingester.Ingest()) 150 146 if err != nil { ··· 191 187 knotstream, 192 188 spindlestream, 193 189 logger, 194 - validator, 195 190 } 196 191 197 192 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 - "strings" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "golang.org/x/exp/slices" 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 - }

Submissions

sign up or login to add to the discussion
boltless.me submitted #0
1 commit
expand
appview: remove validator
1/3 timeout, 2/3 success
expand
no conflicts, ready to merge