A vibe coded tangled fork which supports pijul.

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>

+395 -518
+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 ··· 613 611 614 612 string := models.StringFromRecord(did, rkey, record) 615 613 616 - if err = i.Validator.ValidateString(&string); err != nil { 614 + if err = string.Validate(); err != nil { 617 615 l.Error("invalid record", "err", err) 618 616 return err 619 617 } ··· 822 820 823 821 issue := models.IssueFromRecord(did, rkey, record) 824 822 825 - if err := i.Validator.ValidateIssue(&issue); err != nil { 823 + if err := issue.Validate(); err != nil { 826 824 return fmt.Errorf("failed to validate issue: %w", err) 827 825 } 828 826 ··· 902 900 return fmt.Errorf("failed to parse comment from record: %w", err) 903 901 } 904 902 905 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 903 + if err := comment.Validate(); err != nil { 906 904 return fmt.Errorf("failed to validate comment: %w", err) 907 905 } 908 906 ··· 962 960 return fmt.Errorf("failed to parse labeldef from record: %w", err) 963 961 } 964 962 965 - if err := i.Validator.ValidateLabelDefinition(def); err != nil { 963 + if err := def.Validate(); err != nil { 966 964 return fmt.Errorf("failed to validate labeldef: %w", err) 967 965 } 968 966 ··· 1038 1036 if !ok { 1039 1037 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1040 1038 } 1041 - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 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 { 1042 1052 return fmt.Errorf("failed to validate labelop: %w", err) 1043 1053 } 1044 1054 }
+3 -7
appview/issues/issues.go
··· 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 30 "tangled.org/core/appview/searchquery" 31 - "tangled.org/core/appview/validator" 32 31 "tangled.org/core/idresolver" 33 32 "tangled.org/core/orm" 34 33 "tangled.org/core/rbac" ··· 46 45 config *config.Config 47 46 notifier notify.Notifier 48 47 logger *slog.Logger 49 - validator *validator.Validator 50 48 indexer *issues_indexer.Indexer 51 49 } 52 50 ··· 60 58 db *db.DB, 61 59 config *config.Config, 62 60 notifier notify.Notifier, 63 - validator *validator.Validator, 64 61 indexer *issues_indexer.Indexer, 65 62 logger *slog.Logger, 66 63 ) *Issues { ··· 75 72 config: config, 76 73 notifier: notifier, 77 74 logger: logger, 78 - validator: validator, 79 75 indexer: indexer, 80 76 } 81 77 } ··· 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 ··· 1022 1018 Repo: f, 1023 1019 } 1024 1020 1025 - if err := rp.validator.ValidateIssue(issue); err != nil { 1021 + if err := issue.Validate(); err != nil { 1026 1022 l.Error("validation error", "err", err) 1027 1023 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 1028 1024 return
+29 -17
appview/labels/labels.go
··· 16 16 "tangled.org/core/appview/notify" 17 17 "tangled.org/core/appview/oauth" 18 18 "tangled.org/core/appview/pages" 19 - "tangled.org/core/appview/validator" 20 19 "tangled.org/core/orm" 21 20 "tangled.org/core/rbac" 22 21 "tangled.org/core/tid" ··· 29 28 ) 30 29 31 30 type Labels struct { 32 - oauth *oauth.OAuth 33 - pages *pages.Pages 34 - db *db.DB 35 - logger *slog.Logger 36 - validator *validator.Validator 37 - enforcer *rbac.Enforcer 38 - notifier notify.Notifier 31 + oauth *oauth.OAuth 32 + pages *pages.Pages 33 + db *db.DB 34 + logger *slog.Logger 35 + enforcer *rbac.Enforcer 36 + notifier notify.Notifier 39 37 } 40 38 41 39 func New( 42 40 oauth *oauth.OAuth, 43 41 pages *pages.Pages, 44 42 db *db.DB, 45 - validator *validator.Validator, 46 43 enforcer *rbac.Enforcer, 47 44 notifier notify.Notifier, 48 45 logger *slog.Logger, 49 46 ) *Labels { 50 47 return &Labels{ 51 - oauth: oauth, 52 - pages: pages, 53 - db: db, 54 - logger: logger, 55 - validator: validator, 56 - enforcer: enforcer, 57 - notifier: notifier, 48 + oauth: oauth, 49 + pages: pages, 50 + db: db, 51 + logger: logger, 52 + enforcer: enforcer, 53 + notifier: notifier, 58 54 } 59 55 } 60 56 ··· 167 163 168 164 for i := range labelOps { 169 165 def := actx.Defs[labelOps[i].OperandKey] 170 - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 166 + op := labelOps[i] 167 + 168 + // validate permissions: only collaborators can apply labels currently 169 + // 170 + // TODO: introduce a repo:triage permission 171 + ok, err := l.enforcer.IsPushAllowed(op.Did, repo.Knot, repo.DidSlashRepo()) 172 + if err != nil { 173 + fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) 174 + return 175 + } 176 + if !ok { 177 + fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) 178 + return 179 + } 180 + 181 + if err := def.ValidateOperandValue(&op); err != nil { 171 182 fail(fmt.Sprintf("Invalid form data: %s", err), err) 172 183 return 173 184 } 185 + labelOps[i] = op 174 186 } 175 187 176 188 // 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 { ··· 59 61 return "open" 60 62 } 61 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 62 84 } 63 85 64 86 type CommentListItem struct { ··· 215 237 216 238 func (i *IssueComment) IsReply() bool { 217 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 218 250 } 219 251 220 252 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+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) { ··· 203 366 204 367 // otherwise, createdat is in the future relative to indexedat -> use indexedat 205 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 206 385 } 207 386 208 387 type LabelOperation string
+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" ··· 33 35 Contents: s.Contents, 34 36 CreatedAt: s.Created.Format(time.RFC3339), 35 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 36 57 } 37 58 38 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
··· 31 31 "tangled.org/core/appview/models" 32 32 "tangled.org/core/appview/oauth" 33 33 "tangled.org/core/appview/pages/markup" 34 + "tangled.org/core/appview/pages/markup/sanitizer" 34 35 "tangled.org/core/crypto" 35 36 ) 36 37 ··· 264 265 "markdown": func(text string) template.HTML { 265 266 p.rctx.RendererType = markup.RendererTypeDefault 266 267 htmlString := p.rctx.RenderMarkdown(text) 267 - sanitized := p.rctx.SanitizeDefault(htmlString) 268 + sanitized := sanitizer.SanitizeDefault(htmlString) 268 269 return template.HTML(sanitized) 269 270 }, 270 271 "description": func(text string) template.HTML { ··· 274 275 emoji.Emoji, 275 276 ), 276 277 )) 277 - sanitized := p.rctx.SanitizeDescription(htmlString) 278 + sanitized := sanitizer.SanitizeDescription(htmlString) 278 279 return template.HTML(sanitized) 279 280 }, 280 281 "readme": func(text string) template.HTML { 281 282 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 282 283 htmlString := p.rctx.RenderMarkdown(text) 283 - sanitized := p.rctx.SanitizeDefault(htmlString) 284 + sanitized := sanitizer.SanitizeDefault(htmlString) 284 285 return template.HTML(sanitized) 285 286 }, 286 287 "code": func(content, path string) string {
-9
appview/pages/markup/markdown.go
··· 49 49 IsDev bool 50 50 Hostname string 51 51 RendererType RendererType 52 - Sanitizer Sanitizer 53 52 Files fs.FS 54 53 } 55 54 ··· 182 181 } 183 182 default: 184 183 } 185 - } 186 - 187 - func (rctx *RenderContext) SanitizeDefault(html string) string { 188 - return rctx.Sanitizer.SanitizeDefault(html) 189 - } 190 - 191 - func (rctx *RenderContext) SanitizeDescription(html string) string { 192 - return rctx.Sanitizer.SanitizeDescription(html) 193 184 } 194 185 195 186 type MarkdownTransformer struct {
+5 -17
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" ··· 21 21 sharedDescriptionPolicy = buildDescriptionPolicy() 22 22 } 23 23 24 - type Sanitizer struct { 25 - defaultPolicy *bluemonday.Policy 26 - descriptionPolicy *bluemonday.Policy 27 - } 28 - 29 - func NewSanitizer() Sanitizer { 30 - return Sanitizer{ 31 - defaultPolicy: sharedDefaultPolicy, 32 - descriptionPolicy: sharedDescriptionPolicy, 33 - } 34 - } 35 - 36 - func (s *Sanitizer) SanitizeDefault(html string) string { 37 - return s.defaultPolicy.Sanitize(html) 24 + func SanitizeDefault(html string) string { 25 + return sharedDefaultPolicy.Sanitize(html) 38 26 } 39 - func (s *Sanitizer) SanitizeDescription(html string) string { 40 - return s.descriptionPolicy.Sanitize(html) 27 + func SanitizeDescription(html string) string { 28 + return sharedDescriptionPolicy.Sanitize(html) 41 29 } 42 30 43 31 func buildDefaultPolicy() *bluemonday.Policy {
+5 -5
appview/pages/pages.go
··· 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/oauth" 25 25 "tangled.org/core/appview/pages/markup" 26 + "tangled.org/core/appview/pages/markup/sanitizer" 26 27 "tangled.org/core/appview/pages/repoinfo" 27 28 "tangled.org/core/appview/pagination" 28 29 "tangled.org/core/idresolver" ··· 58 59 Hostname: config.Core.AppviewHost, 59 60 CamoUrl: config.Camo.Host, 60 61 CamoSecret: config.Camo.SharedSecret, 61 - Sanitizer: markup.NewSanitizer(), 62 62 Files: Files, 63 63 } 64 64 ··· 293 293 294 294 p.rctx.RendererType = markup.RendererTypeDefault 295 295 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 296 - sanitized := p.rctx.SanitizeDefault(htmlString) 296 + sanitized := sanitizer.SanitizeDefault(htmlString) 297 297 params.Content = template.HTML(sanitized) 298 298 299 299 return p.execute("legal/terms", w, params) ··· 321 321 322 322 p.rctx.RendererType = markup.RendererTypeDefault 323 323 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 324 - sanitized := p.rctx.SanitizeDefault(htmlString) 324 + sanitized := sanitizer.SanitizeDefault(htmlString) 325 325 params.Content = template.HTML(sanitized) 326 326 327 327 return p.execute("legal/privacy", w, params) ··· 744 744 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 745 745 params.Raw = false 746 746 htmlString := p.rctx.RenderMarkdown(params.Readme) 747 - sanitized := p.rctx.SanitizeDefault(htmlString) 747 + sanitized := sanitizer.SanitizeDefault(htmlString) 748 748 params.HTMLReadme = template.HTML(sanitized) 749 749 default: 750 750 params.Raw = true ··· 837 837 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 838 838 params.Raw = false 839 839 htmlString := p.rctx.RenderMarkdown(params.Readme) 840 - sanitized := p.rctx.SanitizeDefault(htmlString) 840 + sanitized := sanitizer.SanitizeDefault(htmlString) 841 841 params.HTMLReadme = template.HTML(sanitized) 842 842 default: 843 843 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 34 "tangled.org/core/appview/searchquery" 35 - "tangled.org/core/appview/validator" 36 35 "tangled.org/core/appview/xrpcclient" 37 36 "tangled.org/core/idresolver" 38 37 "tangled.org/core/orm" ··· 63 62 notifier notify.Notifier 64 63 enforcer *rbac.Enforcer 65 64 logger *slog.Logger 66 - validator *validator.Validator 67 65 indexer *pulls_indexer.Indexer 68 66 } 69 67 ··· 77 75 config *config.Config, 78 76 notifier notify.Notifier, 79 77 enforcer *rbac.Enforcer, 80 - validator *validator.Validator, 81 78 indexer *pulls_indexer.Indexer, 82 79 logger *slog.Logger, 83 80 ) *Pulls { ··· 92 89 notifier: notifier, 93 90 enforcer: enforcer, 94 91 logger: logger, 95 - validator: validator, 96 92 indexer: indexer, 97 93 } 98 94 } ··· 975 971 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 976 972 return 977 973 } 978 - sanitizer := markup.NewSanitizer() 979 974 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 980 975 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 981 976 return ··· 1103 1098 patch := comparison.FormatPatchRaw 1104 1099 combined := comparison.CombinedPatchRaw 1105 1100 1106 - if err := s.validator.ValidatePatch(&patch); err != nil { 1101 + if err := validatePatch(&patch); err != nil { 1107 1102 s.logger.Error("failed to validate patch", "err", err) 1108 1103 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1109 1104 return ··· 1121 1116 } 1122 1117 1123 1118 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1124 - if err := s.validator.ValidatePatch(&patch); err != nil { 1119 + if err := validatePatch(&patch); err != nil { 1125 1120 s.logger.Error("patch validation failed", "err", err) 1126 1121 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1127 1122 return ··· 1213 1208 patch := comparison.FormatPatchRaw 1214 1209 combined := comparison.CombinedPatchRaw 1215 1210 1216 - if err := s.validator.ValidatePatch(&patch); err != nil { 1211 + if err := validatePatch(&patch); err != nil { 1217 1212 s.logger.Error("failed to validate patch", "err", err) 1218 1213 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1219 1214 return ··· 1506 1501 return 1507 1502 } 1508 1503 1509 - if err := s.validator.ValidatePatch(&patch); err != nil { 1504 + if err := validatePatch(&patch); err != nil { 1510 1505 s.logger.Error("faield to validate patch", "err", err) 1511 1506 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1512 1507 return ··· 1927 1922 return 1928 1923 } 1929 1924 1930 - if err := s.validator.ValidatePatch(&patch); err != nil { 1925 + if err := validatePatch(&patch); err != nil { 1931 1926 s.pages.Notice(w, "resubmit-error", err.Error()) 1932 1927 return 1933 1928 } ··· 2559 2554 } 2560 2555 2561 2556 func ptrPullState(s models.PullState) *models.PullState { return &s } 2557 + 2558 + func validatePatch(patch *string) error { 2559 + if patch == nil || *patch == "" { 2560 + return fmt.Errorf("patch is empty") 2561 + } 2562 + 2563 + // add newline if not present to diff style patches 2564 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 2565 + *patch = *patch + "\n" 2566 + } 2567 + 2568 + if err := patchutil.IsPatchValid(*patch); err != nil { 2569 + return err 2570 + } 2571 + 2572 + return nil 2573 + }
+1 -5
appview/repo/repo.go
··· 22 22 "tangled.org/core/appview/oauth" 23 23 "tangled.org/core/appview/pages" 24 24 "tangled.org/core/appview/reporesolver" 25 - "tangled.org/core/appview/validator" 26 25 xrpcclient "tangled.org/core/appview/xrpcclient" 27 26 "tangled.org/core/eventconsumer" 28 27 "tangled.org/core/idresolver" ··· 51 50 notifier notify.Notifier 52 51 logger *slog.Logger 53 52 serviceAuth *serviceauth.ServiceAuth 54 - validator *validator.Validator 55 53 cfClient *cloudflare.Client 56 54 } 57 55 ··· 66 64 notifier notify.Notifier, 67 65 enforcer *rbac.Enforcer, 68 66 logger *slog.Logger, 69 - validator *validator.Validator, 70 67 cfClient *cloudflare.Client, 71 68 ) *Repo { 72 69 return &Repo{ ··· 80 77 notifier: notifier, 81 78 enforcer: enforcer, 82 79 logger: logger, 83 - validator: validator, 84 80 cfClient: cfClient, 85 81 } 86 82 } ··· 231 227 Multiple: multiple, 232 228 Created: time.Now(), 233 229 } 234 - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 230 + if err := label.Validate(); err != nil { 235 231 fail(err.Error(), err) 236 232 return 237 233 }
+66 -6
appview/repo/settings.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "net/http" 8 + "net/url" 8 9 "path" 10 + "regexp" 9 11 "slices" 10 12 "strings" 11 13 "time" ··· 19 21 "tangled.org/core/appview/sites" 20 22 xrpcclient "tangled.org/core/appview/xrpcclient" 21 23 "tangled.org/core/orm" 24 + "tangled.org/core/sets" 22 25 "tangled.org/core/types" 23 26 24 27 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 591 594 topicStr = r.FormValue("topics") 592 595 ) 593 596 594 - err = rp.validator.ValidateURI(website) 595 - if website != "" && err != nil { 596 - l.Error("invalid uri", "err", err) 597 - rp.pages.Notice(w, noticeId, err.Error()) 598 - return 597 + if website != "" { 598 + if err := validateURI(website); err != nil { 599 + l.Error("invalid uri", "err", err) 600 + rp.pages.Notice(w, noticeId, err.Error()) 601 + return 602 + } 599 603 } 600 604 601 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 605 + topics, err := parseRepoTopicStr(topicStr) 602 606 if err != nil { 603 607 l.Error("invalid topics", "err", err) 604 608 rp.pages.Notice(w, noticeId, err.Error()) ··· 658 662 659 663 rp.pages.HxRefresh(w) 660 664 } 665 + 666 + const ( 667 + maxTopicLen = 50 668 + maxTopics = 20 669 + ) 670 + 671 + var ( 672 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 673 + ) 674 + 675 + // parseRepoTopicStr parses and validates whitespace-separated topic string. 676 + // 677 + // Rules: 678 + // - topics are separated by whitespace 679 + // - each topic may contain lowercase letters, digits, and hyphens only 680 + // - each topic must be <= 50 characters long 681 + // - no more than 20 topics allowed 682 + // - duplicates are removed 683 + func parseRepoTopicStr(topicStr string) ([]string, error) { 684 + topicStr = strings.TrimSpace(topicStr) 685 + if topicStr == "" { 686 + return nil, nil 687 + } 688 + parts := strings.Fields(topicStr) 689 + if len(parts) > maxTopics { 690 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 691 + } 692 + 693 + topicSet := sets.New[string]() 694 + 695 + for _, t := range parts { 696 + if topicSet.Contains(t) { 697 + continue 698 + } 699 + if len(t) > maxTopicLen { 700 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 701 + } 702 + if !topicRE.MatchString(t) { 703 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 704 + } 705 + topicSet.Insert(t) 706 + } 707 + return slices.Collect(topicSet.All()), nil 708 + } 709 + 710 + // TODO(boltless): move this to models.Repo instead 711 + func validateURI(uri string) error { 712 + parsed, err := url.Parse(uri) 713 + if err != nil { 714 + return fmt.Errorf("invalid uri format") 715 + } 716 + if parsed.Scheme == "" { 717 + return fmt.Errorf("uri scheme missing") 718 + } 719 + return nil 720 + }
-4
appview/state/router.go
··· 280 280 s.db, 281 281 s.config, 282 282 s.notifier, 283 - s.validator, 284 283 s.indexer.Issues, 285 284 log.SubLogger(s.logger, "issues"), 286 285 ) ··· 298 297 s.config, 299 298 s.notifier, 300 299 s.enforcer, 301 - s.validator, 302 300 s.indexer.Pulls, 303 301 log.SubLogger(s.logger, "pulls"), 304 302 ) ··· 317 315 s.notifier, 318 316 s.enforcer, 319 317 log.SubLogger(s.logger, "repo"), 320 - s.validator, 321 318 s.cfClient, 322 319 ) 323 320 return repo.Router(mw) ··· 343 340 s.oauth, 344 341 s.pages, 345 342 s.db, 346 - s.validator, 347 343 s.enforcer, 348 344 s.notifier, 349 345 log.SubLogger(s.logger, "labels"),
-5
appview/state/state.go
··· 25 25 "tangled.org/core/appview/oauth" 26 26 "tangled.org/core/appview/pages" 27 27 "tangled.org/core/appview/reporesolver" 28 - "tangled.org/core/appview/validator" 29 28 xrpcclient "tangled.org/core/appview/xrpcclient" 30 29 "tangled.org/core/consts" 31 30 "tangled.org/core/eventconsumer" ··· 63 62 knotstream *eventconsumer.Consumer 64 63 spindlestream *eventconsumer.Consumer 65 64 logger *slog.Logger 66 - validator *validator.Validator 67 65 cfClient *cloudflare.Client 68 66 } 69 67 ··· 102 100 if err != nil { 103 101 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 104 102 } 105 - validator := validator.New(d, res, enforcer) 106 103 107 104 repoResolver := reporesolver.New(config, enforcer, d) 108 105 ··· 150 147 IdResolver: res, 151 148 Config: config, 152 149 Logger: log.SubLogger(logger, "ingester"), 153 - Validator: validator, 154 150 } 155 151 err = jc.StartJetstream(ctx, ingester.Ingest()) 156 152 if err != nil { ··· 211 207 knotstream: knotstream, 212 208 spindlestream: spindlestream, 213 209 logger: logger, 214 - validator: validator, 215 210 cfClient: cfClient, 216 211 } 217 212
-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 - }