diff --git a/appview/ingester.go b/appview/ingester.go index 338d40b0..9355ddf9 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -19,7 +19,6 @@ import ( "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/serververify" - "tangled.org/core/appview/validator" "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" @@ -31,7 +30,6 @@ type Ingester struct { IdResolver *idresolver.Resolver Config *config.Config Logger *slog.Logger - Validator *validator.Validator } type processFunc func(ctx context.Context, e *jmodels.Event) error @@ -613,7 +611,7 @@ func (i *Ingester) ingestString(e *jmodels.Event) error { string := models.StringFromRecord(did, rkey, record) - if err = i.Validator.ValidateString(&string); err != nil { + if err = string.Validate(); err != nil { l.Error("invalid record", "err", err) return err } @@ -822,7 +820,7 @@ func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error { issue := models.IssueFromRecord(did, rkey, record) - if err := i.Validator.ValidateIssue(&issue); err != nil { + if err := issue.Validate(); err != nil { return fmt.Errorf("failed to validate issue: %w", err) } @@ -902,7 +900,7 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { return fmt.Errorf("failed to parse comment from record: %w", err) } - if err := i.Validator.ValidateIssueComment(comment); err != nil { + if err := comment.Validate(); err != nil { return fmt.Errorf("failed to validate comment: %w", err) } @@ -962,7 +960,7 @@ func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { return fmt.Errorf("failed to parse labeldef from record: %w", err) } - if err := i.Validator.ValidateLabelDefinition(def); err != nil { + if err := def.Validate(); err != nil { return fmt.Errorf("failed to validate labeldef: %w", err) } @@ -1038,7 +1036,19 @@ func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { if !ok { return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) } - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { + + // validate permissions: only collaborators can apply labels currently + // + // TODO: introduce a repo:triage permission + ok, err := i.Enforcer.IsPushAllowed(o.Did, repo.Knot, repo.DidSlashRepo()) + if err != nil { + return fmt.Errorf("enforcing permission: %w", err) + } + if !ok { + return fmt.Errorf("unauthorized label operation") + } + + if err := def.ValidateOperandValue(&o); err != nil { return fmt.Errorf("failed to validate labelop: %w", err) } } diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 9d35bb71..a566ba29 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -27,7 +27,6 @@ import ( "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/validator" "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/rbac" @@ -45,7 +44,6 @@ type Issues struct { config *config.Config notifier notify.Notifier logger *slog.Logger - validator *validator.Validator indexer *issues_indexer.Indexer } @@ -59,7 +57,6 @@ func New( db *db.DB, config *config.Config, notifier notify.Notifier, - validator *validator.Validator, indexer *issues_indexer.Indexer, logger *slog.Logger, ) *Issues { @@ -74,7 +71,6 @@ func New( config: config, notifier: notifier, logger: logger, - validator: validator, indexer: indexer, } } @@ -165,7 +161,7 @@ func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { newIssue.Body = r.FormValue("body") newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) - if err := rp.validator.ValidateIssue(newIssue); err != nil { + if err := newIssue.Validate(); err != nil { l.Error("validation error", "err", err) rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) return @@ -424,7 +420,7 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { Mentions: mentions, References: references, } - if err = rp.validator.ValidateIssueComment(&comment); err != nil { + if err = comment.Validate(); err != nil { l.Error("failed to validate comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return @@ -927,7 +923,7 @@ func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { Repo: f, } - if err := rp.validator.ValidateIssue(issue); err != nil { + if err := issue.Validate(); err != nil { l.Error("validation error", "err", err) rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) return diff --git a/appview/labels/labels.go b/appview/labels/labels.go index 7aea6e6a..3b9fa987 100644 --- a/appview/labels/labels.go +++ b/appview/labels/labels.go @@ -15,7 +15,6 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" - "tangled.org/core/appview/validator" "tangled.org/core/orm" "tangled.org/core/rbac" "tangled.org/core/tid" @@ -28,29 +27,26 @@ import ( ) type Labels struct { - oauth *oauth.OAuth - pages *pages.Pages - db *db.DB - logger *slog.Logger - validator *validator.Validator - enforcer *rbac.Enforcer + oauth *oauth.OAuth + pages *pages.Pages + db *db.DB + logger *slog.Logger + enforcer *rbac.Enforcer } func New( oauth *oauth.OAuth, pages *pages.Pages, db *db.DB, - validator *validator.Validator, enforcer *rbac.Enforcer, logger *slog.Logger, ) *Labels { return &Labels{ - oauth: oauth, - pages: pages, - db: db, - logger: logger, - validator: validator, - enforcer: enforcer, + oauth: oauth, + pages: pages, + db: db, + logger: logger, + enforcer: enforcer, } } @@ -163,10 +159,26 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { for i := range labelOps { def := actx.Defs[labelOps[i].OperandKey] - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { + op := labelOps[i] + + // validate permissions: only collaborators can apply labels currently + // + // TODO: introduce a repo:triage permission + ok, err := l.enforcer.IsPushAllowed(op.Did, repo.Knot, repo.DidSlashRepo()) + if err != nil { + fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) + return + } + if !ok { + fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) + return + } + + if err := def.ValidateOperandValue(&op); err != nil { fail(fmt.Sprintf("Invalid form data: %s", err), err) return } + labelOps[i] = op } // reduce the opset diff --git a/appview/models/issue.go b/appview/models/issue.go index 1576d43b..2a5da66e 100644 --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -3,10 +3,12 @@ package models import ( "fmt" "sort" + "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" + "tangled.org/core/appview/pages/markup/sanitizer" ) type Issue struct { @@ -61,6 +63,26 @@ func (i *Issue) State() string { return "closed" } +var _ Validator = new(Issue) + +func (i *Issue) Validate() error { + if i.Title == "" { + return fmt.Errorf("issue title is empty") + } + if i.Body == "" { + return fmt.Errorf("issue body is empty") + } + + if st := strings.TrimSpace(sanitizer.SanitizeDescription(i.Title)); st == "" { + return fmt.Errorf("title is empty after HTML sanitization") + } + + if st := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); st == "" { + return fmt.Errorf("body is empty after HTML sanitization") + } + return nil +} + type CommentListItem struct { Self *IssueComment Replies []*IssueComment @@ -217,6 +239,16 @@ func (i *IssueComment) IsReply() bool { return i.ReplyTo != nil } +var _ Validator = new(IssueComment) + +func (i *IssueComment) Validate() error { + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); sb == "" { + return fmt.Errorf("body is empty after HTML sanitization") + } + + return nil +} + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { created, err := time.Parse(time.RFC3339, record.CreatedAt) if err != nil { diff --git a/appview/models/label.go b/appview/models/label.go index fb79b8ae..5cdaf75b 100644 --- a/appview/models/label.go +++ b/appview/models/label.go @@ -7,7 +7,9 @@ import ( "encoding/json" "errors" "fmt" + "regexp" "slices" + "strings" "time" "github.com/bluesky-social/indigo/api/atproto" @@ -120,6 +122,167 @@ func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { } } +var ( + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) + // Color should be a valid hex color + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) + // You can only label issues and pulls presently + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} +) + +var _ Validator = new(LabelDefinition) + +func (l *LabelDefinition) Validate() error { + if l.Name == "" { + return fmt.Errorf("label name is empty") + } + if len(l.Name) > 40 { + return fmt.Errorf("label name too long (max 40 graphemes)") + } + if len(l.Name) < 1 { + return fmt.Errorf("label name too short (min 1 grapheme)") + } + if !labelNameRegex.MatchString(l.Name) { + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") + } + + if !l.ValueType.IsConcreteType() { + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type) + } + + // null type checks: cannot be enums, multiple or explicit format + if l.ValueType.IsNull() && l.ValueType.IsEnum() { + return fmt.Errorf("null type cannot be used in conjunction with enum type") + } + if l.ValueType.IsNull() && l.Multiple { + return fmt.Errorf("null type labels cannot be multiple") + } + if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() { + return fmt.Errorf("format cannot be used in conjunction with null type") + } + + // format checks: cannot be used with enum, or integers + if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() { + return fmt.Errorf("enum types cannot be used in conjunction with format specification") + } + + if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() { + return fmt.Errorf("format specifications are only permitted on string types") + } + + // validate scope (nsid format) + if l.Scope == nil { + return fmt.Errorf("scope is required") + } + for _, s := range l.Scope { + if _, err := syntax.ParseNSID(s); err != nil { + return fmt.Errorf("failed to parse scope: %w", err) + } + if !slices.Contains(validScopes, s) { + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) + } + } + + // validate color if provided + if l.Color != nil { + color := strings.TrimSpace(*l.Color) + if color == "" { + // empty color is fine, set to nil + l.Color = nil + } else { + if !colorRegex.MatchString(color) { + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") + } + // expand 3-digit hex to 6-digit hex + if len(color) == 4 { // #ABC + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) + } + // convert to uppercase for consistency + color = strings.ToUpper(color) + l.Color = &color + } + } + + return nil +} + +// ValidateOperandValue validates the label operation operand value based on +// label definition. +// +// NOTE: This can modify the [LabelOp] +func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error { + expectedKey := def.AtUri().String() + if op.OperandKey != def.AtUri().String() { + return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey) + } + + valueType := def.ValueType + + // this is permitted, it "unsets" a label + if op.OperandValue == "" { + op.Operation = LabelOperationDel + return nil + } + + switch valueType.Type { + case ConcreteTypeNull: + // For null type, value should be empty + if op.OperandValue != "null" { + return fmt.Errorf("null type requires empty value, got %q", op.OperandValue) + } + + case ConcreteTypeString: + // For string type, validate enum constraints if present + if valueType.IsEnum() { + if !slices.Contains(valueType.Enum, op.OperandValue) { + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) + } + } + + switch valueType.Format { + case ValueTypeFormatDid: + if _, err := syntax.ParseDID(op.OperandValue); err != nil { + return fmt.Errorf("failed to resolve did/handle: %w", err) + } + case ValueTypeFormatAny, "": + default: + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) + } + + case ConcreteTypeInt: + if op.OperandValue == "" { + return fmt.Errorf("integer type requires non-empty value") + } + if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil { + return fmt.Errorf("value %q is not a valid integer", op.OperandValue) + } + + if valueType.IsEnum() { + if !slices.Contains(valueType.Enum, op.OperandValue) { + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) + } + } + + case ConcreteTypeBool: + if op.OperandValue != "true" && op.OperandValue != "false" { + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue) + } + + // validate enum constraints if present (though uncommon for booleans) + if valueType.IsEnum() { + if !slices.Contains(valueType.Enum, op.OperandValue) { + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) + } + } + + default: + return fmt.Errorf("unsupported value type: %q", valueType.Type) + } + + return nil +} + // random color for a given seed func randomColor(seed string) string { hash := sha1.Sum([]byte(seed)) @@ -131,14 +294,14 @@ func randomColor(seed string) string { return fmt.Sprintf("#%s%s%s", r, g, b) } -func (ld LabelDefinition) GetColor() string { - if ld.Color == nil { - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) +func (l LabelDefinition) GetColor() string { + if l.Color == nil { + seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) color := randomColor(seed) return color } - return *ld.Color + return *l.Color } func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { @@ -205,6 +368,22 @@ func (l LabelOp) SortAt() time.Time { return indexedAt } +var _ Validator = new(LabelOp) + +func (l *LabelOp) Validate() error { + if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { + return fmt.Errorf("invalid subject URI: %w", err) + } + if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) + } + // Validate performed time is not zero/invalid + if l.PerformedAt.IsZero() { + return fmt.Errorf("performed_at timestamp is required") + } + return nil +} + type LabelOperation string const ( diff --git a/appview/models/string.go b/appview/models/string.go index 718abfcd..1db2efe6 100644 --- a/appview/models/string.go +++ b/appview/models/string.go @@ -2,10 +2,12 @@ package models import ( "bytes" + "errors" "fmt" "io" "strings" "time" + "unicode/utf8" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" @@ -35,6 +37,25 @@ func (s *String) AsRecord() tangled.String { } } +var _ Validator = new(String) + +func (s *String) Validate() error { + var err error + if utf8.RuneCountInString(s.Filename) > 140 { + err = errors.Join(err, fmt.Errorf("filename too long")) + } + + if utf8.RuneCountInString(s.Description) > 280 { + err = errors.Join(err, fmt.Errorf("description too long")) + } + + if len(s.Contents) == 0 { + err = errors.Join(err, fmt.Errorf("contents is empty")) + } + + return err +} + func StringFromRecord(did, rkey string, record tangled.String) String { created, err := time.Parse(record.CreatedAt, time.RFC3339) if err != nil { diff --git a/appview/models/validator.go b/appview/models/validator.go new file mode 100644 index 00000000..4e3c7d1a --- /dev/null +++ b/appview/models/validator.go @@ -0,0 +1,6 @@ +package models + +type Validator interface { + // Validate checks the object and returns any error. + Validate() error +} diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 63340d80..57a13748 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -30,6 +30,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages/markup" + "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/crypto" ) @@ -260,7 +261,7 @@ func (p *Pages) funcMap() template.FuncMap { "markdown": func(text string) template.HTML { p.rctx.RendererType = markup.RendererTypeDefault htmlString := p.rctx.RenderMarkdown(text) - sanitized := p.rctx.SanitizeDefault(htmlString) + sanitized := sanitizer.SanitizeDefault(htmlString) return template.HTML(sanitized) }, "description": func(text string) template.HTML { @@ -270,13 +271,13 @@ func (p *Pages) funcMap() template.FuncMap { emoji.Emoji, ), )) - sanitized := p.rctx.SanitizeDescription(htmlString) + sanitized := sanitizer.SanitizeDescription(htmlString) return template.HTML(sanitized) }, "readme": func(text string) template.HTML { p.rctx.RendererType = markup.RendererTypeRepoMarkdown htmlString := p.rctx.RenderMarkdown(text) - sanitized := p.rctx.SanitizeDefault(htmlString) + sanitized := sanitizer.SanitizeDefault(htmlString) return template.HTML(sanitized) }, "code": func(content, path string) string { diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go index f17e14ea..5cb6c993 100644 --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -47,7 +47,6 @@ type RenderContext struct { repoinfo.RepoInfo IsDev bool RendererType RendererType - Sanitizer Sanitizer Files fs.FS } @@ -177,14 +176,6 @@ func visitNode(ctx *RenderContext, node *htmlparse.Node) { } } -func (rctx *RenderContext) SanitizeDefault(html string) string { - return rctx.Sanitizer.SanitizeDefault(html) -} - -func (rctx *RenderContext) SanitizeDescription(html string) string { - return rctx.Sanitizer.SanitizeDescription(html) -} - type MarkdownTransformer struct { rctx *RenderContext } diff --git a/appview/pages/markup/sanitizer.go b/appview/pages/markup/sanitizer/sanitizer.go similarity index 88% rename from appview/pages/markup/sanitizer.go rename to appview/pages/markup/sanitizer/sanitizer.go index 4ce8e1b6..546fef91 100644 --- a/appview/pages/markup/sanitizer.go +++ b/appview/pages/markup/sanitizer/sanitizer.go @@ -1,4 +1,4 @@ -package markup +package sanitizer import ( "maps" @@ -10,26 +10,19 @@ import ( "github.com/microcosm-cc/bluemonday" ) -type Sanitizer struct { - defaultPolicy *bluemonday.Policy - descriptionPolicy *bluemonday.Policy -} - -func NewSanitizer() Sanitizer { - return Sanitizer{ - defaultPolicy: defaultPolicy(), - descriptionPolicy: descriptionPolicy(), - } -} +var ( + defaultPolicy = newDefaultPolicy() + descriptionPolicy = newDescriptionPolicy() +) -func (s *Sanitizer) SanitizeDefault(html string) string { - return s.defaultPolicy.Sanitize(html) +func SanitizeDefault(html string) string { + return defaultPolicy.Sanitize(html) } -func (s *Sanitizer) SanitizeDescription(html string) string { - return s.descriptionPolicy.Sanitize(html) +func SanitizeDescription(html string) string { + return descriptionPolicy.Sanitize(html) } -func defaultPolicy() *bluemonday.Policy { +func newDefaultPolicy() *bluemonday.Policy { policy := bluemonday.UGCPolicy() // Allow generally safe attributes @@ -123,7 +116,7 @@ func defaultPolicy() *bluemonday.Policy { return policy } -func descriptionPolicy() *bluemonday.Policy { +func newDescriptionPolicy() *bluemonday.Policy { policy := bluemonday.NewPolicy() policy.AllowStandardURLs() diff --git a/appview/pages/pages.go b/appview/pages/pages.go index ad8392a1..a7646138 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -23,6 +23,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages/markup" + "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" "tangled.org/core/idresolver" @@ -57,7 +58,6 @@ func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, IsDev: config.Core.Dev, CamoUrl: config.Camo.Host, CamoSecret: config.Camo.SharedSecret, - Sanitizer: markup.NewSanitizer(), Files: Files, } @@ -273,7 +273,7 @@ func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { p.rctx.RendererType = markup.RendererTypeDefault htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) - sanitized := p.rctx.SanitizeDefault(htmlString) + sanitized := sanitizer.SanitizeDefault(htmlString) params.Content = template.HTML(sanitized) return p.execute("legal/terms", w, params) @@ -301,7 +301,7 @@ func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { p.rctx.RendererType = markup.RendererTypeDefault htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) - sanitized := p.rctx.SanitizeDefault(htmlString) + sanitized := sanitizer.SanitizeDefault(htmlString) params.Content = template.HTML(sanitized) return p.execute("legal/privacy", w, params) @@ -698,7 +698,7 @@ func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": params.Raw = false htmlString := p.rctx.RenderMarkdown(params.Readme) - sanitized := p.rctx.SanitizeDefault(htmlString) + sanitized := sanitizer.SanitizeDefault(htmlString) params.HTMLReadme = template.HTML(sanitized) default: params.Raw = true @@ -789,7 +789,7 @@ func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": params.Raw = false htmlString := p.rctx.RenderMarkdown(params.Readme) - sanitized := p.rctx.SanitizeDefault(htmlString) + sanitized := sanitizer.SanitizeDefault(htmlString) params.HTMLReadme = template.HTML(sanitized) default: params.Raw = true diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index feef02bb..a00d0129 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -27,11 +27,10 @@ import ( "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" - "tangled.org/core/appview/pages/markup" + "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/validator" "tangled.org/core/appview/xrpcclient" "tangled.org/core/idresolver" "tangled.org/core/orm" @@ -59,7 +58,6 @@ type Pulls struct { notifier notify.Notifier enforcer *rbac.Enforcer logger *slog.Logger - validator *validator.Validator indexer *pulls_indexer.Indexer } @@ -73,7 +71,6 @@ func New( config *config.Config, notifier notify.Notifier, enforcer *rbac.Enforcer, - validator *validator.Validator, indexer *pulls_indexer.Indexer, logger *slog.Logger, ) *Pulls { @@ -88,7 +85,6 @@ func New( notifier: notifier, enforcer: enforcer, logger: logger, - validator: validator, indexer: indexer, } } @@ -868,7 +864,6 @@ func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { s.pages.Notice(w, "pull", "Title is required for git-diff patches.") return } - sanitizer := markup.NewSanitizer() if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") return @@ -996,7 +991,7 @@ func (s *Pulls) handleBranchBasedPull( patch := comparison.FormatPatchRaw combined := comparison.CombinedPatchRaw - if err := s.validator.ValidatePatch(&patch); err != nil { + if err := validatePatch(&patch); err != nil { s.logger.Error("failed to validate patch", "err", err) s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return @@ -1014,7 +1009,7 @@ func (s *Pulls) handleBranchBasedPull( } func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { - if err := s.validator.ValidatePatch(&patch); err != nil { + if err := validatePatch(&patch); err != nil { s.logger.Error("patch validation failed", "err", err) s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return @@ -1106,7 +1101,7 @@ func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo patch := comparison.FormatPatchRaw combined := comparison.CombinedPatchRaw - if err := s.validator.ValidatePatch(&patch); err != nil { + if err := validatePatch(&patch); err != nil { s.logger.Error("failed to validate patch", "err", err) s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return @@ -1399,7 +1394,7 @@ func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { return } - if err := s.validator.ValidatePatch(&patch); err != nil { + if err := validatePatch(&patch); err != nil { s.logger.Error("faield to validate patch", "err", err) s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") return @@ -1820,7 +1815,7 @@ func (s *Pulls) resubmitPullHelper( return } - if err := s.validator.ValidatePatch(&patch); err != nil { + if err := validatePatch(&patch); err != nil { s.pages.Notice(w, "resubmit-error", err.Error()) return } @@ -2447,3 +2442,20 @@ func gz(s string) io.Reader { w.Close() return &b } + +func validatePatch(patch *string) error { + if patch == nil || *patch == "" { + return fmt.Errorf("patch is empty") + } + + // add newline if not present to diff style patches + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { + *patch = *patch + "\n" + } + + if err := patchutil.IsPatchValid(*patch); err != nil { + return err + } + + return nil +} diff --git a/appview/repo/repo.go b/appview/repo/repo.go index f04f9061..381f5a0a 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -20,7 +20,6 @@ import ( "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/validator" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" @@ -49,7 +48,6 @@ type Repo struct { notifier notify.Notifier logger *slog.Logger serviceAuth *serviceauth.ServiceAuth - validator *validator.Validator } func New( @@ -63,7 +61,6 @@ func New( notifier notify.Notifier, enforcer *rbac.Enforcer, logger *slog.Logger, - validator *validator.Validator, ) *Repo { return &Repo{oauth: oauth, repoResolver: repoResolver, @@ -75,7 +72,6 @@ func New( notifier: notifier, enforcer: enforcer, logger: logger, - validator: validator, } } @@ -225,7 +221,7 @@ func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { Multiple: multiple, Created: time.Now(), } - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { + if err := label.Validate(); err != nil { fail(err.Error(), err) return } diff --git a/appview/repo/settings.go b/appview/repo/settings.go index ff537f44..71696521 100644 --- a/appview/repo/settings.go +++ b/appview/repo/settings.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "regexp" "slices" "strings" "time" @@ -15,6 +17,7 @@ import ( "tangled.org/core/appview/pages" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" + "tangled.org/core/sets" "tangled.org/core/types" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -385,14 +388,15 @@ func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { topicStr = r.FormValue("topics") ) - err = rp.validator.ValidateURI(website) - if website != "" && err != nil { - l.Error("invalid uri", "err", err) - rp.pages.Notice(w, noticeId, err.Error()) - return + if website != "" { + if err := validateURI(website); err != nil { + l.Error("invalid uri", "err", err) + rp.pages.Notice(w, noticeId, err.Error()) + return + } } - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) + topics, err := parseRepoTopicStr(topicStr) if err != nil { l.Error("invalid topics", "err", err) rp.pages.Notice(w, noticeId, err.Error()) @@ -452,3 +456,59 @@ func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } + +const ( + maxTopicLen = 50 + maxTopics = 20 +) + +var ( + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) +) + +// parseRepoTopicStr parses and validates whitespace-separated topic string. +// +// Rules: +// - topics are separated by whitespace +// - each topic may contain lowercase letters, digits, and hyphens only +// - each topic must be <= 50 characters long +// - no more than 20 topics allowed +// - duplicates are removed +func parseRepoTopicStr(topicStr string) ([]string, error) { + topicStr = strings.TrimSpace(topicStr) + if topicStr == "" { + return nil, nil + } + parts := strings.Fields(topicStr) + if len(parts) > maxTopics { + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) + } + + topicSet := sets.New[string]() + + for _, t := range parts { + if topicSet.Contains(t) { + continue + } + if len(t) > maxTopicLen { + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) + } + if !topicRE.MatchString(t) { + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) + } + topicSet.Insert(t) + } + return slices.Collect(topicSet.All()), nil +} + +// TODO(boltless): move this to models.Repo instead +func validateURI(uri string) error { + parsed, err := url.Parse(uri) + if err != nil { + return fmt.Errorf("invalid uri format") + } + if parsed.Scheme == "" { + return fmt.Errorf("uri scheme missing") + } + return nil +} diff --git a/appview/state/router.go b/appview/state/router.go index cb3522ce..028835b8 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -276,7 +276,6 @@ func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { s.db, s.config, s.notifier, - s.validator, s.indexer.Issues, log.SubLogger(s.logger, "issues"), ) @@ -294,7 +293,6 @@ func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { s.config, s.notifier, s.enforcer, - s.validator, s.indexer.Pulls, log.SubLogger(s.logger, "pulls"), ) @@ -313,7 +311,6 @@ func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { s.notifier, s.enforcer, log.SubLogger(s.logger, "repo"), - s.validator, ) return repo.Router(mw) } @@ -338,7 +335,6 @@ func (s *State) LabelsRouter() http.Handler { s.oauth, s.pages, s.db, - s.validator, s.enforcer, log.SubLogger(s.logger, "labels"), ) diff --git a/appview/state/state.go b/appview/state/state.go index 4f7b5b22..8a2b4c22 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -23,7 +23,6 @@ import ( "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" - "tangled.org/core/appview/validator" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/eventconsumer" "tangled.org/core/idresolver" @@ -59,7 +58,6 @@ type State struct { knotstream *eventconsumer.Consumer spindlestream *eventconsumer.Consumer logger *slog.Logger - validator *validator.Validator } func Make(ctx context.Context, config *config.Config) (*State, error) { @@ -97,7 +95,6 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { if err != nil { return nil, fmt.Errorf("failed to start oauth handler: %w", err) } - validator := validator.New(d, res, enforcer) repoResolver := reporesolver.New(config, enforcer, d) @@ -144,7 +141,6 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { IdResolver: res, Config: config, Logger: log.SubLogger(logger, "ingester"), - Validator: validator, } err = jc.StartJetstream(ctx, ingester.Ingest()) if err != nil { @@ -191,7 +187,6 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { knotstream, spindlestream, logger, - validator, } return state, nil diff --git a/appview/validator/issue.go b/appview/validator/issue.go deleted file mode 100644 index b199f513..00000000 --- a/appview/validator/issue.go +++ /dev/null @@ -1,55 +0,0 @@ -package validator - -import ( - "fmt" - "strings" - - "tangled.org/core/appview/db" - "tangled.org/core/appview/models" - "tangled.org/core/orm" -) - -func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { - // if comments have parents, only ingest ones that are 1 level deep - if comment.ReplyTo != nil { - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) - if err != nil { - return fmt.Errorf("failed to fetch parent comment: %w", err) - } - if len(parents) != 1 { - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) - } - - // depth check - parent := parents[0] - if parent.ReplyTo != nil { - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") - } - } - - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { - return fmt.Errorf("body is empty after HTML sanitization") - } - - return nil -} - -func (v *Validator) ValidateIssue(issue *models.Issue) error { - if issue.Title == "" { - return fmt.Errorf("issue title is empty") - } - - if issue.Body == "" { - return fmt.Errorf("issue body is empty") - } - - if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { - return fmt.Errorf("title is empty after HTML sanitization") - } - - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { - return fmt.Errorf("body is empty after HTML sanitization") - } - - return nil -} diff --git a/appview/validator/label.go b/appview/validator/label.go deleted file mode 100644 index 44423949..00000000 --- a/appview/validator/label.go +++ /dev/null @@ -1,217 +0,0 @@ -package validator - -import ( - "context" - "fmt" - "regexp" - "strings" - - "github.com/bluesky-social/indigo/atproto/syntax" - "golang.org/x/exp/slices" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/models" -) - -var ( - // Label name should be alphanumeric with hyphens/underscores, but not start/end with them - labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) - // Color should be a valid hex color - colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) - // You can only label issues and pulls presently - validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} -) - -func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { - if label.Name == "" { - return fmt.Errorf("label name is empty") - } - if len(label.Name) > 40 { - return fmt.Errorf("label name too long (max 40 graphemes)") - } - if len(label.Name) < 1 { - return fmt.Errorf("label name too short (min 1 grapheme)") - } - if !labelNameRegex.MatchString(label.Name) { - return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") - } - - if !label.ValueType.IsConcreteType() { - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) - } - - // null type checks: cannot be enums, multiple or explicit format - if label.ValueType.IsNull() && label.ValueType.IsEnum() { - return fmt.Errorf("null type cannot be used in conjunction with enum type") - } - if label.ValueType.IsNull() && label.Multiple { - return fmt.Errorf("null type labels cannot be multiple") - } - if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { - return fmt.Errorf("format cannot be used in conjunction with null type") - } - - // format checks: cannot be used with enum, or integers - if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { - return fmt.Errorf("enum types cannot be used in conjunction with format specification") - } - - if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { - return fmt.Errorf("format specifications are only permitted on string types") - } - - // validate scope (nsid format) - if label.Scope == nil { - return fmt.Errorf("scope is required") - } - for _, s := range label.Scope { - if _, err := syntax.ParseNSID(s); err != nil { - return fmt.Errorf("failed to parse scope: %w", err) - } - if !slices.Contains(validScopes, s) { - return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) - } - } - - // validate color if provided - if label.Color != nil { - color := strings.TrimSpace(*label.Color) - if color == "" { - // empty color is fine, set to nil - label.Color = nil - } else { - if !colorRegex.MatchString(color) { - return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") - } - // expand 3-digit hex to 6-digit hex - if len(color) == 4 { // #ABC - color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) - } - // convert to uppercase for consistency - color = strings.ToUpper(color) - label.Color = &color - } - } - - return nil -} - -func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { - if labelDef == nil { - return fmt.Errorf("label definition is required") - } - if repo == nil { - return fmt.Errorf("repo is required") - } - if labelOp == nil { - return fmt.Errorf("label operation is required") - } - - // validate permissions: only collaborators can apply labels currently - // - // TODO: introduce a repo:triage permission - ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) - if err != nil { - return fmt.Errorf("failed to enforce permissions: %w", err) - } - if !ok { - return fmt.Errorf("unauhtorized label operation") - } - - expectedKey := labelDef.AtUri().String() - if labelOp.OperandKey != expectedKey { - return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) - } - - if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { - return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) - } - - if labelOp.Subject == "" { - return fmt.Errorf("subject URI is required") - } - if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { - return fmt.Errorf("invalid subject URI: %w", err) - } - - if err := v.validateOperandValue(labelDef, labelOp); err != nil { - return fmt.Errorf("invalid operand value: %w", err) - } - - // Validate performed time is not zero/invalid - if labelOp.PerformedAt.IsZero() { - return fmt.Errorf("performed_at timestamp is required") - } - - return nil -} - -func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { - valueType := labelDef.ValueType - - // this is permitted, it "unsets" a label - if labelOp.OperandValue == "" { - labelOp.Operation = models.LabelOperationDel - return nil - } - - switch valueType.Type { - case models.ConcreteTypeNull: - // For null type, value should be empty - if labelOp.OperandValue != "null" { - return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) - } - - case models.ConcreteTypeString: - // For string type, validate enum constraints if present - if valueType.IsEnum() { - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) - } - } - - switch valueType.Format { - case models.ValueTypeFormatDid: - id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) - if err != nil { - return fmt.Errorf("failed to resolve did/handle: %w", err) - } - - labelOp.OperandValue = id.DID.String() - - case models.ValueTypeFormatAny, "": - default: - return fmt.Errorf("unsupported format constraint: %q", valueType.Format) - } - - case models.ConcreteTypeInt: - if labelOp.OperandValue == "" { - return fmt.Errorf("integer type requires non-empty value") - } - if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { - return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) - } - - if valueType.IsEnum() { - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) - } - } - - case models.ConcreteTypeBool: - if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { - return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) - } - - // validate enum constraints if present (though uncommon for booleans) - if valueType.IsEnum() { - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) - } - } - - default: - return fmt.Errorf("unsupported value type: %q", valueType.Type) - } - - return nil -} diff --git a/appview/validator/patch.go b/appview/validator/patch.go deleted file mode 100644 index eeac9c34..00000000 --- a/appview/validator/patch.go +++ /dev/null @@ -1,25 +0,0 @@ -package validator - -import ( - "fmt" - "strings" - - "tangled.org/core/patchutil" -) - -func (v *Validator) ValidatePatch(patch *string) error { - if patch == nil || *patch == "" { - return fmt.Errorf("patch is empty") - } - - // add newline if not present to diff style patches - if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { - *patch = *patch + "\n" - } - - if err := patchutil.IsPatchValid(*patch); err != nil { - return err - } - - return nil -} diff --git a/appview/validator/repo_topics.go b/appview/validator/repo_topics.go deleted file mode 100644 index c4a9609b..00000000 --- a/appview/validator/repo_topics.go +++ /dev/null @@ -1,53 +0,0 @@ -package validator - -import ( - "fmt" - "maps" - "regexp" - "slices" - "strings" -) - -const ( - maxTopicLen = 50 - maxTopics = 20 -) - -var ( - topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) -) - -// ValidateRepoTopicStr parses and validates whitespace-separated topic string. -// -// Rules: -// - topics are separated by whitespace -// - each topic may contain lowercase letters, digits, and hyphens only -// - each topic must be <= 50 characters long -// - no more than 20 topics allowed -// - duplicates are removed -func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { - topicsStr = strings.TrimSpace(topicsStr) - if topicsStr == "" { - return nil, nil - } - parts := strings.Fields(topicsStr) - if len(parts) > maxTopics { - return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) - } - - topicSet := make(map[string]struct{}) - - for _, t := range parts { - if _, exists := topicSet[t]; exists { - continue - } - if len(t) > maxTopicLen { - return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) - } - if !topicRE.MatchString(t) { - return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) - } - topicSet[t] = struct{}{} - } - return slices.Collect(maps.Keys(topicSet)), nil -} diff --git a/appview/validator/string.go b/appview/validator/string.go deleted file mode 100644 index 44b72ffe..00000000 --- a/appview/validator/string.go +++ /dev/null @@ -1,27 +0,0 @@ -package validator - -import ( - "errors" - "fmt" - "unicode/utf8" - - "tangled.org/core/appview/models" -) - -func (v *Validator) ValidateString(s *models.String) error { - var err error - - if utf8.RuneCountInString(s.Filename) > 140 { - err = errors.Join(err, fmt.Errorf("filename too long")) - } - - if utf8.RuneCountInString(s.Description) > 280 { - err = errors.Join(err, fmt.Errorf("description too long")) - } - - if len(s.Contents) == 0 { - err = errors.Join(err, fmt.Errorf("contents is empty")) - } - - return err -} diff --git a/appview/validator/uri.go b/appview/validator/uri.go deleted file mode 100644 index ef824cf1..00000000 --- a/appview/validator/uri.go +++ /dev/null @@ -1,17 +0,0 @@ -package validator - -import ( - "fmt" - "net/url" -) - -func (v *Validator) ValidateURI(uri string) error { - parsed, err := url.Parse(uri) - if err != nil { - return fmt.Errorf("invalid uri format") - } - if parsed.Scheme == "" { - return fmt.Errorf("uri scheme missing") - } - return nil -} diff --git a/appview/validator/validator.go b/appview/validator/validator.go deleted file mode 100644 index 1fcf5888..00000000 --- a/appview/validator/validator.go +++ /dev/null @@ -1,24 +0,0 @@ -package validator - -import ( - "tangled.org/core/appview/db" - "tangled.org/core/appview/pages/markup" - "tangled.org/core/idresolver" - "tangled.org/core/rbac" -) - -type Validator struct { - db *db.DB - sanitizer markup.Sanitizer - resolver *idresolver.Resolver - enforcer *rbac.Enforcer -} - -func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { - return &Validator{ - db: db, - sanitizer: markup.NewSanitizer(), - resolver: res, - enforcer: enforcer, - } -} -- 2.51.2 From d2b09d54950cf8f84fe3d8b8e6ca01e09424602a Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 19 Jan 2026 20:18:51 +0900 Subject: [PATCH 02/24] appview: move tx rollback logic out of `db.UpsertProfile()` Signed-off-by: Seongmin Lee --- appview/db/profile.go | 5 +---- appview/ingester.go | 6 ++++++ appview/state/profile.go | 17 ++++++++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/appview/db/profile.go b/appview/db/profile.go index 49b1cc75..d057c3b2 100644 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -131,8 +131,6 @@ func monthsBetween(from, to time.Time) int { } func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { - defer tx.Rollback() - // update links _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) if err != nil { @@ -228,8 +226,7 @@ func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { return err } } - - return tx.Commit() + return nil } func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { diff --git a/appview/ingester.go b/appview/ingester.go index 9355ddf9..7756ca05 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -347,6 +347,7 @@ func (i *Ingester) ingestProfile(e *jmodels.Event) error { if err != nil { return fmt.Errorf("failed to start transaction") } + defer tx.Rollback() err = db.ValidateProfile(tx, &profile) if err != nil { @@ -354,6 +355,11 @@ func (i *Ingester) ingestProfile(e *jmodels.Event) error { } err = db.UpsertProfile(tx, &profile) + if err != nil { + return fmt.Errorf("upserting profile: %w", err) + } + + err = tx.Commit() case jmodels.CommitOperationDelete: err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) } diff --git a/appview/state/profile.go b/appview/state/profile.go index 34b8901c..f2112ee1 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -613,6 +613,14 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") return } + defer tx.Rollback() + + err = db.UpsertProfile(tx, profile) + if err != nil { + log.Println("failed to update profile", err) + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") + return + } client, err := s.oauth.AuthorizedClient(r) if err != nil { @@ -661,11 +669,10 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r return } - err = db.UpsertProfile(tx, profile) - if err != nil { - log.Println("failed to update profile", err) - s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") - return + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // db failed, but PDS operation succeed. + // log error and continue } s.notifier.UpdateProfile(r.Context(), profile) -- 2.51.2 From 4a8bce5d32646159fbb52034f07d6e4e8c1536f9 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 16 Jan 2026 19:41:29 +0900 Subject: [PATCH 03/24] appview/db: remove foreign key constraint between collections Appview cannot modify the user-owned record on repository deletion Signed-off-by: Seongmin Lee --- appview/db/db.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/appview/db/db.go b/appview/db/db.go index e6ef3143..ff4a67b9 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1181,6 +1181,29 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + // we cannot modify user-owned record on repository delete + orm.RunMigration(conn, logger, "remove-foreign-key-profile_pinned_repositories-and-repos", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table profile_pinned_repositories_new ( + did text not null, + + -- data + at_uri text not null, + + -- constraints + unique(did, at_uri), + foreign key (did) references profile(did) on delete cascade + ); + + insert into profile_pinned_repositories_new (did, at_uri) + select did, at_uri from profile_pinned_repositories; + + drop table profile_pinned_repositories; + alter table profile_pinned_repositories_new rename to profile_pinned_repositories; + `) + return err + }) + return &DB{ db, logger, -- 2.51.2 From 84be602454c5e8e2d72015c6ed5fb718192643b4 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 16 Jan 2026 19:41:29 +0900 Subject: [PATCH 04/24] appview/db: more flexible tables migrate tables: `stars`, `reactions`, `follows`, `public_keys` Two major changes: 1. Remove autoincrement id for these tables. AUTOINCREMENT primary key does not help much for these tables and only introduces slice performance overhead. Use default `rowid` with non-autoincrement integer instead. 2. Remove unique constraints other than `(did, rkey)` We cannot block users creating non-unique atproto records. Appview needs to handle those properly. For example, if user unstar a repo, appview should delete all existing star records pointing to that repo. To allow this, remove all constraints other than `(did, rkey)`. Minor changes done while migrating tables: - rename `thread_at` in `reactions` to `subject_at` to match with other tables - follow common column names like `did` and `created` - allow self-follow (similar reason to 2nd major change. we should block it from service layer instead) Signed-off-by: Seongmin Lee --- appview/db/db.go | 110 +++++++++++++++++++++++++++++++++++++++++ appview/db/follow.go | 24 ++++----- appview/db/reaction.go | 34 ++++++------- appview/db/timeline.go | 2 +- 4 files changed, 140 insertions(+), 30 deletions(-) diff --git a/appview/db/db.go b/appview/db/db.go index ff4a67b9..4ce09649 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + "fmt" "log/slog" "strings" @@ -1204,6 +1205,115 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + // several changes here + // 1. remove autoincrement id for these tables + // 2. remove unique constraints other than (did, rkey) to handle non-unique atproto records + // 3. add generated at_uri field + // + // see comments below and commit message for details + orm.RunMigration(conn, logger, "flexible-stars-reactions-follows-public_keys", func(tx *sql.Tx) error { + // - add at_uri + // - remove unique constraint (did, subject_at) + if _, err := tx.Exec(` + create table stars_new ( + did text not null, + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.feed.star' || '/' || rkey) stored, + + subject_at text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + unique(did, rkey) + ); + + insert into stars_new (did, rkey, subject_at, created) + select did, rkey, subject_at, created from stars; + + drop table stars; + alter table stars_new rename to stars; + `); err != nil { + return fmt.Errorf("migrating stars: %w", err) + } + + // - add at_uri + // - reacted_by_did -> did + // - thread_at -> subject_at + // - remove unique constraint + if _, err := tx.Exec(` + create table reactions_new ( + did text not null, + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.feed.reaction' || '/' || rkey) stored, + + subject_at text not null, + kind text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + unique(did, rkey) + ); + + insert into reactions_new (did, rkey, subject_at, kind, created) + select reacted_by_did, rkey, thread_at, kind, created from reactions; + + drop table reactions; + alter table reactions_new rename to reactions; + `); err != nil { + return fmt.Errorf("migrating reactions: %w", err) + } + + // - add at_uri column + // - user_did -> did + // - followed_at -> created + // - remove unique constraint + // - remove check constraint + if _, err := tx.Exec(` + create table follows_new ( + did text not null, + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.graph.follow' || '/' || rkey) stored, + + subject_did text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + unique(did, rkey) + ); + + insert into follows_new (did, rkey, subject_did, created) + select user_did, rkey, subject_did, followed_at from follows; + + drop table follows; + alter table follows_new rename to follows; + `); err != nil { + return fmt.Errorf("migrating follows: %w", err) + } + + // - add at_uri column + // - remove foreign key relationship from repos + if _, err := tx.Exec(` + create table public_keys_new ( + did text not null, + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.publicKey' || '/' || rkey) stored, + + name text not null, + key text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + unique(did, rkey) + ); + + insert into public_keys_new (did, rkey, name, key, created) + select did, rkey, name, key, created from public_keys; + + drop table public_keys; + alter table public_keys_new rename to public_keys; + `); err != nil { + return fmt.Errorf("migrating public_keys: %w", err) + } + + return nil + }) + return &DB{ db, logger, diff --git a/appview/db/follow.go b/appview/db/follow.go index fb123b6f..d2527ce8 100644 --- a/appview/db/follow.go +++ b/appview/db/follow.go @@ -11,14 +11,14 @@ import ( ) func AddFollow(e Execer, follow *models.Follow) error { - query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` + query := `insert or ignore into follows (did, subject_did, rkey) values (?, ?, ?)` _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) return err } // Get a follow record func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { - query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` + query := `select did, subject_did, created, rkey from follows where did = ? and subject_did = ?` row := e.QueryRow(query, userDid, subjectDid) var follow models.Follow @@ -41,13 +41,13 @@ func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { // Remove a follow func DeleteFollow(e Execer, userDid, subjectDid string) error { - _, err := e.Exec(`delete from follows where user_did = ? and subject_did = ?`, userDid, subjectDid) + _, err := e.Exec(`delete from follows where did = ? and subject_did = ?`, userDid, subjectDid) return err } // Remove a follow func DeleteFollowByRkey(e Execer, userDid, rkey string) error { - _, err := e.Exec(`delete from follows where user_did = ? and rkey = ?`, userDid, rkey) + _, err := e.Exec(`delete from follows where did = ? and rkey = ?`, userDid, rkey) return err } @@ -56,7 +56,7 @@ func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) err := e.QueryRow( `SELECT COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, - COUNT(CASE WHEN user_did = ? THEN 1 END) AS following + COUNT(CASE WHEN did = ? THEN 1 END) AS following FROM follows;`, did, did).Scan(&followers, &following) if err != nil { return models.FollowStats{}, err @@ -96,10 +96,10 @@ func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.Foll group by subject_did ) f full outer join ( - select user_did as did, count(*) as following + select did as did, count(*) as following from follows - where user_did in (%s) - group by user_did + where did in (%s) + group by did ) g on f.did = g.did`, placeholderStr, placeholderStr) @@ -156,10 +156,10 @@ func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, er } query := fmt.Sprintf( - `select user_did, subject_did, followed_at, rkey + `select did, subject_did, created, rkey from follows %s - order by followed_at desc + order by created desc %s `, whereClause, limitClause) @@ -198,7 +198,7 @@ func GetFollowers(e Execer, did string) ([]models.Follow, error) { } func GetFollowing(e Execer, did string) ([]models.Follow, error) { - return GetFollows(e, 0, orm.FilterEq("user_did", did)) + return GetFollows(e, 0, orm.FilterEq("did", did)) } func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { @@ -239,7 +239,7 @@ func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[stri query := fmt.Sprintf(` SELECT subject_did FROM follows - WHERE user_did = ? AND subject_did IN (%s) + WHERE did = ? AND subject_did IN (%s) `, strings.Join(placeholders, ",")) rows, err := e.Query(query, args...) diff --git a/appview/db/reaction.go b/appview/db/reaction.go index fda13e5c..79d3a02c 100644 --- a/appview/db/reaction.go +++ b/appview/db/reaction.go @@ -8,19 +8,19 @@ import ( "tangled.org/core/appview/models" ) -func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { - query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` - _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) +func AddReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind, rkey string) error { + query := `insert or ignore into reactions (did, subject_at, kind, rkey) values (?, ?, ?, ?)` + _, err := e.Exec(query, did, subjectAt, kind, rkey) return err } // Get a reaction record -func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { +func GetReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { query := ` - select reacted_by_did, thread_at, created, rkey + select did, subject_at, created, rkey from reactions - where reacted_by_did = ? and thread_at = ? and kind = ?` - row := e.QueryRow(query, reactedByDid, threadAt, kind) + where did = ? and subject_at = ? and kind = ?` + row := e.QueryRow(query, did, subjectAt, kind) var reaction models.Reaction var created string @@ -41,37 +41,37 @@ func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind mode } // Remove a reaction -func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { - _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) +func DeleteReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind) error { + _, err := e.Exec(`delete from reactions where did = ? and subject_at = ? and kind = ?`, did, subjectAt, kind) return err } // Remove a reaction -func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { - _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) +func DeleteReactionByRkey(e Execer, did string, rkey string) error { + _, err := e.Exec(`delete from reactions where did = ? and rkey = ?`, did, rkey) return err } -func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { +func GetReactionCount(e Execer, subjectAt syntax.ATURI, kind models.ReactionKind) (int, error) { count := 0 err := e.QueryRow( - `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) + `select count(did) from reactions where subject_at = ? and kind = ?`, subjectAt, kind).Scan(&count) if err != nil { return 0, err } return count, nil } -func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { +func GetReactionMap(e Execer, userLimit int, subjectAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { query := ` - select kind, reacted_by_did, + select kind, did, row_number() over (partition by kind order by created asc) as rn, count(*) over (partition by kind) as total from reactions - where thread_at = ? + where subject_at = ? order by kind, created asc` - rows, err := e.Query(query, threadAt) + rows, err := e.Query(query, subjectAt) if err != nil { return nil, err } diff --git a/appview/db/timeline.go b/appview/db/timeline.go index 0d4b905a..420be24c 100644 --- a/appview/db/timeline.go +++ b/appview/db/timeline.go @@ -183,7 +183,7 @@ func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowi func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { filters := make([]orm.Filter, 0) if userIsFollowing != nil { - filters = append(filters, orm.FilterIn("user_did", userIsFollowing)) + filters = append(filters, orm.FilterIn("did", userIsFollowing)) } follows, err := GetFollows(e, limit, filters...) -- 2.51.2 From f4a09133a636451c495fa96c98b99379b7692497 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 20 Jan 2026 00:15:20 +0900 Subject: [PATCH 05/24] appview/{db,state}: delete all duplicate records on delete Signed-off-by: Seongmin Lee --- appview/db/follow.go | 43 +++++++++++++++++------------------ appview/db/reaction.go | 27 +++++++++++++++++++--- appview/db/star.go | 46 ++++++++++++++++---------------------- appview/state/follow.go | 47 ++++++++++++++++++++++++++------------- appview/state/reaction.go | 36 ++++++++++++++++++++---------- appview/state/star.go | 46 +++++++++++++++++++++++++------------- 6 files changed, 150 insertions(+), 95 deletions(-) diff --git a/appview/db/follow.go b/appview/db/follow.go index d2527ce8..d9d03bf7 100644 --- a/appview/db/follow.go +++ b/appview/db/follow.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/appview/models" "tangled.org/core/orm" ) @@ -16,33 +17,29 @@ func AddFollow(e Execer, follow *models.Follow) error { return err } -// Get a follow record -func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { - query := `select did, subject_did, created, rkey from follows where did = ? and subject_did = ?` - row := e.QueryRow(query, userDid, subjectDid) - - var follow models.Follow - var followedAt string - err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) +// Remove a follow +func DeleteFollow(e Execer, did, subjectDid syntax.DID) ([]syntax.ATURI, error) { + var deleted []syntax.ATURI + rows, err := e.Query( + `delete from follows + where did = ? and subject_did = ? + returning at_uri`, + did, + subjectDid, + ) if err != nil { - return nil, err + return nil, fmt.Errorf("deleting stars: %w", err) } + defer rows.Close() - followedAtTime, err := time.Parse(time.RFC3339, followedAt) - if err != nil { - log.Println("unable to determine followed at time") - follow.FollowedAt = time.Now() - } else { - follow.FollowedAt = followedAtTime + for rows.Next() { + var aturi syntax.ATURI + if err := rows.Scan(&aturi); err != nil { + return nil, fmt.Errorf("scanning at_uri: %w", err) + } + deleted = append(deleted, aturi) } - - return &follow, nil -} - -// Remove a follow -func DeleteFollow(e Execer, userDid, subjectDid string) error { - _, err := e.Exec(`delete from follows where did = ? and subject_did = ?`, userDid, subjectDid) - return err + return deleted, nil } // Remove a follow diff --git a/appview/db/reaction.go b/appview/db/reaction.go index 79d3a02c..f15dc711 100644 --- a/appview/db/reaction.go +++ b/appview/db/reaction.go @@ -1,6 +1,7 @@ package db import ( + "fmt" "log" "time" @@ -41,9 +42,29 @@ func GetReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.React } // Remove a reaction -func DeleteReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind) error { - _, err := e.Exec(`delete from reactions where did = ? and subject_at = ? and kind = ?`, did, subjectAt, kind) - return err +func DeleteReaction(e Execer, did syntax.DID, subjectAt syntax.ATURI, kind models.ReactionKind) ([]syntax.ATURI, error) { + var deleted []syntax.ATURI + rows, err := e.Query( + `delete from reactions + where did = ? and subject_at = ? and kind = ? + returning at_uri`, + did, + subjectAt, + kind, + ) + if err != nil { + return nil, fmt.Errorf("deleting stars: %w", err) + } + defer rows.Close() + + for rows.Next() { + var aturi syntax.ATURI + if err := rows.Scan(&aturi); err != nil { + return nil, fmt.Errorf("scanning at_uri: %w", err) + } + deleted = append(deleted, aturi) + } + return deleted, nil } // Remove a reaction diff --git a/appview/db/star.go b/appview/db/star.go index 5ee36636..83fd00b1 100644 --- a/appview/db/star.go +++ b/appview/db/star.go @@ -4,7 +4,6 @@ import ( "database/sql" "errors" "fmt" - "log" "slices" "strings" "time" @@ -25,36 +24,29 @@ func AddStar(e Execer, star *models.Star) error { return err } -// Get a star record -func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { - query := ` - select did, subject_at, created, rkey - from stars - where did = ? and subject_at = ?` - row := e.QueryRow(query, did, subjectAt) - - var star models.Star - var created string - err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) +// Remove a star +func DeleteStar(tx *sql.Tx, did syntax.DID, subjectAt syntax.ATURI) ([]syntax.ATURI, error) { + var deleted []syntax.ATURI + rows, err := tx.Query( + `delete from stars + where did = ? and subject_at = ? + returning at_uri`, + did, + subjectAt, + ) if err != nil { - return nil, err + return nil, fmt.Errorf("deleting stars: %w", err) } + defer rows.Close() - createdAtTime, err := time.Parse(time.RFC3339, created) - if err != nil { - log.Println("unable to determine followed at time") - star.Created = time.Now() - } else { - star.Created = createdAtTime + for rows.Next() { + var aturi syntax.ATURI + if err := rows.Scan(&aturi); err != nil { + return nil, fmt.Errorf("scanning at_uri: %w", err) + } + deleted = append(deleted, aturi) } - - return &star, nil -} - -// Remove a star -func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { - _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) - return err + return deleted, nil } // Remove a star diff --git a/appview/state/follow.go b/appview/state/follow.go index 73e2781a..689dfe95 100644 --- a/appview/state/follow.go +++ b/appview/state/follow.go @@ -6,6 +6,7 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" @@ -88,30 +89,48 @@ func (s *State) Follow(w http.ResponseWriter, r *http.Request) { return case http.MethodDelete: - // find the record in the db - follow, err := db.GetFollow(s.db, currentUser.Active.Did, subjectIdent.DID.String()) + tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { - log.Println("failed to get follow relationship") + s.logger.Error("failed to start transaction", "err", err) + } + defer tx.Rollback() + + follows, err := db.DeleteFollow(tx, syntax.DID(currentUser.Active.Did), subjectIdent.DID) + if err != nil { + s.logger.Error("failed to delete follows from db", "err", err) return } - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.GraphFollowNSID, - Repo: currentUser.Active.Did, - Rkey: follow.Rkey, + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem + for _, followAt := range follows { + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ + Collection: tangled.GraphFollowNSID, + Rkey: followAt.RecordKey().String(), + }, + }) + } + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ + Repo: currentUser.Active.Did, + Writes: writes, }) - if err != nil { - log.Println("failed to unfollow") + s.logger.Error("failed to delete follows from PDS", "err", err) return } - err = db.DeleteFollowByRkey(s.db, currentUser.Active.Did, follow.Rkey) - if err != nil { - log.Println("failed to delete follow from DB") - // this is not an issue, the firehose event might have already done this + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } + s.notifier.DeleteFollow(r.Context(), &models.Follow{ + UserDid: currentUser.Active.Did, + SubjectDid: subjectIdent.DID.String(), + // Rkey + // FollowedAt + }) + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) if err != nil { log.Println("failed to get follow stats", err) @@ -123,8 +142,6 @@ func (s *State) Follow(w http.ResponseWriter, r *http.Request) { FollowersCount: followStats.Followers, }) - s.notifier.DeleteFollow(r.Context(), follow) - return } diff --git a/appview/state/reaction.go b/appview/state/reaction.go index 4dfceede..54bc4342 100644 --- a/appview/state/reaction.go +++ b/appview/state/reaction.go @@ -87,27 +87,39 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { return case http.MethodDelete: - reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind) + tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { - log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri) + s.logger.Error("failed to start transaction", "err", err) + } + defer tx.Rollback() + + reactions, err := db.DeleteReaction(tx, syntax.DID(currentUser.Active.Did), subjectUri, reactionKind) + if err != nil { + s.logger.Error("failed to delete reactions from db", "err", err) return } - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.FeedReactionNSID, - Repo: currentUser.Active.Did, - Rkey: reaction.Rkey, + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem + for _, reactionAt := range reactions { + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ + Collection: tangled.FeedReactionNSID, + Rkey: reactionAt.RecordKey().String(), + }, + }) + } + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ + Repo: currentUser.Active.Did, + Writes: writes, }) - if err != nil { - log.Println("failed to remove reaction") + s.logger.Error("failed to delete reactions from PDS", "err", err) return } - err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey) - if err != nil { - log.Println("failed to delete reaction from DB") - // this is not an issue, the firehose event might have already done this + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) diff --git a/appview/state/star.go b/appview/state/star.go index 64ceb55a..e150e9de 100644 --- a/appview/state/star.go +++ b/appview/state/star.go @@ -83,38 +83,54 @@ func (s *State) Star(w http.ResponseWriter, r *http.Request) { return case http.MethodDelete: - // find the record in the db - star, err := db.GetStar(s.db, currentUser.Active.Did, subjectUri) + tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { - log.Println("failed to get star relationship") + s.logger.Error("failed to start transaction", "err", err) + } + defer tx.Rollback() + + stars, err := db.DeleteStar(tx, syntax.DID(currentUser.Active.Did), subjectUri) + if err != nil { + s.logger.Error("failed to delete stars from db", "err", err) return } - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.FeedStarNSID, - Repo: currentUser.Active.Did, - Rkey: star.Rkey, + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem + for _, starAt := range stars { + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ + Collection: tangled.FeedStarNSID, + Rkey: starAt.RecordKey().String(), + }, + }) + } + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ + Repo: currentUser.Active.Did, + Writes: writes, }) - if err != nil { - log.Println("failed to unstar") + s.logger.Error("failed to delete stars from PDS", "err", err) return } - err = db.DeleteStarByRkey(s.db, currentUser.Active.Did, star.Rkey) - if err != nil { - log.Println("failed to delete star from DB") - // this is not an issue, the firehose event might have already done this + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } + s.notifier.DeleteStar(r.Context(), &models.Star{ + Did: currentUser.Active.Did, + RepoAt: subjectUri, + // Rkey + // Created + }) + starCount, err := db.GetStarCount(s.db, subjectUri) if err != nil { log.Println("failed to get star count for ", subjectUri) return } - s.notifier.DeleteStar(r.Context(), star) - s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ IsStarred: false, SubjectAt: subjectUri, -- 2.51.2 From 97e1ea946b772baebe76aff24013e2de3defaac1 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 16 Jan 2026 19:41:29 +0900 Subject: [PATCH 06/24] appview: upsert star/reaction/follow records Most service flow will be: 1. start db transaction 2. run db operation 3. run PDS operation 4. rollback db if anything above failed 5. commit transaction If PDS operation succeed, don't try rollback anymore. The ingester will backfill the missed db operations. Signed-off-by: Seongmin Lee --- appview/db/follow.go | 15 ++++++++++--- appview/db/reaction.go | 17 +++++++++++--- appview/db/star.go | 12 ++++++---- appview/ingester.go | 4 ++-- appview/models/follow.go | 9 ++++++++ appview/models/reaction.go | 9 ++++++++ appview/models/star.go | 8 +++++++ appview/state/follow.go | 46 ++++++++++++++++++++++---------------- appview/state/reaction.go | 41 +++++++++++++++++++++------------ appview/state/star.go | 45 ++++++++++++++++++++++--------------- 10 files changed, 143 insertions(+), 63 deletions(-) diff --git a/appview/db/follow.go b/appview/db/follow.go index d9d03bf7..c0e4adcd 100644 --- a/appview/db/follow.go +++ b/appview/db/follow.go @@ -11,9 +11,18 @@ import ( "tangled.org/core/orm" ) -func AddFollow(e Execer, follow *models.Follow) error { - query := `insert or ignore into follows (did, subject_did, rkey) values (?, ?, ?)` - _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) +func UpsertFollow(e Execer, follow models.Follow) error { + _, err := e.Exec( + `insert into follows (did, rkey, subject_did, created) + values (?, ?, ?, ?) + on conflict(did, rkey) do update set + subject_did = excluded.subject_did, + created = excluded.created`, + follow.UserDid, + follow.Rkey, + follow.SubjectDid, + follow.FollowedAt.Format(time.RFC3339), + ) return err } diff --git a/appview/db/reaction.go b/appview/db/reaction.go index f15dc711..62bccb28 100644 --- a/appview/db/reaction.go +++ b/appview/db/reaction.go @@ -9,9 +9,20 @@ import ( "tangled.org/core/appview/models" ) -func AddReaction(e Execer, did string, subjectAt syntax.ATURI, kind models.ReactionKind, rkey string) error { - query := `insert or ignore into reactions (did, subject_at, kind, rkey) values (?, ?, ?, ?)` - _, err := e.Exec(query, did, subjectAt, kind, rkey) +func UpsertReaction(e Execer, reaction models.Reaction) error { + _, err := e.Exec( + `insert into reactions (did, rkey, subject_at, kind, created) + values (?, ?, ?, ?, ?) + on conflict(did, rkey) do update set + subject_at = excluded.subject_at, + kind = excluded.kind, + created = excluded.created`, + reaction.ReactedByDid, + reaction.Rkey, + reaction.ThreadAt, + reaction.Kind, + reaction.Created.Format(time.RFC3339), + ) return err } diff --git a/appview/db/star.go b/appview/db/star.go index 83fd00b1..03bcee4c 100644 --- a/appview/db/star.go +++ b/appview/db/star.go @@ -13,13 +13,17 @@ import ( "tangled.org/core/orm" ) -func AddStar(e Execer, star *models.Star) error { - query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` +func UpsertStar(e Execer, star models.Star) error { _, err := e.Exec( - query, + `insert into stars (did, rkey, subject_at, created) + values (?, ?, ?, ?) + on conflict(did, rkey) do update set + subject_at = excluded.subject_at, + created = excluded.created`, star.Did, - star.RepoAt.String(), star.Rkey, + star.RepoAt, + star.Created.Format(time.RFC3339), ) return err } diff --git a/appview/ingester.go b/appview/ingester.go index 7756ca05..0ef25abe 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -119,7 +119,7 @@ func (i *Ingester) ingestStar(e *jmodels.Event) error { l.Error("invalid record", "err", err) return err } - err = db.AddStar(i.Db, &models.Star{ + err = db.UpsertStar(i.Db, models.Star{ Did: did, RepoAt: subjectUri, Rkey: e.Commit.RKey, @@ -152,7 +152,7 @@ func (i *Ingester) ingestFollow(e *jmodels.Event) error { return err } - err = db.AddFollow(i.Db, &models.Follow{ + err = db.UpsertFollow(i.Db, models.Follow{ UserDid: did, SubjectDid: record.Subject, Rkey: e.Commit.RKey, diff --git a/appview/models/follow.go b/appview/models/follow.go index e9911727..d371226a 100644 --- a/appview/models/follow.go +++ b/appview/models/follow.go @@ -2,6 +2,8 @@ package models import ( "time" + + "tangled.org/core/api/tangled" ) type Follow struct { @@ -11,6 +13,13 @@ type Follow struct { Rkey string } +func (f *Follow) AsRecord() tangled.GraphFollow { + return tangled.GraphFollow{ + Subject: f.SubjectDid, + CreatedAt: f.FollowedAt.Format(time.RFC3339), + } +} + type FollowStats struct { Followers int64 Following int64 diff --git a/appview/models/reaction.go b/appview/models/reaction.go index 3cb21acf..748800f4 100644 --- a/appview/models/reaction.go +++ b/appview/models/reaction.go @@ -4,6 +4,7 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" ) type ReactionKind string @@ -56,6 +57,14 @@ type Reaction struct { Kind ReactionKind } +func (r *Reaction) AsRecord() tangled.FeedReaction { + return tangled.FeedReaction{ + Subject: r.ThreadAt.String(), + Reaction: r.Kind.String(), + CreatedAt: r.Created.Format(time.RFC3339), + } +} + type ReactionDisplayData struct { Count int Users []string diff --git a/appview/models/star.go b/appview/models/star.go index 99d450a0..b0c140d1 100644 --- a/appview/models/star.go +++ b/appview/models/star.go @@ -4,6 +4,7 @@ import ( "time" "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" ) type Star struct { @@ -13,6 +14,13 @@ type Star struct { Rkey string } +func (s *Star) AsRecord() tangled.FeedStar { + return tangled.FeedStar{ + Subject: s.RepoAt.String(), + CreatedAt: s.Created.Format(time.RFC3339), + } +} + // RepoStar is used for reverse mapping to repos type RepoStar struct { Star diff --git a/appview/state/follow.go b/appview/state/follow.go index 689dfe95..800393e7 100644 --- a/appview/state/follow.go +++ b/appview/state/follow.go @@ -43,38 +43,46 @@ func (s *State) Follow(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - createdAt := time.Now().Format(time.RFC3339) - rkey := tid.TID() + follow := models.Follow{ + UserDid: currentUser.Active.Did, + SubjectDid: subjectIdent.DID.String(), + Rkey: tid.TID(), + FollowedAt: time.Now(), + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + s.logger.Error("failed to start transaction", "err", err) + return + } + defer tx.Rollback() + + if err := db.UpsertFollow(tx, follow); err != nil { + s.logger.Error("failed to follow", "err", err) + return + } + + record := follow.AsRecord() resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: currentUser.Active.Did, - Rkey: rkey, + Rkey: follow.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.GraphFollow{ - Subject: subjectIdent.DID.String(), - CreatedAt: createdAt, - }}, + Val: &record, + }, }) if err != nil { log.Println("failed to create atproto record", err) return } - log.Println("created atproto record: ", resp.Uri) - follow := &models.Follow{ - UserDid: currentUser.Active.Did, - SubjectDid: subjectIdent.DID.String(), - Rkey: rkey, - } - - err = db.AddFollow(s.db, follow) - if err != nil { - log.Println("failed to follow", err) - return + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } - s.notifier.NewFollow(r.Context(), follow) + s.notifier.NewFollow(r.Context(), &follow) followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) if err != nil { diff --git a/appview/state/reaction.go b/appview/state/reaction.go index 54bc4342..0850e647 100644 --- a/appview/state/reaction.go +++ b/appview/state/reaction.go @@ -45,29 +45,44 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - createdAt := time.Now().Format(time.RFC3339) - rkey := tid.TID() + reaction := models.Reaction{ + ReactedByDid: currentUser.Active.Did, + Rkey: tid.TID(), + Kind: reactionKind, + ThreadAt: subjectUri, + Created: time.Now(), + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + s.logger.Error("failed to start transaction", "err", err) + return + } + defer tx.Rollback() + + if err := db.UpsertReaction(tx, reaction); err != nil { + log.Println("failed to react", err) + return + } + + record := reaction.AsRecord() resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.FeedReactionNSID, Repo: currentUser.Active.Did, - Rkey: rkey, + Rkey: reaction.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.FeedReaction{ - Subject: subjectUri.String(), - Reaction: reactionKind.String(), - CreatedAt: createdAt, - }, + Val: &record, }, }) if err != nil { log.Println("failed to create atproto record", err) return } + log.Println("created atproto record: ", resp.Uri) - err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey) - if err != nil { - log.Println("failed to react", err) - return + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) @@ -75,8 +90,6 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { log.Println("failed to get reactions for ", subjectUri) } - log.Println("created atproto record: ", resp.Uri) - s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ ThreadAt: subjectUri, Kind: reactionKind, diff --git a/appview/state/star.go b/appview/state/star.go index e150e9de..101ba6f9 100644 --- a/appview/state/star.go +++ b/appview/state/star.go @@ -38,17 +38,33 @@ func (s *State) Star(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - createdAt := time.Now().Format(time.RFC3339) - rkey := tid.TID() + star := models.Star{ + Did: currentUser.Active.Did, + Rkey: tid.TID(), + RepoAt: subjectUri, + Created: time.Now(), + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + s.logger.Error("failed to start transaction", "err", err) + return + } + defer tx.Rollback() + + if err := db.UpsertStar(tx, star); err != nil { + s.logger.Error("failed to star", "err", err) + return + } + + record := star.AsRecord() resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.FeedStarNSID, Repo: currentUser.Active.Did, - Rkey: rkey, + Rkey: star.Rkey, Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.FeedStar{ - Subject: subjectUri.String(), - CreatedAt: createdAt, - }}, + Val: &record, + }, }) if err != nil { log.Println("failed to create atproto record", err) @@ -56,25 +72,18 @@ func (s *State) Star(w http.ResponseWriter, r *http.Request) { } log.Println("created atproto record: ", resp.Uri) - star := &models.Star{ - Did: currentUser.Active.Did, - RepoAt: subjectUri, - Rkey: rkey, + if err := tx.Commit(); err != nil { + s.logger.Error("failed to commit transaction", "err", err) + // DB op failed but record is created in PDS. Ingester will backfill the missed operation } - err = db.AddStar(s.db, star) - if err != nil { - log.Println("failed to star", err) - return - } + s.notifier.NewStar(r.Context(), &star) starCount, err := db.GetStarCount(s.db, subjectUri) if err != nil { log.Println("failed to get star count for ", subjectUri) } - s.notifier.NewStar(r.Context(), star) - s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ IsStarred: true, SubjectAt: subjectUri, -- 2.51.2 From 571811462658b04e8a6ed49dac73b4e0d117b506 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 20 Jan 2026 01:05:48 +0900 Subject: [PATCH 07/24] appview: handle non-unique publicKey records - upsert public key to handle record update event - don't delete by pair of name and key. delete by name or rkey instead. Signed-off-by: Seongmin Lee --- appview/db/pubkeys.go | 24 ++++-- appview/ingester.go | 16 +++- appview/models/pubkey.go | 38 ++++++++++ .../user/settings/fragments/keyListing.html | 2 +- appview/settings/settings.go | 75 ++++++++++--------- 5 files changed, 108 insertions(+), 47 deletions(-) diff --git a/appview/db/pubkeys.go b/appview/db/pubkeys.go index c62525f2..12627f7a 100644 --- a/appview/db/pubkeys.go +++ b/appview/db/pubkeys.go @@ -5,19 +5,29 @@ import ( "time" ) -func AddPublicKey(e Execer, did, name, key, rkey string) error { +func UpsertPublicKey(e Execer, pubKey models.PublicKey) error { _, err := e.Exec( - `insert or ignore into public_keys (did, name, key, rkey) - values (?, ?, ?, ?)`, - did, name, key, rkey) + `insert into public_keys (did, rkey, name, key, created) + values (?, ?, ?, ?, ?) + on conflict(did, rkey) do update set + name = excluded.name, + key = excluded.key, + created = excluded.created`, + pubKey.Did, + pubKey.Rkey, + pubKey.Name, + pubKey.Key, + pubKey.Created.Format(time.RFC3339), + ) return err } -func DeletePublicKey(e Execer, did, name, key string) error { +// for public_keys with empty rkey +func DeletePublicKeyLegacy(e Execer, did, name string) error { _, err := e.Exec(` delete from public_keys - where did = ? and name = ? and key = ?`, - did, name, key) + where did = ? and name = ? and rkey = ''`, + did, name) return err } diff --git a/appview/ingester.go b/appview/ingester.go index 0ef25abe..867e421e 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -131,6 +131,7 @@ func (i *Ingester) ingestStar(e *jmodels.Event) error { if err != nil { return fmt.Errorf("failed to %s star record: %w", e.Commit.Operation, err) } + l.Info("processed star", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) return nil } @@ -164,6 +165,7 @@ func (i *Ingester) ingestFollow(e *jmodels.Event) error { if err != nil { return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) } + l.Info("processed follow", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) return nil } @@ -185,10 +187,17 @@ func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { l.Error("invalid record", "err", err) return err } + pubKey, err := models.PublicKeyFromRecord(syntax.DID(did), syntax.RecordKey(e.Commit.RKey), record) + if err != nil { + l.Error("invalid record", "err", err) + return err + } + if err := pubKey.Validate(); err != nil { + l.Error("invalid record", "err", err) + return err + } - name := record.Name - key := record.Key - err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) + err = db.UpsertPublicKey(i.Db, pubKey) case jmodels.CommitOperationDelete: l.Debug("processing delete of pubkey") err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) @@ -197,6 +206,7 @@ func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { if err != nil { return fmt.Errorf("failed to %s pubkey record: %w", e.Commit.Operation, err) } + l.Info("processed pubkey", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) return nil } diff --git a/appview/models/pubkey.go b/appview/models/pubkey.go index c2d64e26..9c367512 100644 --- a/appview/models/pubkey.go +++ b/appview/models/pubkey.go @@ -2,7 +2,12 @@ package models import ( "encoding/json" + "fmt" "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/gliderlabs/ssh" + "tangled.org/core/api/tangled" ) type PublicKey struct { @@ -23,3 +28,36 @@ func (p PublicKey) MarshalJSON() ([]byte, error) { Alias: (*Alias)(&p), }) } + +func (p *PublicKey) AsRecord() tangled.PublicKey { + return tangled.PublicKey{ + Name: p.Name, + Key: p.Key, + CreatedAt: p.Created.Format(time.RFC3339), + } +} + +var _ Validator = new(PublicKey) + +func (p *PublicKey) Validate() error { + if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(p.Key)); err != nil { + return fmt.Errorf("invalid ssh key format: %w", err) + } + + return nil +} + +func PublicKeyFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.PublicKey) (PublicKey, error) { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + return PublicKey{}, fmt.Errorf("invalid time format '%s'", record.CreatedAt) + } + + return PublicKey{ + Did: did.String(), + Rkey: rkey.String(), + Name: record.Name, + Key: record.Key, + Created: &created, + }, nil +} diff --git a/appview/pages/templates/user/settings/fragments/keyListing.html b/appview/pages/templates/user/settings/fragments/keyListing.html index a645f3f9..5d84dfff 100644 --- a/appview/pages/templates/user/settings/fragments/keyListing.html +++ b/appview/pages/templates/user/settings/fragments/keyListing.html @@ -19,7 +19,7 @@