Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

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>

author
Seongmin Lee
date (Jun 25, 2026, 2:51 AM +0900) commit 4fdeaa00 parent 9b3e1670 change-id olmnmpwv
+499 -676
+38 -10
appview/ingester.go
··· 33 33 "tangled.org/core/appview/notify" 34 34 "tangled.org/core/appview/repoverify" 35 35 "tangled.org/core/appview/serververify" 36 - "tangled.org/core/appview/validator" 37 36 "tangled.org/core/idresolver" 38 37 "tangled.org/core/orm" 39 38 "tangled.org/core/rbac" ··· 48 47 Cache *cache.Cache 49 48 Config *config.Config 50 49 Logger *slog.Logger 51 - Validator *validator.Validator 52 50 MentionsResolver *mentions.Resolver 53 51 Notifier notify.Notifier 54 52 Verifier repoverify.Verifier ··· 932 930 933 931 string := models.StringFromRecord(did, rkey, record) 934 932 935 - if err = i.Validator.ValidateString(&string); err != nil { 933 + if err = string.Validate(); err != nil { 936 934 l.Error("invalid record", "err", err) 937 935 return err 938 936 } ··· 1363 1361 return fmt.Errorf("issue record repo field is not a valid DID: %w", err) 1364 1362 } 1365 1363 1366 - if err := i.Validator.ValidateIssue(&issue); err != nil { 1364 + if err := issue.Validate(); err != nil { 1367 1365 return fmt.Errorf("failed to validate issue: %w", err) 1368 1366 } 1369 1367 ··· 1512 1510 if err != nil { 1513 1511 return fmt.Errorf("failed to parse pull from record: %w", err) 1514 1512 } 1515 - if err := i.Validator.ValidatePull(pull); err != nil { 1513 + if err := pull.Validate(); err != nil { 1516 1514 return fmt.Errorf("failed to validate pull: %w", err) 1517 1515 } 1516 + if pull.DependentOn != nil { 1517 + if err := func() error { 1518 + dependentPull, err := db.GetPull( 1519 + i.Db, 1520 + orm.FilterEq("dependent_on", pull.DependentOn.String()), 1521 + ) 1522 + if errors.Is(err, sql.ErrNoRows) { 1523 + return nil 1524 + } 1525 + if err != nil { 1526 + return fmt.Errorf("failed to fetch pulls with same dependency: %w", err) 1527 + } 1528 + if dependentPull.AtUri() == pull.AtUri() { 1529 + return nil 1530 + } 1531 + return fmt.Errorf("another pull already depends on %s, which would form a DAG, this is presently disallowed", pull.DependentOn.String()) 1532 + }(); err != nil { 1533 + return fmt.Errorf("failed to validate pull stack: %w", err) 1534 + } 1535 + } 1518 1536 1519 1537 tx, err := i.Db.BeginTx(ctx, nil) 1520 1538 if err != nil { ··· 1755 1773 return fmt.Errorf("failed to parse labeldef from record: %w", err) 1756 1774 } 1757 1775 1758 - if err := i.Validator.ValidateLabelDefinition(def); err != nil { 1776 + if err := def.Validate(); err != nil { 1759 1777 return fmt.Errorf("failed to validate labeldef: %w", err) 1760 1778 } 1761 1779 ··· 1833 1851 if !ok { 1834 1852 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1835 1853 } 1836 - if err := i.Validator.ValidateLabelOp(ctx, def, repo, &o); err != nil { 1837 - if !errors.Is(err, knotacl.ErrKnotUnreachable) { 1838 - return fmt.Errorf("failed to validate labelop: %w", err) 1854 + // validate permissions: only collaborators can apply labels currently 1855 + // 1856 + // TODO: introduce a repo:triage permission 1857 + allowed, permErr := i.Acl.HasRepoPermissionErr(ctx, repo, o.Did, "repo:push") 1858 + if permErr != nil { 1859 + if !errors.Is(permErr, knotacl.ErrKnotUnreachable) { 1860 + return fmt.Errorf("enforcing permission: %w", permErr) 1839 1861 } 1840 - l.Warn("ingesting labelop without permission check", "did", o.Did, "err", err) 1862 + l.Warn("ingesting labelop without permission check", "did", o.Did, "err", permErr) 1863 + } else if !allowed { 1864 + return fmt.Errorf("unauthorized label operation") 1865 + } 1866 + 1867 + if err := def.ValidateOperandValue(&o); err != nil { 1868 + return fmt.Errorf("failed to validate labelop: %w", err) 1841 1869 } 1842 1870 } 1843 1871
+2 -4
appview/ingester_string_test.go
··· 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" 15 - "tangled.org/core/appview/validator" 16 15 "tangled.org/core/orm" 17 16 ) 18 17 ··· 25 24 } 26 25 t.Cleanup(func() { d.Close() }) 27 26 return &Ingester{ 28 - Db: d, 29 - Logger: slog.New(slog.DiscardHandler), 30 - Validator: &validator.Validator{}, 27 + Db: d, 28 + Logger: slog.New(slog.DiscardHandler), 31 29 } 32 30 } 33 31
+2 -6
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/ogre" 34 33 "tangled.org/core/orm" ··· 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 ogreClient *ogre.Client 52 50 } ··· 61 59 db *db.DB, 62 60 config *config.Config, 63 61 notifier notify.Notifier, 64 - validator *validator.Validator, 65 62 indexer *issues_indexer.Indexer, 66 63 logger *slog.Logger, 67 64 ) *Issues { ··· 76 73 config: config, 77 74 notifier: notifier, 78 75 logger: logger, 79 - validator: validator, 80 76 indexer: indexer, 81 77 ogreClient: ogre.NewClient(config.Ogre.Host), 82 78 } ··· 206 202 newIssue.Body = r.FormValue("body") 207 203 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 208 204 209 - if err := rp.validator.ValidateIssue(newIssue); err != nil { 205 + if err := newIssue.Validate(); err != nil { 210 206 l.Error("validation error", "err", err) 211 207 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 212 208 return ··· 680 676 Repo: f, 681 677 } 682 678 683 - if err := rp.validator.ValidateIssue(issue); err != nil { 679 + if err := issue.Validate(); err != nil { 684 680 l.Error("validation error", "err", err) 685 681 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 686 682 return
+47 -19
appview/labels/labels.go
··· 11 11 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/knotacl" 14 15 "tangled.org/core/appview/middleware" 15 16 "tangled.org/core/appview/models" 16 17 "tangled.org/core/appview/notify" 17 18 "tangled.org/core/appview/oauth" 18 19 "tangled.org/core/appview/pages" 19 - "tangled.org/core/appview/validator" 20 20 "tangled.org/core/orm" 21 - "tangled.org/core/rbac" 22 21 "tangled.org/core/tid" 23 22 24 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 24 "github.com/bluesky-social/indigo/atproto/atclient" 25 + "github.com/bluesky-social/indigo/atproto/identity" 26 26 "github.com/bluesky-social/indigo/atproto/syntax" 27 27 lexutil "github.com/bluesky-social/indigo/lex/util" 28 28 "github.com/go-chi/chi/v5" 29 29 ) 30 30 31 31 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 32 + oauth *oauth.OAuth 33 + pages *pages.Pages 34 + db *db.DB 35 + dir identity.Directory 36 + logger *slog.Logger 37 + acl *knotacl.Service 38 + notifier notify.Notifier 39 39 } 40 40 41 41 func New( 42 42 oauth *oauth.OAuth, 43 43 pages *pages.Pages, 44 44 db *db.DB, 45 - validator *validator.Validator, 46 - enforcer *rbac.Enforcer, 45 + dir identity.Directory, 46 + acl *knotacl.Service, 47 47 notifier notify.Notifier, 48 48 logger *slog.Logger, 49 49 ) *Labels { 50 50 return &Labels{ 51 - oauth: oauth, 52 - pages: pages, 53 - db: db, 54 - logger: logger, 55 - validator: validator, 56 - enforcer: enforcer, 57 - notifier: notifier, 51 + oauth: oauth, 52 + pages: pages, 53 + db: db, 54 + dir: dir, 55 + logger: logger, 56 + acl: acl, 57 + notifier: notifier, 58 58 } 59 59 } 60 60 ··· 167 167 168 168 for i := range labelOps { 169 169 def := actx.Defs[labelOps[i].OperandKey] 170 - if err := l.validator.ValidateLabelOp(r.Context(), def, repo, &labelOps[i]); err != nil { 170 + op := labelOps[i] 171 + 172 + // validate permissions: only collaborators can apply labels currently 173 + // 174 + // TODO: introduce a repo:triage permission 175 + ok, err := l.acl.HasRepoPermissionErr(r.Context(), repo, op.Did, "repo:push") 176 + if err != nil { 177 + fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) 178 + return 179 + } 180 + if !ok { 181 + fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) 182 + return 183 + } 184 + 185 + // resolve Handle to DID 186 + if def.ValueType.IsString() && def.ValueType.IsDidFormat() { 187 + val := syntax.AtIdentifier(op.OperandValue) 188 + if val.IsHandle() { 189 + ident, err := l.dir.Lookup(r.Context(), val) 190 + if err != nil { 191 + fail(fmt.Sprintf("Failed to resolve handle %q: %s", val, err), err) 192 + } 193 + op.OperandValue = ident.DID.String() 194 + } 195 + } 196 + 197 + if err := def.ValidateOperandValue(&op); err != nil { 171 198 fail(fmt.Sprintf("Invalid form data: %s", err), err) 172 199 return 173 200 } 201 + labelOps[i] = op 174 202 } 175 203 176 204 // reduce the opset
+22
appview/models/issue.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup/sanitizer" 9 11 ) 10 12 11 13 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 func (i *Issue) Participants() []syntax.DID {
+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
+34
appview/models/pull.go
··· 11 11 "time" 12 12 13 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/pages/markup/sanitizer" 14 15 "tangled.org/core/patchutil" 15 16 "tangled.org/core/types" 16 17 ··· 122 123 Source: p.PullSource.AsRecord(), 123 124 DependentOn: dependentOn, 124 125 } 126 + } 127 + 128 + func (pull *Pull) Validate() error { 129 + if len(pull.Submissions) == 0 { 130 + return fmt.Errorf("pull must have at least one submission") 131 + } 132 + 133 + latestSubmission := pull.LatestSubmission() 134 + if latestSubmission == nil { 135 + return fmt.Errorf("pull must have a valid latest submission") 136 + } 137 + 138 + isFormatPatch := patchutil.IsFormatPatch(latestSubmission.Patch) 139 + 140 + // title and body can only be empty if the patch is a format-patch 141 + if !isFormatPatch { 142 + if pull.Title == "" { 143 + return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 144 + } 145 + 146 + if pull.Body == "" { 147 + return fmt.Errorf("pull body is empty (required for non-format-patch pulls)") 148 + } 149 + 150 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(pull.Title)); st == "" { 151 + return fmt.Errorf("title is empty after HTML sanitization") 152 + } 153 + 154 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(pull.Body)); sb == "" { 155 + return fmt.Errorf("body is empty after HTML sanitization") 156 + } 157 + } 158 + return nil 125 159 } 126 160 127 161 func PullFromRecord(did, rkey string, record tangled.RepoPull, blobs []*io.ReadCloser) (*Pull, error) {
+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
··· 34 34 "tangled.org/core/appview/models" 35 35 "tangled.org/core/appview/oauth" 36 36 "tangled.org/core/appview/pages/markup" 37 + "tangled.org/core/appview/pages/markup/sanitizer" 37 38 "tangled.org/core/crypto" 38 39 "tangled.org/core/idresolver" 39 40 ) ··· 313 314 rctx := p.rctx.Clone() 314 315 rctx.RendererType = markup.RendererTypeDefault 315 316 htmlString := rctx.RenderMarkdown(text) 316 - sanitized := rctx.SanitizeDefault(htmlString) 317 + sanitized := sanitizer.SanitizeDefault(htmlString) 317 318 return template.HTML(sanitized) 318 319 }, 319 320 "description": func(text string) template.HTML { ··· 324 325 emoji.Emoji, 325 326 ), 326 327 )) 327 - sanitized := rctx.SanitizeDescription(htmlString) 328 + sanitized := sanitizer.SanitizeDescription(htmlString) 328 329 return template.HTML(sanitized) 329 330 }, 330 331 "readme": func(text string) template.HTML { 331 332 rctx := p.rctx.Clone() 332 333 rctx.RendererType = markup.RendererTypeRepoMarkdown 333 334 htmlString := rctx.RenderMarkdown(text) 334 - sanitized := rctx.SanitizeDefault(htmlString) 335 + sanitized := sanitizer.SanitizeDefault(htmlString) 335 336 return template.HTML(sanitized) 336 337 }, 337 338 "code": func(content, path string) string {
-9
appview/pages/markup/markdown.go
··· 48 48 IsDev bool 49 49 Hostname string 50 50 RendererType RendererType 51 - Sanitizer Sanitizer 52 51 Files fs.FS 53 52 } 54 53 ··· 203 202 } 204 203 default: 205 204 } 206 - } 207 - 208 - func (rctx *RenderContext) SanitizeDefault(html string) string { 209 - return rctx.Sanitizer.SanitizeDefault(html) 210 - } 211 - 212 - func (rctx *RenderContext) SanitizeDescription(html string) string { 213 - return rctx.Sanitizer.SanitizeDescription(html) 214 205 } 215 206 216 207 type MarkdownTransformer struct {
+7 -22
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" ··· 23 23 sharedLogsPolicy = buildLogsPolicy() 24 24 } 25 25 26 - type Sanitizer struct { 27 - defaultPolicy *bluemonday.Policy 28 - descriptionPolicy *bluemonday.Policy 29 - logsPolicy *bluemonday.Policy 26 + func SanitizeDefault(html string) string { 27 + return sharedDefaultPolicy.Sanitize(html) 30 28 } 31 - 32 - func NewSanitizer() Sanitizer { 33 - return Sanitizer{ 34 - defaultPolicy: sharedDefaultPolicy, 35 - descriptionPolicy: sharedDescriptionPolicy, 36 - logsPolicy: sharedLogsPolicy, 37 - } 29 + func SanitizeDescription(html string) string { 30 + return sharedDescriptionPolicy.Sanitize(html) 38 31 } 39 - 40 - func (s *Sanitizer) SanitizeDefault(html string) string { 41 - return s.defaultPolicy.Sanitize(html) 42 - } 43 - func (s *Sanitizer) SanitizeDescription(html string) string { 44 - return s.descriptionPolicy.Sanitize(html) 45 - } 46 - func (s *Sanitizer) SanitizeLogs(html string) string { 47 - return s.logsPolicy.Sanitize(html) 32 + func SanitizeLogs(html string) string { 33 + return sharedLogsPolicy.Sanitize(html) 48 34 } 49 35 50 36 func buildDefaultPolicy() *bluemonday.Policy { ··· 78 64 "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 79 65 "details", "caption", "figure", "figcaption", 80 66 "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 81 - "picture", "source", 82 67 } 83 68 84 69 policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
+5 -5
appview/pages/pages.go
··· 25 25 "tangled.org/core/appview/models" 26 26 "tangled.org/core/appview/oauth" 27 27 "tangled.org/core/appview/pages/markup" 28 + "tangled.org/core/appview/pages/markup/sanitizer" 28 29 "tangled.org/core/appview/pages/repoinfo" 29 30 "tangled.org/core/appview/pagination" 30 31 "tangled.org/core/idresolver" ··· 89 90 Hostname: config.Core.AppviewHost, 90 91 CamoUrl: config.Camo.Host, 91 92 CamoSecret: config.Camo.SharedSecret, 92 - Sanitizer: markup.NewSanitizer(), 93 93 Files: Files, 94 94 } 95 95 ··· 345 345 rctx := p.rctx.Clone() 346 346 rctx.RendererType = markup.RendererTypeDefault 347 347 htmlString := rctx.RenderMarkdown(string(markdownBytes)) 348 - sanitized := rctx.SanitizeDefault(htmlString) 348 + sanitized := sanitizer.SanitizeDefault(htmlString) 349 349 params.Content = template.HTML(sanitized) 350 350 351 351 return p.execute("legal/terms", w, params) ··· 374 374 rctx := p.rctx.Clone() 375 375 rctx.RendererType = markup.RendererTypeDefault 376 376 htmlString := rctx.RenderMarkdown(string(markdownBytes)) 377 - sanitized := rctx.SanitizeDefault(htmlString) 377 + sanitized := sanitizer.SanitizeDefault(htmlString) 378 378 params.Content = template.HTML(sanitized) 379 379 380 380 return p.execute("legal/privacy", w, params) ··· 919 919 case markup.FormatMarkdown: 920 920 params.Raw = false 921 921 htmlString := rctx.RenderMarkdown(params.Readme) 922 - sanitized := rctx.SanitizeDefault(htmlString) 922 + sanitized := sanitizer.SanitizeDefault(htmlString) 923 923 params.HTMLReadme = template.HTML(sanitized) 924 924 default: 925 925 params.Raw = true ··· 1017 1017 case markup.FormatMarkdown: 1018 1018 params.Raw = false 1019 1019 htmlString := rctx.RenderMarkdown(params.Readme) 1020 - sanitized := rctx.SanitizeDefault(htmlString) 1020 + sanitized := sanitizer.SanitizeDefault(htmlString) 1021 1021 params.HTMLReadme = template.HTML(sanitized) 1022 1022 default: 1023 1023 params.Raw = true
+4 -6
appview/pipelines/logs.go
··· 8 8 9 9 terminal "github.com/buildkite/terminal-to-html/v3" 10 10 "github.com/gorilla/websocket" 11 - "tangled.org/core/appview/pages/markup" 11 + "tangled.org/core/appview/pages/markup/sanitizer" 12 12 "tangled.org/core/hostutil" 13 13 ) 14 14 ··· 20 20 // 21 21 // the stack contents are prepended to each new line so colours carry over. 22 22 type ansiState struct { 23 - stack []string 24 - sanitizer markup.Sanitizer 23 + stack []string 25 24 } 26 25 27 26 func NewAnsiState() *ansiState { 28 27 return &ansiState{ 29 - stack: []string{}, 30 - sanitizer: markup.NewSanitizer(), 28 + stack: []string{}, 31 29 } 32 30 } 33 31 ··· 37 35 // render current line with the existing prefix 38 36 rendered := terminal.Render([]byte(prefix + line)) 39 37 // sanitize 40 - sanitized := a.sanitizer.SanitizeLogs(rendered) 38 + sanitized := sanitizer.SanitizeLogs(rendered) 41 39 42 40 // update the stack with sequences from current line 43 41 for _, m := range sequenceRe.FindAllStringSubmatch(line, -1) {
+2 -3
appview/pulls/compose.go
··· 17 17 "tangled.org/core/appview/models" 18 18 "tangled.org/core/appview/oauth" 19 19 "tangled.org/core/appview/pages" 20 - "tangled.org/core/appview/pages/markup" 20 + "tangled.org/core/appview/pages/markup/sanitizer" 21 21 "tangled.org/core/appview/xrpcclient" 22 22 "tangled.org/core/patchutil" 23 23 "tangled.org/core/types" ··· 78 78 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 79 79 return 80 80 } 81 - sanitizer := markup.NewSanitizer() 82 81 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 83 82 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 84 83 return ··· 426 425 if strings.TrimSpace(patch) == "" { 427 426 return nil, nil, nil 428 427 } 429 - if verr := s.validator.ValidatePatch(&patch); verr != nil { 428 + if verr := validatePatch(&patch); verr != nil { 430 429 return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 431 430 } 432 431 comparison = parsePastedPatch(patch)
+3 -7
appview/pulls/compose_helpers_test.go
··· 12 12 "tangled.org/core/appview/models" 13 13 "tangled.org/core/appview/pages" 14 14 "tangled.org/core/appview/pages/repoinfo" 15 - "tangled.org/core/appview/validator" 16 15 "tangled.org/core/patchutil" 17 16 "tangled.org/core/types" 18 17 ) ··· 371 370 372 371 func TestPrefetchComparisonPatch(t *testing.T) { 373 372 s := &Pulls{ 374 - validator: &validator.Validator{}, 375 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 373 + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 376 374 } 377 375 378 376 cases := []struct { ··· 408 406 409 407 func TestPrefetchComparisonValidPatch(t *testing.T) { 410 408 s := &Pulls{ 411 - validator: &validator.Validator{}, 412 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 409 + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 413 410 } 414 411 patch := `diff --git a/a.txt b/a.txt 415 412 index 0000000..1111111 100644 ··· 435 432 436 433 func TestPrefetchComparisonMissingInputs(t *testing.T) { 437 434 s := &Pulls{ 438 - validator: &validator.Validator{}, 439 - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 435 + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 440 436 } 441 437 442 438 cases := []struct {
+3 -3
appview/pulls/create.go
··· 71 71 patch := comparison.FormatPatchRaw 72 72 combined := comparison.CombinedPatchRaw 73 73 74 - if err := s.validator.ValidatePatch(&patch); err != nil { 74 + if err := validatePatch(&patch); err != nil { 75 75 s.logger.Error("failed to validate patch", "err", err) 76 76 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 77 77 return ··· 85 85 } 86 86 87 87 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 88 - if err := s.validator.ValidatePatch(&patch); err != nil { 88 + if err := validatePatch(&patch); err != nil { 89 89 s.logger.Error("patch validation failed", "err", err) 90 90 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 91 91 return ··· 178 178 patch := comparison.FormatPatchRaw 179 179 combined := comparison.CombinedPatchRaw 180 180 181 - if err := s.validator.ValidatePatch(&patch); err != nil { 181 + if err := validatePatch(&patch); err != nil { 182 182 s.logger.Error("failed to validate patch", "err", err) 183 183 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 184 184 return
+27 -1
appview/pulls/labels.go
··· 140 140 valid := make([]models.LabelOp, 0, len(raw)) 141 141 for _, op := range raw { 142 142 def := defs[op.OperandKey] 143 - if err := s.validator.ValidateLabelOp(ctx, def, repo, &op); err != nil { 143 + 144 + // validate permissions: only collaborators can apply labels currently 145 + // 146 + // TODO: introduce a repo:triage permission 147 + ok, err := s.acl.HasRepoPermissionErr(ctx, repo, op.Did, "repo:push") 148 + if err != nil { 149 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 150 + continue 151 + } 152 + if !ok { 153 + l.Warn("forbidden label op", "subject", op.Subject, "key", op.OperandKey) 154 + continue 155 + } 156 + 157 + // resolve Handle to DID 158 + if def.ValueType.IsString() && def.ValueType.IsDidFormat() { 159 + val := syntax.AtIdentifier(op.OperandValue) 160 + if val.IsHandle() { 161 + ident, err := s.idResolver.Directory().Lookup(ctx, val) 162 + if err != nil { 163 + l.Warn("failed to resolve handle", "err", err, "subject", op.Subject, "key", op.OperandKey) 164 + } 165 + op.OperandValue = ident.DID.String() 166 + } 167 + } 168 + 169 + if err := def.ValidateOperandValue(&op); err != nil { 144 170 l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 145 171 continue 146 172 }
+19 -4
appview/pulls/pulls.go
··· 6 6 "fmt" 7 7 "io" 8 8 "log/slog" 9 + "strings" 9 10 10 11 "tangled.org/core/appview/config" 11 12 "tangled.org/core/appview/db" ··· 17 18 "tangled.org/core/appview/oauth" 18 19 "tangled.org/core/appview/pages" 19 20 "tangled.org/core/appview/reporesolver" 20 - "tangled.org/core/appview/validator" 21 21 "tangled.org/core/idresolver" 22 22 "tangled.org/core/ogre" 23 + "tangled.org/core/patchutil" 23 24 24 25 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 26 ) ··· 37 38 notifier notify.Notifier 38 39 acl *knotacl.Service 39 40 logger *slog.Logger 40 - validator *validator.Validator 41 41 indexer *pulls_indexer.Indexer 42 42 ogreClient *ogre.Client 43 43 } ··· 52 52 config *config.Config, 53 53 notifier notify.Notifier, 54 54 acl *knotacl.Service, 55 - validator *validator.Validator, 56 55 indexer *pulls_indexer.Indexer, 57 56 logger *slog.Logger, 58 57 ) *Pulls { ··· 67 66 notifier: notifier, 68 67 acl: acl, 69 68 logger: logger, 70 - validator: validator, 71 69 indexer: indexer, 72 70 ogreClient: ogre.NewClient(config.Ogre.Host), 73 71 } ··· 90 88 } 91 89 92 90 func ptrPullState(s models.PullState) *models.PullState { return &s } 91 + 92 + func validatePatch(patch *string) error { 93 + if patch == nil || *patch == "" { 94 + return fmt.Errorf("patch is empty") 95 + } 96 + 97 + // add newline if not present to diff style patches 98 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 99 + *patch = *patch + "\n" 100 + } 101 + 102 + if err := patchutil.IsPatchValid(*patch); err != nil { 103 + return err 104 + } 105 + 106 + return nil 107 + }
+1 -1
appview/pulls/resubmit.go
··· 274 274 return 275 275 } 276 276 277 - if err := s.validator.ValidatePatch(&patch); err != nil { 277 + if err := validatePatch(&patch); err != nil { 278 278 s.pages.Notice(w, "resubmit-error", err.Error()) 279 279 return 280 280 }
+1 -5
appview/repo/repo.go
··· 26 26 "tangled.org/core/appview/pagination" 27 27 "tangled.org/core/appview/reporesolver" 28 28 "tangled.org/core/appview/sites" 29 - "tangled.org/core/appview/validator" 30 29 xrpcclient "tangled.org/core/appview/xrpcclient" 31 30 "tangled.org/core/consts" 32 31 "tangled.org/core/eventconsumer" ··· 58 57 notifier notify.Notifier 59 58 logger *slog.Logger 60 59 serviceAuth *serviceauth.ServiceAuth 61 - validator *validator.Validator 62 60 cfClient *cloudflare.Client 63 61 ogreClient *ogre.Client 64 62 } ··· 75 73 enforcer *rbac.Enforcer, 76 74 acl *knotacl.Service, 77 75 logger *slog.Logger, 78 - validator *validator.Validator, 79 76 cfClient *cloudflare.Client, 80 77 ) *Repo { 81 78 return &Repo{ ··· 90 87 enforcer: enforcer, 91 88 acl: acl, 92 89 logger: logger, 93 - validator: validator, 94 90 cfClient: cfClient, 95 91 ogreClient: ogre.NewClient(config.Ogre.Host), 96 92 } ··· 251 247 Multiple: multiple, 252 248 Created: time.Now(), 253 249 } 254 - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 250 + if err := label.Validate(); err != nil { 255 251 fail(err.Error(), err) 256 252 return 257 253 }
+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" ··· 21 23 xrpcclient "tangled.org/core/appview/xrpcclient" 22 24 "tangled.org/core/consts" 23 25 "tangled.org/core/orm" 26 + "tangled.org/core/sets" 24 27 "tangled.org/core/types" 25 28 26 29 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 552 555 topicStr = r.FormValue("topics") 553 556 ) 554 557 555 - err = rp.validator.ValidateURI(website) 556 - if website != "" && err != nil { 557 - l.Error("invalid uri", "err", err) 558 - rp.pages.Notice(w, noticeId, err.Error()) 559 - return 558 + if website != "" { 559 + if err := validateURI(website); err != nil { 560 + l.Error("invalid uri", "err", err) 561 + rp.pages.Notice(w, noticeId, err.Error()) 562 + return 563 + } 560 564 } 561 565 562 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 566 + topics, err := parseRepoTopicStr(topicStr) 563 567 if err != nil { 564 568 l.Error("invalid topics", "err", err) 565 569 rp.pages.Notice(w, noticeId, err.Error()) ··· 619 623 620 624 rp.pages.HxRefresh(w) 621 625 } 626 + 627 + const ( 628 + maxTopicLen = 50 629 + maxTopics = 20 630 + ) 631 + 632 + var ( 633 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 634 + ) 635 + 636 + // parseRepoTopicStr parses and validates whitespace-separated topic string. 637 + // 638 + // Rules: 639 + // - topics are separated by whitespace 640 + // - each topic may contain lowercase letters, digits, and hyphens only 641 + // - each topic must be <= 50 characters long 642 + // - no more than 20 topics allowed 643 + // - duplicates are removed 644 + func parseRepoTopicStr(topicStr string) ([]string, error) { 645 + topicStr = strings.TrimSpace(topicStr) 646 + if topicStr == "" { 647 + return nil, nil 648 + } 649 + parts := strings.Fields(topicStr) 650 + if len(parts) > maxTopics { 651 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 652 + } 653 + 654 + topicSet := sets.New[string]() 655 + 656 + for _, t := range parts { 657 + if topicSet.Contains(t) { 658 + continue 659 + } 660 + if len(t) > maxTopicLen { 661 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 662 + } 663 + if !topicRE.MatchString(t) { 664 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 665 + } 666 + topicSet.Insert(t) 667 + } 668 + return slices.Collect(topicSet.All()), nil 669 + } 670 + 671 + // TODO(boltless): move this to models.Repo instead 672 + func validateURI(uri string) error { 673 + parsed, err := url.Parse(uri) 674 + if err != nil { 675 + return fmt.Errorf("invalid uri format") 676 + } 677 + if parsed.Scheme == "" { 678 + return fmt.Errorf("uri scheme missing") 679 + } 680 + return nil 681 + }
+2 -5
appview/state/router.go
··· 356 356 s.db, 357 357 s.config, 358 358 s.notifier, 359 - s.validator, 360 359 s.indexer.Issues, 361 360 log.SubLogger(s.logger, "issues"), 362 361 ) ··· 374 373 s.config, 375 374 s.notifier, 376 375 s.aclService, 377 - s.validator, 378 376 s.indexer.Pulls, 379 377 log.SubLogger(s.logger, "pulls"), 380 378 ) ··· 394 392 s.enforcer, 395 393 s.aclService, 396 394 log.SubLogger(s.logger, "repo"), 397 - s.validator, 398 395 s.cfClient, 399 396 ) 400 397 return repo.Router(mw) ··· 421 418 s.oauth, 422 419 s.pages, 423 420 s.db, 424 - s.validator, 425 - s.enforcer, 421 + s.idResolver.Directory(), 422 + s.aclService, 426 423 s.notifier, 427 424 log.SubLogger(s.logger, "labels"), 428 425 )
-7
appview/state/state.go
··· 34 34 pipelinessh "tangled.org/core/appview/pipelines/ssh" 35 35 "tangled.org/core/appview/reporesolver" 36 36 "tangled.org/core/appview/repoverify" 37 - "tangled.org/core/appview/validator" 38 37 xrpcclient "tangled.org/core/appview/xrpcclient" 39 38 "tangled.org/core/consts" 40 39 "tangled.org/core/eventconsumer" ··· 74 73 spindlestream *eventconsumer.Consumer 75 74 pipelineNotifier *pipelines.StatusNotifier 76 75 logger *slog.Logger 77 - validator *validator.Validator 78 76 cfClient *cloudflare.Client 79 77 } 80 78 ··· 120 118 if err != nil { 121 119 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 122 120 } 123 - 124 - validator := validator.New(d, res, aclService) 125 - 126 121 repoResolver := reporesolver.New(config, aclService, d, rdb) 127 122 128 123 mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) ··· 194 189 Cache: rdb, 195 190 Config: config, 196 191 Logger: log.SubLogger(logger, "ingester"), 197 - Validator: validator, 198 192 MentionsResolver: mentionsResolver, 199 193 Notifier: notifier, 200 194 Verifier: repoverify.New(res, config.Core.Dev), ··· 248 242 spindlestream: spindlestream, 249 243 pipelineNotifier: pipelineNotifier, 250 244 logger: logger, 251 - validator: validator, 252 245 cfClient: cfClient, 253 246 } 254 247
-28
appview/validator/issue.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/appview/models" 8 - ) 9 - 10 - func (v *Validator) ValidateIssue(issue *models.Issue) error { 11 - if issue.Title == "" { 12 - return fmt.Errorf("issue title is empty") 13 - } 14 - 15 - if issue.Body == "" { 16 - return fmt.Errorf("issue body is empty") 17 - } 18 - 19 - if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 20 - return fmt.Errorf("title is empty after HTML sanitization") 21 - } 22 - 23 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 24 - return fmt.Errorf("body is empty after HTML sanitization") 25 - } 26 - 27 - return nil 28 - }
-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(ctx context.Context, 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 - expectedKey := labelDef.AtUri().String() 110 - if labelOp.OperandKey != expectedKey { 111 - return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 112 - } 113 - 114 - if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 115 - return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 116 - } 117 - 118 - if labelOp.Subject == "" { 119 - return fmt.Errorf("subject URI is required") 120 - } 121 - if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 122 - return fmt.Errorf("invalid subject URI: %w", err) 123 - } 124 - 125 - if err := v.validateOperandValue(labelDef, labelOp); err != nil { 126 - return fmt.Errorf("invalid operand value: %w", err) 127 - } 128 - 129 - // Validate performed time is not zero/invalid 130 - if labelOp.PerformedAt.IsZero() { 131 - return fmt.Errorf("performed_at timestamp is required") 132 - } 133 - 134 - // validate permissions: only collaborators can apply labels currently 135 - // 136 - // TODO: introduce a repo:triage permission 137 - ok, err := v.acl.HasRepoPermissionErr(ctx, repo, labelOp.Did, "repo:push") 138 - if err != nil { 139 - return err 140 - } 141 - if !ok { 142 - return fmt.Errorf("unauthorized label operation") 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 - }
-87
appview/validator/label_test.go
··· 1 - package validator 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "io" 8 - "log/slog" 9 - "net/http" 10 - "net/http/httptest" 11 - "path/filepath" 12 - "strings" 13 - "testing" 14 - "time" 15 - 16 - "tangled.org/core/api/tangled" 17 - "tangled.org/core/appview/db" 18 - "tangled.org/core/appview/knotacl" 19 - "tangled.org/core/appview/models" 20 - "tangled.org/core/consts" 21 - "tangled.org/core/rbac" 22 - ) 23 - 24 - func unreachableListValidator(t *testing.T) (*Validator, string) { 25 - t.Helper() 26 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 - switch { 28 - case strings.HasSuffix(r.URL.Path, tangled.KnotVersionNSID): 29 - json.NewEncoder(w).Encode(tangled.KnotVersion_Output{Version: "v1.15.0", Capabilities: []string{string(consts.CapKnotACL)}}) 30 - case strings.HasSuffix(r.URL.Path, tangled.RepoListCollaboratorsNSID): 31 - http.Error(w, "list down", http.StatusInternalServerError) 32 - default: 33 - http.NotFound(w, r) 34 - } 35 - })) 36 - t.Cleanup(srv.Close) 37 - host := strings.TrimPrefix(srv.URL, "http://") 38 - 39 - dir := t.TempDir() 40 - enforcer, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db")) 41 - if err != nil { 42 - t.Fatalf("NewEnforcer: %v", err) 43 - } 44 - d, err := db.Make(context.Background(), filepath.Join(dir, "appview.db")) 45 - if err != nil { 46 - t.Fatalf("db.Make: %v", err) 47 - } 48 - svc := knotacl.NewService(enforcer, d, true, slog.New(slog.NewTextHandler(io.Discard, nil))) 49 - return &Validator{acl: svc}, host 50 - } 51 - 52 - func TestValidateLabelOp_MalformedRejectedBeforePermCheck(t *testing.T) { 53 - v, host := unreachableListValidator(t) 54 - def := &models.LabelDefinition{Did: "did:plc:akshay", Rkey: "deadbeef"} 55 - repo := &models.Repo{Did: "did:plc:akshay", Knot: host, RepoDid: "did:plc:limpet"} 56 - 57 - op := &models.LabelOp{ 58 - Did: "did:plc:scallop", 59 - OperandKey: "does-not-match-the-def-aturi", 60 - Operation: "garbage", 61 - } 62 - err := v.ValidateLabelOp(context.Background(), def, repo, op) 63 - if errors.Is(err, knotacl.ErrKnotUnreachable) { 64 - t.Fatalf("malformed op returned ErrKnotUnreachable; structural validation did not run before the perm check") 65 - } 66 - if err == nil || !strings.Contains(err.Error(), "operand key") { 67 - t.Fatalf("want a structural operand-key error, got %v", err) 68 - } 69 - } 70 - 71 - func TestValidateLabelOp_WellFormedFailsOpenWhenKnotUnreachable(t *testing.T) { 72 - v, host := unreachableListValidator(t) 73 - def := &models.LabelDefinition{Did: "did:plc:akshay", Rkey: "deadbeef"} 74 - repo := &models.Repo{Did: "did:plc:akshay", Knot: host, RepoDid: "did:plc:limpet"} 75 - 76 - op := &models.LabelOp{ 77 - Did: "did:plc:scallop", 78 - OperandKey: def.AtUri().String(), 79 - Operation: models.LabelOperationAdd, 80 - Subject: "at://did:plc:limpet/sh.tangled.repo.issue/abc123", 81 - PerformedAt: time.Now(), 82 - } 83 - err := v.ValidateLabelOp(context.Background(), def, repo, op) 84 - if !errors.Is(err, knotacl.ErrKnotUnreachable) { 85 - t.Fatalf("well-formed op against an unreachable knot = %v, want ErrKnotUnreachable so the ingester fails open", err) 86 - } 87 - }
-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 - }
-68
appview/validator/pull.go
··· 1 - package validator 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "strings" 7 - 8 - "tangled.org/core/appview/db" 9 - "tangled.org/core/appview/models" 10 - "tangled.org/core/orm" 11 - "tangled.org/core/patchutil" 12 - ) 13 - 14 - func (v *Validator) ValidatePull(pull *models.Pull) error { 15 - if len(pull.Submissions) == 0 { 16 - return fmt.Errorf("pull must have at least one submission") 17 - } 18 - 19 - latestSubmission := pull.LatestSubmission() 20 - if latestSubmission == nil { 21 - return fmt.Errorf("pull must have a valid latest submission") 22 - } 23 - 24 - isFormatPatch := patchutil.IsFormatPatch(latestSubmission.Patch) 25 - 26 - // title and body can only be empty if the patch is a format-patch 27 - if !isFormatPatch { 28 - if pull.Title == "" { 29 - return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 30 - } 31 - 32 - if pull.Body == "" { 33 - return fmt.Errorf("pull body is empty (required for non-format-patch pulls)") 34 - } 35 - 36 - if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(pull.Title)); st == "" { 37 - return fmt.Errorf("title is empty after HTML sanitization") 38 - } 39 - 40 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(pull.Body)); sb == "" { 41 - return fmt.Errorf("body is empty after HTML sanitization") 42 - } 43 - } 44 - 45 - // the dependent_on should not form a DAG, aka, two PRs should not have the same dependent 46 - if pull.DependentOn != nil { 47 - dependentPull, err := db.GetPull( 48 - v.db, 49 - orm.FilterEq("dependent_on", pull.DependentOn.String()), 50 - ) 51 - 52 - if err == sql.ErrNoRows { 53 - return nil 54 - } 55 - 56 - if err != nil { 57 - return fmt.Errorf("failed to fetch pulls with same dependency: %w", err) 58 - } 59 - 60 - if dependentPull.AtUri() == pull.AtUri() { 61 - return nil 62 - } 63 - 64 - return fmt.Errorf("another pull already depends on %s, which would form a DAG, this is presently disallowed", pull.DependentOn.String()) 65 - } 66 - 67 - return nil 68 - }
-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/knotacl" 6 - "tangled.org/core/appview/pages/markup" 7 - "tangled.org/core/idresolver" 8 - ) 9 - 10 - type Validator struct { 11 - db *db.DB 12 - sanitizer markup.Sanitizer 13 - resolver *idresolver.Resolver 14 - acl *knotacl.Service 15 - } 16 - 17 - func New(db *db.DB, res *idresolver.Resolver, acl *knotacl.Service) *Validator { 18 - return &Validator{ 19 - db: db, 20 - sanitizer: markup.NewSanitizer(), 21 - resolver: res, 22 - acl: acl, 23 - } 24 - }