diff --git a/api/tangled/accountbeginSignup.go b/api/tangled/accountbeginSignup.go new file mode 100644 index 00000000..f503b7e8 --- /dev/null +++ b/api/tangled/accountbeginSignup.go @@ -0,0 +1,32 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.beginSignup + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountBeginSignupNSID = "org.tangled.temp.account.beginSignup" +) + +// TempAccountBeginSignup_Input is the input argument to a org.tangled.temp.account.beginSignup call. +type TempAccountBeginSignup_Input struct { + // email: Email address for the new account. + Email string `json:"email" cborgen:"email"` + // turnstileToken: Cloudflare Turnstile challenge response token. + TurnstileToken string `json:"turnstileToken" cborgen:"turnstileToken"` +} + +// TempAccountBeginSignup calls the XRPC method "org.tangled.temp.account.beginSignup". +func TempAccountBeginSignup(ctx context.Context, c util.LexClient, input *TempAccountBeginSignup_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.account.beginSignup", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/accountcompleteSignup.go b/api/tangled/accountcompleteSignup.go new file mode 100644 index 00000000..57a11eda --- /dev/null +++ b/api/tangled/accountcompleteSignup.go @@ -0,0 +1,43 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.completeSignup + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountCompleteSignupNSID = "org.tangled.temp.account.completeSignup" +) + +// TempAccountCompleteSignup_Input is the input argument to a org.tangled.temp.account.completeSignup call. +type TempAccountCompleteSignup_Input struct { + // code: Verification code from the signup email. + Code string `json:"code" cborgen:"code"` + // password: Password for the new account. + Password string `json:"password" cborgen:"password"` + // username: Desired username; the assigned handle is .. + Username string `json:"username" cborgen:"username"` +} + +// TempAccountCompleteSignup_Output is the output of a org.tangled.temp.account.completeSignup call. +type TempAccountCompleteSignup_Output struct { + // did: DID of the newly provisioned account. + Did string `json:"did" cborgen:"did"` + // handle: The assigned handle, .. + Handle string `json:"handle" cborgen:"handle"` +} + +// TempAccountCompleteSignup calls the XRPC method "org.tangled.temp.account.completeSignup". +func TempAccountCompleteSignup(ctx context.Context, c util.LexClient, input *TempAccountCompleteSignup_Input) (*TempAccountCompleteSignup_Output, error) { + var out TempAccountCompleteSignup_Output + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.account.completeSignup", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/accountdeleteEmail.go b/api/tangled/accountdeleteEmail.go new file mode 100644 index 00000000..35af9452 --- /dev/null +++ b/api/tangled/accountdeleteEmail.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.deleteEmail + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountDeleteEmailNSID = "org.tangled.temp.account.deleteEmail" +) + +// TempAccountDeleteEmail_Input is the input argument to a org.tangled.temp.account.deleteEmail call. +type TempAccountDeleteEmail_Input struct { + // email: Email address to remove. + Email string `json:"email" cborgen:"email"` +} + +// TempAccountDeleteEmail calls the XRPC method "org.tangled.temp.account.deleteEmail". +func TempAccountDeleteEmail(ctx context.Context, c util.LexClient, input *TempAccountDeleteEmail_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.account.deleteEmail", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/accountdismissNewsletter.go b/api/tangled/accountdismissNewsletter.go new file mode 100644 index 00000000..03f7bc32 --- /dev/null +++ b/api/tangled/accountdismissNewsletter.go @@ -0,0 +1,24 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.dismissNewsletter + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountDismissNewsletterNSID = "org.tangled.temp.account.dismissNewsletter" +) + +// TempAccountDismissNewsletter calls the XRPC method "org.tangled.temp.account.dismissNewsletter". +func TempAccountDismissNewsletter(ctx context.Context, c util.LexClient) error { + if err := c.LexDo(ctx, util.Procedure, "", "org.tangled.temp.account.dismissNewsletter", nil, nil, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/accountlistEmails.go b/api/tangled/accountlistEmails.go new file mode 100644 index 00000000..45accf48 --- /dev/null +++ b/api/tangled/accountlistEmails.go @@ -0,0 +1,41 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.listEmails + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountListEmailsNSID = "org.tangled.temp.account.listEmails" +) + +// TempAccountListEmails_Email is a "email" in the org.tangled.temp.account.listEmails schema. +type TempAccountListEmails_Email struct { + // address: Email address. + Address string `json:"address" cborgen:"address"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // primary: Whether this is the primary email address. + Primary bool `json:"primary" cborgen:"primary"` + // verified: Whether the address has been verified. + Verified bool `json:"verified" cborgen:"verified"` +} + +// TempAccountListEmails_Output is the output of a org.tangled.temp.account.listEmails call. +type TempAccountListEmails_Output struct { + Emails []*TempAccountListEmails_Email `json:"emails" cborgen:"emails"` +} + +// TempAccountListEmails calls the XRPC method "org.tangled.temp.account.listEmails". +func TempAccountListEmails(ctx context.Context, c util.LexClient) (*TempAccountListEmails_Output, error) { + var out TempAccountListEmails_Output + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.account.listEmails", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/accountsetPrimaryEmail.go b/api/tangled/accountsetPrimaryEmail.go new file mode 100644 index 00000000..ebf4d558 --- /dev/null +++ b/api/tangled/accountsetPrimaryEmail.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.setPrimaryEmail + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountSetPrimaryEmailNSID = "org.tangled.temp.account.setPrimaryEmail" +) + +// TempAccountSetPrimaryEmail_Input is the input argument to a org.tangled.temp.account.setPrimaryEmail call. +type TempAccountSetPrimaryEmail_Input struct { + // email: Email address to promote to primary. + Email string `json:"email" cborgen:"email"` +} + +// TempAccountSetPrimaryEmail calls the XRPC method "org.tangled.temp.account.setPrimaryEmail". +func TempAccountSetPrimaryEmail(ctx context.Context, c util.LexClient, input *TempAccountSetPrimaryEmail_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.account.setPrimaryEmail", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/accountsubscribeNewsletter.go b/api/tangled/accountsubscribeNewsletter.go new file mode 100644 index 00000000..0732deee --- /dev/null +++ b/api/tangled/accountsubscribeNewsletter.go @@ -0,0 +1,24 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.account.subscribeNewsletter + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempAccountSubscribeNewsletterNSID = "org.tangled.temp.account.subscribeNewsletter" +) + +// TempAccountSubscribeNewsletter calls the XRPC method "org.tangled.temp.account.subscribeNewsletter". +func TempAccountSubscribeNewsletter(ctx context.Context, c util.LexClient) error { + if err := c.LexDo(ctx, util.Procedure, "", "org.tangled.temp.account.subscribeNewsletter", nil, nil, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/focusbeginSession.go b/api/tangled/focusbeginSession.go new file mode 100644 index 00000000..ea88d043 --- /dev/null +++ b/api/tangled/focusbeginSession.go @@ -0,0 +1,37 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.focus.beginSession + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempFocusBeginSessionNSID = "org.tangled.temp.focus.beginSession" +) + +// TempFocusBeginSession_Output is the output of a org.tangled.temp.focus.beginSession call. +type TempFocusBeginSession_Output struct { + // issueAt: AT-URI of the issue to navigate to, if the item is an issue. + IssueAt *string `json:"issueAt,omitempty" cborgen:"issueAt,omitempty"` + // notificationId: ID of the first notification to address. Absent if the queue is empty. + NotificationId *int64 `json:"notificationId,omitempty" cborgen:"notificationId,omitempty"` + // pullAt: AT-URI of the pull to navigate to, if the item is a pull. + PullAt *string `json:"pullAt,omitempty" cborgen:"pullAt,omitempty"` + // repoDid: DID of the repository the item belongs to. Absent if the queue is empty. + RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"` +} + +// TempFocusBeginSession calls the XRPC method "org.tangled.temp.focus.beginSession". +func TempFocusBeginSession(ctx context.Context, c util.LexClient) (*TempFocusBeginSession_Output, error) { + var out TempFocusBeginSession_Output + if err := c.LexDo(ctx, util.Procedure, "", "org.tangled.temp.focus.beginSession", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/focusendSession.go b/api/tangled/focusendSession.go new file mode 100644 index 00000000..88f07d35 --- /dev/null +++ b/api/tangled/focusendSession.go @@ -0,0 +1,24 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.focus.endSession + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempFocusEndSessionNSID = "org.tangled.temp.focus.endSession" +) + +// TempFocusEndSession calls the XRPC method "org.tangled.temp.focus.endSession". +func TempFocusEndSession(ctx context.Context, c util.LexClient) error { + if err := c.LexDo(ctx, util.Procedure, "", "org.tangled.temp.focus.endSession", nil, nil, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/focusnextItem.go b/api/tangled/focusnextItem.go new file mode 100644 index 00000000..1563ebe1 --- /dev/null +++ b/api/tangled/focusnextItem.go @@ -0,0 +1,43 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.focus.nextItem + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempFocusNextItemNSID = "org.tangled.temp.focus.nextItem" +) + +// TempFocusNextItem_Input is the input argument to a org.tangled.temp.focus.nextItem call. +type TempFocusNextItem_Input struct { + // currentId: ID of the notification just addressed, to be marked read. + CurrentId int64 `json:"currentId" cborgen:"currentId"` +} + +// TempFocusNextItem_Output is the output of a org.tangled.temp.focus.nextItem call. +type TempFocusNextItem_Output struct { + // issueAt: AT-URI of the issue to navigate to, if the item is an issue. + IssueAt *string `json:"issueAt,omitempty" cborgen:"issueAt,omitempty"` + // notificationId: ID of the next notification to address. Absent when focus mode has ended. + NotificationId *int64 `json:"notificationId,omitempty" cborgen:"notificationId,omitempty"` + // pullAt: AT-URI of the pull to navigate to, if the item is a pull. + PullAt *string `json:"pullAt,omitempty" cborgen:"pullAt,omitempty"` + // repoDid: DID of the repository the next item belongs to. Absent when focus mode has ended. + RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"` +} + +// TempFocusNextItem calls the XRPC method "org.tangled.temp.focus.nextItem". +func TempFocusNextItem(ctx context.Context, c util.LexClient, input *TempFocusNextItem_Input) (*TempFocusNextItem_Output, error) { + var out TempFocusNextItem_Output + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.focus.nextItem", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/notificationdeleteNotification.go b/api/tangled/notificationdeleteNotification.go new file mode 100644 index 00000000..895994f8 --- /dev/null +++ b/api/tangled/notificationdeleteNotification.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.deleteNotification + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationDeleteNotificationNSID = "org.tangled.temp.notification.deleteNotification" +) + +// TempNotificationDeleteNotification_Input is the input argument to a org.tangled.temp.notification.deleteNotification call. +type TempNotificationDeleteNotification_Input struct { + // id: ID of the notification to delete. + Id int64 `json:"id" cborgen:"id"` +} + +// TempNotificationDeleteNotification calls the XRPC method "org.tangled.temp.notification.deleteNotification". +func TempNotificationDeleteNotification(ctx context.Context, c util.LexClient, input *TempNotificationDeleteNotification_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.notification.deleteNotification", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/notificationgetPreferences.go b/api/tangled/notificationgetPreferences.go new file mode 100644 index 00000000..171413bb --- /dev/null +++ b/api/tangled/notificationgetPreferences.go @@ -0,0 +1,39 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.getPreferences + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationGetPreferencesNSID = "org.tangled.temp.notification.getPreferences" +) + +// TempNotificationGetPreferences_Preferences is a "preferences" in the org.tangled.temp.notification.getPreferences schema. +type TempNotificationGetPreferences_Preferences struct { + EmailNotifications bool `json:"emailNotifications" cborgen:"emailNotifications"` + Followed bool `json:"followed" cborgen:"followed"` + IssueClosed bool `json:"issueClosed" cborgen:"issueClosed"` + IssueCommented bool `json:"issueCommented" cborgen:"issueCommented"` + IssueCreated bool `json:"issueCreated" cborgen:"issueCreated"` + PullCommented bool `json:"pullCommented" cborgen:"pullCommented"` + PullCreated bool `json:"pullCreated" cborgen:"pullCreated"` + PullMerged bool `json:"pullMerged" cborgen:"pullMerged"` + RepoStarred bool `json:"repoStarred" cborgen:"repoStarred"` + UserMentioned bool `json:"userMentioned" cborgen:"userMentioned"` +} + +// TempNotificationGetPreferences calls the XRPC method "org.tangled.temp.notification.getPreferences". +func TempNotificationGetPreferences(ctx context.Context, c util.LexClient) (*TempNotificationGetPreferences_Preferences, error) { + var out TempNotificationGetPreferences_Preferences + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.notification.getPreferences", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/notificationgetUnreadCount.go b/api/tangled/notificationgetUnreadCount.go new file mode 100644 index 00000000..67908382 --- /dev/null +++ b/api/tangled/notificationgetUnreadCount.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.getUnreadCount + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationGetUnreadCountNSID = "org.tangled.temp.notification.getUnreadCount" +) + +// TempNotificationGetUnreadCount_Output is the output of a org.tangled.temp.notification.getUnreadCount call. +type TempNotificationGetUnreadCount_Output struct { + Count int64 `json:"count" cborgen:"count"` +} + +// TempNotificationGetUnreadCount calls the XRPC method "org.tangled.temp.notification.getUnreadCount". +func TempNotificationGetUnreadCount(ctx context.Context, c util.LexClient) (*TempNotificationGetUnreadCount_Output, error) { + var out TempNotificationGetUnreadCount_Output + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.notification.getUnreadCount", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/notificationlistNotifications.go b/api/tangled/notificationlistNotifications.go new file mode 100644 index 00000000..dc0ccc7d --- /dev/null +++ b/api/tangled/notificationlistNotifications.go @@ -0,0 +1,71 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.listNotifications + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationListNotificationsNSID = "org.tangled.temp.notification.listNotifications" +) + +// TempNotificationListNotifications_Notification is a "notification" in the org.tangled.temp.notification.listNotifications schema. +type TempNotificationListNotifications_Notification struct { + // actorDid: DID of the user who triggered this notification. + ActorDid string `json:"actorDid" cborgen:"actorDid"` + // category: Broad category: 'social' or 'work'. + Category string `json:"category" cborgen:"category"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // id: Stable numeric ID for this notification. + Id int64 `json:"id" cborgen:"id"` + // issueAt: AT-URI of the related org.tangled.issue.issue record, if applicable. + IssueAt *string `json:"issueAt,omitempty" cborgen:"issueAt,omitempty"` + // pullAt: AT-URI of the related org.tangled.pulls.pull record, if applicable. + PullAt *string `json:"pullAt,omitempty" cborgen:"pullAt,omitempty"` + Read bool `json:"read" cborgen:"read"` + // repoDid: DID of the related repository, if applicable. + RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"` + // type: Notification type: repo_starred, issue_created, issue_commented, issue_closed, issue_reopen, issue_assigned, issue_unassigned, pull_created, pull_commented, pull_merged, pull_closed, pull_reopen, pull_assigned, pull_unassigned, followed, user_mentioned. + Type string `json:"type" cborgen:"type"` +} + +// TempNotificationListNotifications_Output is the output of a org.tangled.temp.notification.listNotifications call. +type TempNotificationListNotifications_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Notifications []*TempNotificationListNotifications_Notification `json:"notifications" cborgen:"notifications"` + SocialUnreadCount int64 `json:"socialUnreadCount" cborgen:"socialUnreadCount"` + WorkUnreadCount int64 `json:"workUnreadCount" cborgen:"workUnreadCount"` +} + +// TempNotificationListNotifications calls the XRPC method "org.tangled.temp.notification.listNotifications". +// +// category: Filter by category: 'all', 'social', or 'work'. +// limit: Max notifications per category to return. +// read: Filter by read state: 'all' or 'unread'. +func TempNotificationListNotifications(ctx context.Context, c util.LexClient, category string, cursor string, limit int64, read string) (*TempNotificationListNotifications_Output, error) { + var out TempNotificationListNotifications_Output + + params := map[string]interface{}{} + if category != "" { + params["category"] = category + } + if cursor != "" { + params["cursor"] = cursor + } + if limit != 0 { + params["limit"] = limit + } + if read != "" { + params["read"] = read + } + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.notification.listNotifications", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/notificationmarkAllRead.go b/api/tangled/notificationmarkAllRead.go new file mode 100644 index 00000000..239bc1fe --- /dev/null +++ b/api/tangled/notificationmarkAllRead.go @@ -0,0 +1,24 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.markAllRead + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationMarkAllReadNSID = "org.tangled.temp.notification.markAllRead" +) + +// TempNotificationMarkAllRead calls the XRPC method "org.tangled.temp.notification.markAllRead". +func TempNotificationMarkAllRead(ctx context.Context, c util.LexClient) error { + if err := c.LexDo(ctx, util.Procedure, "", "org.tangled.temp.notification.markAllRead", nil, nil, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/notificationupdatePreferences.go b/api/tangled/notificationupdatePreferences.go new file mode 100644 index 00000000..f356fbc3 --- /dev/null +++ b/api/tangled/notificationupdatePreferences.go @@ -0,0 +1,38 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.updatePreferences + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationUpdatePreferencesNSID = "org.tangled.temp.notification.updatePreferences" +) + +// TempNotificationUpdatePreferences_Input is the input argument to a org.tangled.temp.notification.updatePreferences call. +type TempNotificationUpdatePreferences_Input struct { + EmailNotifications *bool `json:"emailNotifications,omitempty" cborgen:"emailNotifications,omitempty"` + Followed *bool `json:"followed,omitempty" cborgen:"followed,omitempty"` + IssueClosed *bool `json:"issueClosed,omitempty" cborgen:"issueClosed,omitempty"` + IssueCommented *bool `json:"issueCommented,omitempty" cborgen:"issueCommented,omitempty"` + IssueCreated *bool `json:"issueCreated,omitempty" cborgen:"issueCreated,omitempty"` + PullCommented *bool `json:"pullCommented,omitempty" cborgen:"pullCommented,omitempty"` + PullCreated *bool `json:"pullCreated,omitempty" cborgen:"pullCreated,omitempty"` + PullMerged *bool `json:"pullMerged,omitempty" cborgen:"pullMerged,omitempty"` + RepoStarred *bool `json:"repoStarred,omitempty" cborgen:"repoStarred,omitempty"` + UserMentioned *bool `json:"userMentioned,omitempty" cborgen:"userMentioned,omitempty"` +} + +// TempNotificationUpdatePreferences calls the XRPC method "org.tangled.temp.notification.updatePreferences". +func TempNotificationUpdatePreferences(ctx context.Context, c util.LexClient, input *TempNotificationUpdatePreferences_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.notification.updatePreferences", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/notificationupdateSeen.go b/api/tangled/notificationupdateSeen.go new file mode 100644 index 00000000..e263e44a --- /dev/null +++ b/api/tangled/notificationupdateSeen.go @@ -0,0 +1,31 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.updateSeen + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationUpdateSeenNSID = "org.tangled.temp.notification.updateSeen" +) + +// TempNotificationUpdateSeen_Input is the input argument to a org.tangled.temp.notification.updateSeen call. +type TempNotificationUpdateSeen_Input struct { + // id: ID of the notification to update. + Id int64 `json:"id" cborgen:"id"` + Read bool `json:"read" cborgen:"read"` +} + +// TempNotificationUpdateSeen calls the XRPC method "org.tangled.temp.notification.updateSeen". +func TempNotificationUpdateSeen(ctx context.Context, c util.LexClient, input *TempNotificationUpdateSeen_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.notification.updateSeen", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/repocreate.go b/api/tangled/repocreate.go index 441d268d..94b3c1cb 100644 --- a/api/tangled/repocreate.go +++ b/api/tangled/repocreate.go @@ -30,6 +30,8 @@ type RepoCreate_Input struct { // RepoCreate_Output is the output of a sh.tangled.repo.create call. type RepoCreate_Output struct { + // key: Multibase-encoded public signing key the knot holds for this repository + Key *string `json:"key,omitempty" cborgen:"key,omitempty"` RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"` } diff --git a/api/tangled/repocreateWebhook.go b/api/tangled/repocreateWebhook.go new file mode 100644 index 00000000..d38172d0 --- /dev/null +++ b/api/tangled/repocreateWebhook.go @@ -0,0 +1,45 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.createWebhook + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoCreateWebhookNSID = "org.tangled.temp.repo.createWebhook" +) + +// TempRepoCreateWebhook_Input is the input argument to a org.tangled.temp.repo.createWebhook call. +type TempRepoCreateWebhook_Input struct { + // active: Whether the webhook should be active immediately. Defaults to true. + Active *bool `json:"active,omitempty" cborgen:"active,omitempty"` + // events: Event types to subscribe to (e.g. 'push', 'repository:renamed'). + Events []string `json:"events" cborgen:"events"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` + // secret: Optional HMAC secret used to sign payloads. If omitted, payloads are not signed. + Secret *string `json:"secret,omitempty" cborgen:"secret,omitempty"` + // url: Endpoint URL that will receive webhook payloads. + Url string `json:"url" cborgen:"url"` +} + +// TempRepoCreateWebhook_Output is the output of a org.tangled.temp.repo.createWebhook call. +type TempRepoCreateWebhook_Output struct { + // id: ID of the newly created webhook. + Id int64 `json:"id" cborgen:"id"` +} + +// TempRepoCreateWebhook calls the XRPC method "org.tangled.temp.repo.createWebhook". +func TempRepoCreateWebhook(ctx context.Context, c util.LexClient, input *TempRepoCreateWebhook_Input) (*TempRepoCreateWebhook_Output, error) { + var out TempRepoCreateWebhook_Output + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.createWebhook", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/repodelete.go b/api/tangled/repodelete.go index d8191c2d..696b94fd 100644 --- a/api/tangled/repodelete.go +++ b/api/tangled/repodelete.go @@ -18,6 +18,8 @@ const ( type RepoDelete_Input struct { // did: DID of the repository owner Did string `json:"did" cborgen:"did"` + // force: Admin-only. Delete even though the repository record still exists on the owner's PDS. + Force *bool `json:"force,omitempty" cborgen:"force,omitempty"` // name: Name of the repository to delete Name string `json:"name" cborgen:"name"` // rkey: Rkey of the repository record diff --git a/api/tangled/repodeleteWebhook.go b/api/tangled/repodeleteWebhook.go new file mode 100644 index 00000000..3f4bd6a5 --- /dev/null +++ b/api/tangled/repodeleteWebhook.go @@ -0,0 +1,32 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.deleteWebhook + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoDeleteWebhookNSID = "org.tangled.temp.repo.deleteWebhook" +) + +// TempRepoDeleteWebhook_Input is the input argument to a org.tangled.temp.repo.deleteWebhook call. +type TempRepoDeleteWebhook_Input struct { + // id: Webhook ID to delete. + Id int64 `json:"id" cborgen:"id"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` +} + +// TempRepoDeleteWebhook calls the XRPC method "org.tangled.temp.repo.deleteWebhook". +func TempRepoDeleteWebhook(ctx context.Context, c util.LexClient, input *TempRepoDeleteWebhook_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.deleteWebhook", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/repodisableSite.go b/api/tangled/repodisableSite.go new file mode 100644 index 00000000..110e405f --- /dev/null +++ b/api/tangled/repodisableSite.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.disableSite + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoDisableSiteNSID = "org.tangled.temp.repo.disableSite" +) + +// TempRepoDisableSite_Input is the input argument to a org.tangled.temp.repo.disableSite call. +type TempRepoDisableSite_Input struct { + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` +} + +// TempRepoDisableSite calls the XRPC method "org.tangled.temp.repo.disableSite". +func TempRepoDisableSite(ctx context.Context, c util.LexClient, input *TempRepoDisableSite_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.disableSite", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/repogetSiteConfig.go b/api/tangled/repogetSiteConfig.go new file mode 100644 index 00000000..a07b1bb7 --- /dev/null +++ b/api/tangled/repogetSiteConfig.go @@ -0,0 +1,46 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.getSiteConfig + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoGetSiteConfigNSID = "org.tangled.temp.repo.getSiteConfig" +) + +// TempRepoGetSiteConfig_Output is the output of a org.tangled.temp.repo.getSiteConfig call. +type TempRepoGetSiteConfig_Output struct { + // config: Site config for the repository. Absent if no site is configured. + Config *TempRepoGetSiteConfig_SiteConfig `json:"config,omitempty" cborgen:"config,omitempty"` +} + +// TempRepoGetSiteConfig_SiteConfig is a "siteConfig" in the org.tangled.temp.repo.getSiteConfig schema. +type TempRepoGetSiteConfig_SiteConfig struct { + // branch: Branch to deploy from. + Branch string `json:"branch" cborgen:"branch"` + // dir: Directory within the repository to deploy (e.g. '/' or '/docs'). + Dir string `json:"dir" cborgen:"dir"` + // isIndex: Whether this repo serves as the index (root) for the domain. + IsIndex bool `json:"isIndex" cborgen:"isIndex"` +} + +// TempRepoGetSiteConfig calls the XRPC method "org.tangled.temp.repo.getSiteConfig". +// +// repoDid: DID of the repository as minted by the knot. +func TempRepoGetSiteConfig(ctx context.Context, c util.LexClient, repoDid string) (*TempRepoGetSiteConfig_Output, error) { + var out TempRepoGetSiteConfig_Output + + params := map[string]interface{}{} + params["repoDid"] = repoDid + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.repo.getSiteConfig", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/repolistWebhookDeliveries.go b/api/tangled/repolistWebhookDeliveries.go new file mode 100644 index 00000000..d46a5c12 --- /dev/null +++ b/api/tangled/repolistWebhookDeliveries.go @@ -0,0 +1,55 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.listWebhookDeliveries + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoListWebhookDeliveriesNSID = "org.tangled.temp.repo.listWebhookDeliveries" +) + +// TempRepoListWebhookDeliveries_Delivery is a "delivery" in the org.tangled.temp.repo.listWebhookDeliveries schema. +type TempRepoListWebhookDeliveries_Delivery struct { + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // deliveryId: UUID for tracking this delivery attempt. + DeliveryId string `json:"deliveryId" cborgen:"deliveryId"` + // event: Event type that triggered the delivery. + Event string `json:"event" cborgen:"event"` + Id int64 `json:"id" cborgen:"id"` + RequestBody *string `json:"requestBody,omitempty" cborgen:"requestBody,omitempty"` + ResponseBody *string `json:"responseBody,omitempty" cborgen:"responseBody,omitempty"` + ResponseCode *int64 `json:"responseCode,omitempty" cborgen:"responseCode,omitempty"` + Success bool `json:"success" cborgen:"success"` + Url string `json:"url" cborgen:"url"` +} + +// TempRepoListWebhookDeliveries_Output is the output of a org.tangled.temp.repo.listWebhookDeliveries call. +type TempRepoListWebhookDeliveries_Output struct { + Deliveries []*TempRepoListWebhookDeliveries_Delivery `json:"deliveries" cborgen:"deliveries"` +} + +// TempRepoListWebhookDeliveries calls the XRPC method "org.tangled.temp.repo.listWebhookDeliveries". +// +// id: Webhook ID. +// repoDid: DID of the repository as minted by the knot. +func TempRepoListWebhookDeliveries(ctx context.Context, c util.LexClient, id int64, limit int64, repoDid string) (*TempRepoListWebhookDeliveries_Output, error) { + var out TempRepoListWebhookDeliveries_Output + + params := map[string]interface{}{} + params["id"] = id + if limit != 0 { + params["limit"] = limit + } + params["repoDid"] = repoDid + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.repo.listWebhookDeliveries", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/repolistWebhooks.go b/api/tangled/repolistWebhooks.go new file mode 100644 index 00000000..719ca25e --- /dev/null +++ b/api/tangled/repolistWebhooks.go @@ -0,0 +1,49 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.listWebhooks + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoListWebhooksNSID = "org.tangled.temp.repo.listWebhooks" +) + +// TempRepoListWebhooks_Output is the output of a org.tangled.temp.repo.listWebhooks call. +type TempRepoListWebhooks_Output struct { + Webhooks []*TempRepoListWebhooks_Webhook `json:"webhooks" cborgen:"webhooks"` +} + +// TempRepoListWebhooks_Webhook is a "webhook" in the org.tangled.temp.repo.listWebhooks schema. +type TempRepoListWebhooks_Webhook struct { + // active: Whether the webhook is currently enabled. + Active bool `json:"active" cborgen:"active"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // events: Event types this webhook is subscribed to (e.g. 'push', 'repository:renamed'). + Events []string `json:"events" cborgen:"events"` + // id: Webhook identifier. + Id int64 `json:"id" cborgen:"id"` + UpdatedAt *string `json:"updatedAt,omitempty" cborgen:"updatedAt,omitempty"` + // url: Endpoint URL that receives webhook payloads. + Url string `json:"url" cborgen:"url"` +} + +// TempRepoListWebhooks calls the XRPC method "org.tangled.temp.repo.listWebhooks". +// +// repoDid: DID of the repository as minted by the knot. +func TempRepoListWebhooks(ctx context.Context, c util.LexClient, repoDid string) (*TempRepoListWebhooks_Output, error) { + var out TempRepoListWebhooks_Output + + params := map[string]interface{}{} + params["repoDid"] = repoDid + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.repo.listWebhooks", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/reporetryWebhookDelivery.go b/api/tangled/reporetryWebhookDelivery.go new file mode 100644 index 00000000..d5e3f118 --- /dev/null +++ b/api/tangled/reporetryWebhookDelivery.go @@ -0,0 +1,34 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.retryWebhookDelivery + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoRetryWebhookDeliveryNSID = "org.tangled.temp.repo.retryWebhookDelivery" +) + +// TempRepoRetryWebhookDelivery_Input is the input argument to a org.tangled.temp.repo.retryWebhookDelivery call. +type TempRepoRetryWebhookDelivery_Input struct { + // deliveryId: UUID of the delivery to retry. + DeliveryId string `json:"deliveryId" cborgen:"deliveryId"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` + // webhookId: Webhook ID that owns the delivery. + WebhookId int64 `json:"webhookId" cborgen:"webhookId"` +} + +// TempRepoRetryWebhookDelivery calls the XRPC method "org.tangled.temp.repo.retryWebhookDelivery". +func TempRepoRetryWebhookDelivery(ctx context.Context, c util.LexClient, input *TempRepoRetryWebhookDelivery_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.retryWebhookDelivery", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/repotoggleWebhook.go b/api/tangled/repotoggleWebhook.go new file mode 100644 index 00000000..69c2d292 --- /dev/null +++ b/api/tangled/repotoggleWebhook.go @@ -0,0 +1,39 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.toggleWebhook + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoToggleWebhookNSID = "org.tangled.temp.repo.toggleWebhook" +) + +// TempRepoToggleWebhook_Input is the input argument to a org.tangled.temp.repo.toggleWebhook call. +type TempRepoToggleWebhook_Input struct { + // id: Webhook ID to toggle. + Id int64 `json:"id" cborgen:"id"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` +} + +// TempRepoToggleWebhook_Output is the output of a org.tangled.temp.repo.toggleWebhook call. +type TempRepoToggleWebhook_Output struct { + // active: New active state of the webhook. + Active bool `json:"active" cborgen:"active"` +} + +// TempRepoToggleWebhook calls the XRPC method "org.tangled.temp.repo.toggleWebhook". +func TempRepoToggleWebhook(ctx context.Context, c util.LexClient, input *TempRepoToggleWebhook_Input) (*TempRepoToggleWebhook_Output, error) { + var out TempRepoToggleWebhook_Output + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.toggleWebhook", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/repoupdateSiteConfig.go b/api/tangled/repoupdateSiteConfig.go new file mode 100644 index 00000000..850aa7c0 --- /dev/null +++ b/api/tangled/repoupdateSiteConfig.go @@ -0,0 +1,36 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.updateSiteConfig + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoUpdateSiteConfigNSID = "org.tangled.temp.repo.updateSiteConfig" +) + +// TempRepoUpdateSiteConfig_Input is the input argument to a org.tangled.temp.repo.updateSiteConfig call. +type TempRepoUpdateSiteConfig_Input struct { + // branch: Branch to deploy from. + Branch string `json:"branch" cborgen:"branch"` + // dir: Directory within the repository to deploy (e.g. '/' or '/docs'). + Dir string `json:"dir" cborgen:"dir"` + // isIndex: Whether this repo should serve as the index (root) for the claimed domain. + IsIndex *bool `json:"isIndex,omitempty" cborgen:"isIndex,omitempty"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` +} + +// TempRepoUpdateSiteConfig calls the XRPC method "org.tangled.temp.repo.updateSiteConfig". +func TempRepoUpdateSiteConfig(ctx context.Context, c util.LexClient, input *TempRepoUpdateSiteConfig_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.updateSiteConfig", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/repoupdateWebhook.go b/api/tangled/repoupdateWebhook.go new file mode 100644 index 00000000..9d654c5b --- /dev/null +++ b/api/tangled/repoupdateWebhook.go @@ -0,0 +1,36 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.repo.updateWebhook + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempRepoUpdateWebhookNSID = "org.tangled.temp.repo.updateWebhook" +) + +// TempRepoUpdateWebhook_Input is the input argument to a org.tangled.temp.repo.updateWebhook call. +type TempRepoUpdateWebhook_Input struct { + Active *bool `json:"active,omitempty" cborgen:"active,omitempty"` + Events []string `json:"events,omitempty" cborgen:"events,omitempty"` + // id: Webhook ID to update. + Id int64 `json:"id" cborgen:"id"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` + Secret *string `json:"secret,omitempty" cborgen:"secret,omitempty"` + Url *string `json:"url,omitempty" cborgen:"url,omitempty"` +} + +// TempRepoUpdateWebhook calls the XRPC method "org.tangled.temp.repo.updateWebhook". +func TempRepoUpdateWebhook(ctx context.Context, c util.LexClient, input *TempRepoUpdateWebhook_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.repo.updateWebhook", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/searchsearchCode.go b/api/tangled/searchsearchCode.go new file mode 100644 index 00000000..0950f65f --- /dev/null +++ b/api/tangled/searchsearchCode.go @@ -0,0 +1,79 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.search.searchCode + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSearchSearchCodeNSID = "org.tangled.temp.search.searchCode" +) + +// TempSearchSearchCode_Chunk is a "chunk" in the org.tangled.temp.search.searchCode schema. +type TempSearchSearchCode_Chunk struct { + // content: Source lines for this match chunk. + Content string `json:"content" cborgen:"content"` + // highlights: Byte-offset ranges within content that match the query. + Highlights []*TempSearchSearchCode_Highlight `json:"highlights,omitempty" cborgen:"highlights,omitempty"` + // lineStart: 1-based line number of the first line in content. + LineStart int64 `json:"lineStart" cborgen:"lineStart"` +} + +// TempSearchSearchCode_FileResult is a "fileResult" in the org.tangled.temp.search.searchCode schema. +type TempSearchSearchCode_FileResult struct { + Chunks []*TempSearchSearchCode_Chunk `json:"chunks" cborgen:"chunks"` + // language: Detected programming language. + Language *string `json:"language,omitempty" cborgen:"language,omitempty"` + // path: File path relative to repository root. + Path string `json:"path" cborgen:"path"` + // repoDid: DID of the repository as minted by the knot. + RepoDid string `json:"repoDid" cborgen:"repoDid"` +} + +// TempSearchSearchCode_Highlight is a "highlight" in the org.tangled.temp.search.searchCode schema. +type TempSearchSearchCode_Highlight struct { + // end: End byte offset (exclusive) within the chunk content string. + End int64 `json:"end" cborgen:"end"` + // start: Start byte offset within the chunk content string. + Start int64 `json:"start" cborgen:"start"` +} + +// TempSearchSearchCode_Output is the output of a org.tangled.temp.search.searchCode call. +type TempSearchSearchCode_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Results []*TempSearchSearchCode_FileResult `json:"results" cborgen:"results"` +} + +// TempSearchSearchCode calls the XRPC method "org.tangled.temp.search.searchCode". +// +// lang: Restrict search to a specific programming language (e.g. 'go', 'rust'). +// q: Search query string. +// repoDid: Restrict search to a specific repository, by its DID. +func TempSearchSearchCode(ctx context.Context, c util.LexClient, cursor string, lang string, limit int64, q string, repoDid string) (*TempSearchSearchCode_Output, error) { + var out TempSearchSearchCode_Output + + params := map[string]interface{}{} + if cursor != "" { + params["cursor"] = cursor + } + if lang != "" { + params["lang"] = lang + } + if limit != 0 { + params["limit"] = limit + } + params["q"] = q + if repoDid != "" { + params["repoDid"] = repoDid + } + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.search.searchCode", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/siteclaimDomain.go b/api/tangled/siteclaimDomain.go new file mode 100644 index 00000000..990edd09 --- /dev/null +++ b/api/tangled/siteclaimDomain.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.site.claimDomain + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSiteClaimDomainNSID = "org.tangled.temp.site.claimDomain" +) + +// TempSiteClaimDomain_Input is the input argument to a org.tangled.temp.site.claimDomain call. +type TempSiteClaimDomain_Input struct { + // subdomain: Desired subdomain label (lowercase letters, digits, and hyphens; 4–63 characters). + Subdomain string `json:"subdomain" cborgen:"subdomain"` +} + +// TempSiteClaimDomain calls the XRPC method "org.tangled.temp.site.claimDomain". +func TempSiteClaimDomain(ctx context.Context, c util.LexClient, input *TempSiteClaimDomain_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.site.claimDomain", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/api/tangled/sitegetDomainClaim.go b/api/tangled/sitegetDomainClaim.go new file mode 100644 index 00000000..f0e1b9a9 --- /dev/null +++ b/api/tangled/sitegetDomainClaim.go @@ -0,0 +1,31 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.site.getDomainClaim + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSiteGetDomainClaimNSID = "org.tangled.temp.site.getDomainClaim" +) + +// TempSiteGetDomainClaim_Output is the output of a org.tangled.temp.site.getDomainClaim call. +type TempSiteGetDomainClaim_Output struct { + // domain: The claimed domain (e.g. 'alice.sites.tangled.sh'). Absent if no active claim. + Domain *string `json:"domain,omitempty" cborgen:"domain,omitempty"` +} + +// TempSiteGetDomainClaim calls the XRPC method "org.tangled.temp.site.getDomainClaim". +func TempSiteGetDomainClaim(ctx context.Context, c util.LexClient) (*TempSiteGetDomainClaim_Output, error) { + var out TempSiteGetDomainClaim_Output + if err := c.LexDo(ctx, util.Query, "", "org.tangled.temp.site.getDomainClaim", nil, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/api/tangled/sitereleaseDomain.go b/api/tangled/sitereleaseDomain.go new file mode 100644 index 00000000..d1b6dcd1 --- /dev/null +++ b/api/tangled/sitereleaseDomain.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.site.releaseDomain + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempSiteReleaseDomainNSID = "org.tangled.temp.site.releaseDomain" +) + +// TempSiteReleaseDomain_Input is the input argument to a org.tangled.temp.site.releaseDomain call. +type TempSiteReleaseDomain_Input struct { + // domain: Full domain to release (must match the user's active claim). + Domain string `json:"domain" cborgen:"domain"` +} + +// TempSiteReleaseDomain calls the XRPC method "org.tangled.temp.site.releaseDomain". +func TempSiteReleaseDomain(ctx context.Context, c util.LexClient, input *TempSiteReleaseDomain_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.site.releaseDomain", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/lexicon-build-config.json b/lexicon-build-config.json index 2b02573a..f0c905c4 100644 --- a/lexicon-build-config.json +++ b/lexicon-build-config.json @@ -11,5 +11,12 @@ "outdir": "api/tangled", "import": "tangled.org/core/api/tangled", "gen-server": true + }, + { + "package": "tangled", + "prefix": "org.tangled", + "outdir": "api/tangled", + "import": "tangled.org/core/api/tangled", + "gen-server": true } ] diff --git a/lexicons/temp/account/beginSignup.json b/lexicons/temp/account/beginSignup.json new file mode 100644 index 00000000..02d663de --- /dev/null +++ b/lexicons/temp/account/beginSignup.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.beginSignup", + "defs": { + "main": { + "type": "procedure", + "description": "Begin signup: validate the Turnstile token and email a verification code to the address.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email", "turnstileToken"], + "properties": { + "email": { + "type": "string", + "description": "Email address for the new account." + }, + "turnstileToken": { + "type": "string", + "description": "Cloudflare Turnstile challenge response token." + } + } + } + }, + "errors": [ + { "name": "SignupDisabled", "description": "Signup is not currently enabled on this service." }, + { "name": "InvalidTurnstileToken", "description": "The Turnstile captcha response could not be verified." }, + { "name": "InvalidEmail", "description": "The email address is not valid." }, + { "name": "EmailAlreadyRegistered", "description": "An account already exists for this email address." } + ] + } + } +} diff --git a/lexicons/temp/account/completeSignup.json b/lexicons/temp/account/completeSignup.json new file mode 100644 index 00000000..c6ea46a4 --- /dev/null +++ b/lexicons/temp/account/completeSignup.json @@ -0,0 +1,55 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.completeSignup", + "defs": { + "main": { + "type": "procedure", + "description": "Complete signup: provision the PDS account for the chosen username using the verification code from the signup email.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["username", "password", "code"], + "properties": { + "username": { + "type": "string", + "description": "Desired username; the assigned handle is .." + }, + "password": { + "type": "string", + "description": "Password for the new account." + }, + "code": { + "type": "string", + "description": "Verification code from the signup email." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the newly provisioned account." + }, + "handle": { + "type": "string", + "description": "The assigned handle, .." + } + } + } + }, + "errors": [ + { "name": "SignupDisabled", "description": "Signup is not currently enabled on this service." }, + { "name": "InvalidUsername", "description": "Username must be 4-63 characters, lowercase letters, digits, or hyphens, and cannot start or end with a hyphen." }, + { "name": "UsernameUnavailable", "description": "This username is reserved or already taken." }, + { "name": "InvalidCode", "description": "The verification code is invalid or has expired." } + ] + } + } +} diff --git a/lexicons/temp/account/deleteEmail.json b/lexicons/temp/account/deleteEmail.json new file mode 100644 index 00000000..934dfc67 --- /dev/null +++ b/lexicons/temp/account/deleteEmail.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.deleteEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Remove an email address from the authenticated account. Cannot remove the primary address.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "description": "Email address to remove." + } + } + } + }, + "errors": [ + { "name": "CannotDeletePrimary", "description": "The primary email address cannot be deleted. Set another address as primary first." } + ] + } + } +} diff --git a/lexicons/temp/account/dismissNewsletter.json b/lexicons/temp/account/dismissNewsletter.json new file mode 100644 index 00000000..38622c47 --- /dev/null +++ b/lexicons/temp/account/dismissNewsletter.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.dismissNewsletter", + "defs": { + "main": { + "type": "procedure", + "description": "Dismiss the newsletter sign-up prompt without subscribing." + } + } +} diff --git a/lexicons/temp/account/listEmails.json b/lexicons/temp/account/listEmails.json new file mode 100644 index 00000000..ca66e1ff --- /dev/null +++ b/lexicons/temp/account/listEmails.json @@ -0,0 +1,48 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.listEmails", + "defs": { + "main": { + "type": "query", + "description": "List all email addresses associated with the authenticated account.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["emails"], + "properties": { + "emails": { + "type": "array", + "items": { + "type": "ref", + "ref": "#email" + } + } + } + } + } + }, + "email": { + "type": "object", + "required": ["address", "verified", "primary", "createdAt"], + "properties": { + "address": { + "type": "string", + "description": "Email address." + }, + "verified": { + "type": "boolean", + "description": "Whether the address has been verified." + }, + "primary": { + "type": "boolean", + "description": "Whether this is the primary email address." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } +} diff --git a/lexicons/temp/account/setPrimaryEmail.json b/lexicons/temp/account/setPrimaryEmail.json new file mode 100644 index 00000000..e13d49ae --- /dev/null +++ b/lexicons/temp/account/setPrimaryEmail.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.setPrimaryEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Set an email address as the primary address for the authenticated account. The address must already be verified.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "description": "Email address to promote to primary." + } + } + } + }, + "errors": [ + { "name": "EmailNotVerified", "description": "The email address must be verified before it can be made primary." }, + { "name": "EmailNotFound", "description": "The email address is not associated with this account." } + ] + } + } +} diff --git a/lexicons/temp/account/subscribeNewsletter.json b/lexicons/temp/account/subscribeNewsletter.json new file mode 100644 index 00000000..6ec6c73d --- /dev/null +++ b/lexicons/temp/account/subscribeNewsletter.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.account.subscribeNewsletter", + "defs": { + "main": { + "type": "procedure", + "description": "Opt the authenticated account into newsletter / announcement emails." + } + } +} diff --git a/lexicons/temp/focus/beginSession.json b/lexicons/temp/focus/beginSession.json new file mode 100644 index 00000000..ec13b0b5 --- /dev/null +++ b/lexicons/temp/focus/beginSession.json @@ -0,0 +1,43 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.focus.beginSession", + "defs": { + "main": { + "type": "procedure", + "description": "Activate focus mode. Returns the oldest unread work notification so the client can navigate to it.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "notificationId": { + "type": "integer", + "description": "ID of the first notification to address. Absent if the queue is empty." + }, + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository the item belongs to. Absent if the queue is empty." + }, + "issueAt": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the issue to navigate to, if the item is an issue." + }, + "pullAt": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the pull to navigate to, if the item is a pull." + } + } + } + }, + "errors": [ + { + "name": "NoFocusItems", + "description": "There are no unread work notifications to focus on." + } + ] + } + } +} diff --git a/lexicons/temp/focus/endSession.json b/lexicons/temp/focus/endSession.json new file mode 100644 index 00000000..ab115e49 --- /dev/null +++ b/lexicons/temp/focus/endSession.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.focus.endSession", + "defs": { + "main": { + "type": "procedure", + "description": "Deactivate focus mode." + } + } +} diff --git a/lexicons/temp/focus/nextItem.json b/lexicons/temp/focus/nextItem.json new file mode 100644 index 00000000..e051d939 --- /dev/null +++ b/lexicons/temp/focus/nextItem.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.focus.nextItem", + "defs": { + "main": { + "type": "procedure", + "description": "Mark the current focus notification as read and return the next one. Ends focus mode and returns no item when the queue is exhausted.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["currentId"], + "properties": { + "currentId": { + "type": "integer", + "description": "ID of the notification just addressed, to be marked read." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "notificationId": { + "type": "integer", + "description": "ID of the next notification to address. Absent when focus mode has ended." + }, + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository the next item belongs to. Absent when focus mode has ended." + }, + "issueAt": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the issue to navigate to, if the item is an issue." + }, + "pullAt": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the pull to navigate to, if the item is a pull." + } + } + } + } + } + } +} diff --git a/lexicons/temp/notification/deleteNotification.json b/lexicons/temp/notification/deleteNotification.json new file mode 100644 index 00000000..e800350e --- /dev/null +++ b/lexicons/temp/notification/deleteNotification.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.deleteNotification", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a notification.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "description": "ID of the notification to delete." + } + } + } + } + } + } +} diff --git a/lexicons/temp/notification/getPreferences.json b/lexicons/temp/notification/getPreferences.json new file mode 100644 index 00000000..7b5f80c9 --- /dev/null +++ b/lexicons/temp/notification/getPreferences.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.getPreferences", + "defs": { + "main": { + "type": "query", + "description": "Get notification preferences for the authenticated user.", + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#preferences" + } + } + }, + "preferences": { + "type": "object", + "required": [ + "repoStarred", + "issueCreated", + "issueCommented", + "issueClosed", + "pullCreated", + "pullCommented", + "pullMerged", + "followed", + "userMentioned", + "emailNotifications" + ], + "properties": { + "repoStarred": { "type": "boolean" }, + "issueCreated": { "type": "boolean" }, + "issueCommented": { "type": "boolean" }, + "issueClosed": { "type": "boolean" }, + "pullCreated": { "type": "boolean" }, + "pullCommented": { "type": "boolean" }, + "pullMerged": { "type": "boolean" }, + "followed": { "type": "boolean" }, + "userMentioned": { "type": "boolean" }, + "emailNotifications": { "type": "boolean" } + } + } + } +} diff --git a/lexicons/temp/notification/getUnreadCount.json b/lexicons/temp/notification/getUnreadCount.json new file mode 100644 index 00000000..74e842d3 --- /dev/null +++ b/lexicons/temp/notification/getUnreadCount.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.getUnreadCount", + "defs": { + "main": { + "type": "query", + "description": "Get the total number of unread notifications for the authenticated user. Used to drive the bell badge.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["count"], + "properties": { + "count": { + "type": "integer" + } + } + } + } + } + } +} diff --git a/lexicons/temp/notification/listNotifications.json b/lexicons/temp/notification/listNotifications.json new file mode 100644 index 00000000..1554f9cc --- /dev/null +++ b/lexicons/temp/notification/listNotifications.json @@ -0,0 +1,102 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.listNotifications", + "defs": { + "main": { + "type": "query", + "description": "List notifications for the authenticated user, split by category.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Max notifications per category to return." + }, + "cursor": { + "type": "string" + }, + "read": { + "type": "string", + "description": "Filter by read state: 'all' or 'unread'." + }, + "category": { + "type": "string", + "description": "Filter by category: 'all', 'social', or 'work'." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["notifications", "workUnreadCount", "socialUnreadCount"], + "properties": { + "notifications": { + "type": "array", + "items": { + "type": "ref", + "ref": "#notification" + } + }, + "workUnreadCount": { + "type": "integer" + }, + "socialUnreadCount": { + "type": "integer" + }, + "cursor": { + "type": "string" + } + } + } + } + }, + "notification": { + "type": "object", + "required": ["id", "type", "category", "actorDid", "read", "createdAt"], + "properties": { + "id": { + "type": "integer", + "description": "Stable numeric ID for this notification." + }, + "type": { + "type": "string", + "description": "Notification type: repo_starred, issue_created, issue_commented, issue_closed, issue_reopen, issue_assigned, issue_unassigned, pull_created, pull_commented, pull_merged, pull_closed, pull_reopen, pull_assigned, pull_unassigned, followed, user_mentioned." + }, + "category": { + "type": "string", + "description": "Broad category: 'social' or 'work'." + }, + "actorDid": { + "type": "string", + "format": "did", + "description": "DID of the user who triggered this notification." + }, + "read": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the related repository, if applicable." + }, + "issueAt": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the related org.tangled.issue.issue record, if applicable." + }, + "pullAt": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the related org.tangled.pulls.pull record, if applicable." + } + } + } + } +} diff --git a/lexicons/temp/notification/markAllRead.json b/lexicons/temp/notification/markAllRead.json new file mode 100644 index 00000000..f9deeca4 --- /dev/null +++ b/lexicons/temp/notification/markAllRead.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.markAllRead", + "defs": { + "main": { + "type": "procedure", + "description": "Mark all of the authenticated user's notifications as read." + } + } +} diff --git a/lexicons/temp/notification/updatePreferences.json b/lexicons/temp/notification/updatePreferences.json new file mode 100644 index 00000000..ba0ff22d --- /dev/null +++ b/lexicons/temp/notification/updatePreferences.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.updatePreferences", + "defs": { + "main": { + "type": "procedure", + "description": "Update notification preferences for the authenticated user. Only provided fields are updated.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "repoStarred": { "type": "boolean" }, + "issueCreated": { "type": "boolean" }, + "issueCommented": { "type": "boolean" }, + "issueClosed": { "type": "boolean" }, + "pullCreated": { "type": "boolean" }, + "pullCommented": { "type": "boolean" }, + "pullMerged": { "type": "boolean" }, + "followed": { "type": "boolean" }, + "userMentioned": { "type": "boolean" }, + "emailNotifications": { "type": "boolean" } + } + } + } + } + } +} diff --git a/lexicons/temp/notification/updateSeen.json b/lexicons/temp/notification/updateSeen.json new file mode 100644 index 00000000..fda7e686 --- /dev/null +++ b/lexicons/temp/notification/updateSeen.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.updateSeen", + "defs": { + "main": { + "type": "procedure", + "description": "Mark a single notification as read or unread.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["id", "read"], + "properties": { + "id": { + "type": "integer", + "description": "ID of the notification to update." + }, + "read": { + "type": "boolean" + } + } + } + } + } + } +} diff --git a/lexicons/temp/repo/createWebhook.json b/lexicons/temp/repo/createWebhook.json new file mode 100644 index 00000000..57d28780 --- /dev/null +++ b/lexicons/temp/repo/createWebhook.json @@ -0,0 +1,60 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.createWebhook", + "defs": { + "main": { + "type": "procedure", + "description": "Create a new webhook for a repository.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid", "url", "events"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Endpoint URL that will receive webhook payloads." + }, + "secret": { + "type": "string", + "description": "Optional HMAC secret used to sign payloads. If omitted, payloads are not signed." + }, + "active": { + "type": "boolean", + "description": "Whether the webhook should be active immediately. Defaults to true." + }, + "events": { + "type": "array", + "description": "Event types to subscribe to (e.g. 'push', 'repository:renamed').", + "items": { + "type": "string" + } + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "description": "ID of the newly created webhook." + } + } + } + }, + "errors": [ + { "name": "NoEventsSelected", "description": "At least one event type must be specified." } + ] + } + } +} diff --git a/lexicons/temp/repo/deleteWebhook.json b/lexicons/temp/repo/deleteWebhook.json new file mode 100644 index 00000000..7a530fd8 --- /dev/null +++ b/lexicons/temp/repo/deleteWebhook.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.deleteWebhook", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a webhook from a repository.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid", "id"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "id": { + "type": "integer", + "description": "Webhook ID to delete." + } + } + } + }, + "errors": [ + { "name": "WebhookNotFound" } + ] + } + } +} diff --git a/lexicons/temp/repo/disableSite.json b/lexicons/temp/repo/disableSite.json new file mode 100644 index 00000000..6d16e430 --- /dev/null +++ b/lexicons/temp/repo/disableSite.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.disableSite", + "defs": { + "main": { + "type": "procedure", + "description": "Remove the site configuration for a repository, deleting its deployed files.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + } + } + } + }, + "errors": [ + { "name": "SiteNotFound", "description": "No site configuration exists for this repository." } + ] + } + } +} diff --git a/lexicons/temp/repo/getSiteConfig.json b/lexicons/temp/repo/getSiteConfig.json new file mode 100644 index 00000000..da369dbb --- /dev/null +++ b/lexicons/temp/repo/getSiteConfig.json @@ -0,0 +1,52 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.getSiteConfig", + "defs": { + "main": { + "type": "query", + "description": "Get the site configuration for a repository, if one exists.", + "parameters": { + "type": "params", + "required": ["repoDid"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "config": { + "type": "ref", + "ref": "#siteConfig", + "description": "Site config for the repository. Absent if no site is configured." + } + } + } + } + }, + "siteConfig": { + "type": "object", + "required": ["branch", "dir", "isIndex"], + "properties": { + "branch": { + "type": "string", + "description": "Branch to deploy from." + }, + "dir": { + "type": "string", + "description": "Directory within the repository to deploy (e.g. '/' or '/docs')." + }, + "isIndex": { + "type": "boolean", + "description": "Whether this repo serves as the index (root) for the domain." + } + } + } + } +} diff --git a/lexicons/temp/repo/listWebhookDeliveries.json b/lexicons/temp/repo/listWebhookDeliveries.json new file mode 100644 index 00000000..7ee73dbc --- /dev/null +++ b/lexicons/temp/repo/listWebhookDeliveries.json @@ -0,0 +1,83 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.listWebhookDeliveries", + "defs": { + "main": { + "type": "query", + "description": "List recent delivery attempts for a webhook.", + "parameters": { + "type": "params", + "required": ["repoDid", "id"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "id": { + "type": "integer", + "description": "Webhook ID." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["deliveries"], + "properties": { + "deliveries": { + "type": "array", + "items": { + "type": "ref", + "ref": "#delivery" + } + } + } + } + } + }, + "delivery": { + "type": "object", + "required": ["id", "deliveryId", "event", "url", "success", "createdAt"], + "properties": { + "id": { + "type": "integer" + }, + "deliveryId": { + "type": "string", + "description": "UUID for tracking this delivery attempt." + }, + "event": { + "type": "string", + "description": "Event type that triggered the delivery." + }, + "url": { + "type": "string", + "format": "uri" + }, + "requestBody": { + "type": "string" + }, + "responseCode": { + "type": "integer" + }, + "responseBody": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } +} diff --git a/lexicons/temp/repo/listWebhooks.json b/lexicons/temp/repo/listWebhooks.json new file mode 100644 index 00000000..54351c43 --- /dev/null +++ b/lexicons/temp/repo/listWebhooks.json @@ -0,0 +1,71 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.listWebhooks", + "defs": { + "main": { + "type": "query", + "description": "List webhooks configured for a repository.", + "parameters": { + "type": "params", + "required": ["repoDid"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["webhooks"], + "properties": { + "webhooks": { + "type": "array", + "items": { + "type": "ref", + "ref": "#webhook" + } + } + } + } + } + }, + "webhook": { + "type": "object", + "required": ["id", "url", "active", "events", "createdAt"], + "properties": { + "id": { + "type": "integer", + "description": "Webhook identifier." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Endpoint URL that receives webhook payloads." + }, + "active": { + "type": "boolean", + "description": "Whether the webhook is currently enabled." + }, + "events": { + "type": "array", + "description": "Event types this webhook is subscribed to (e.g. 'push', 'repository:renamed').", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "updatedAt": { + "type": "string", + "format": "datetime" + } + } + } + } +} diff --git a/lexicons/temp/repo/retryWebhookDelivery.json b/lexicons/temp/repo/retryWebhookDelivery.json new file mode 100644 index 00000000..0fe909e3 --- /dev/null +++ b/lexicons/temp/repo/retryWebhookDelivery.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.retryWebhookDelivery", + "defs": { + "main": { + "type": "procedure", + "description": "Re-send a specific webhook delivery.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid", "webhookId", "deliveryId"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "webhookId": { + "type": "integer", + "description": "Webhook ID that owns the delivery." + }, + "deliveryId": { + "type": "string", + "description": "UUID of the delivery to retry." + } + } + } + }, + "errors": [ + { "name": "WebhookNotFound" }, + { "name": "DeliveryNotFound" } + ] + } + } +} diff --git a/lexicons/temp/repo/toggleWebhook.json b/lexicons/temp/repo/toggleWebhook.json new file mode 100644 index 00000000..aa6cecfe --- /dev/null +++ b/lexicons/temp/repo/toggleWebhook.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.toggleWebhook", + "defs": { + "main": { + "type": "procedure", + "description": "Toggle the active state of a webhook.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid", "id"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "id": { + "type": "integer", + "description": "Webhook ID to toggle." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["active"], + "properties": { + "active": { + "type": "boolean", + "description": "New active state of the webhook." + } + } + } + }, + "errors": [ + { "name": "WebhookNotFound" } + ] + } + } +} diff --git a/lexicons/temp/repo/updateSiteConfig.json b/lexicons/temp/repo/updateSiteConfig.json new file mode 100644 index 00000000..29bfffd3 --- /dev/null +++ b/lexicons/temp/repo/updateSiteConfig.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.updateSiteConfig", + "defs": { + "main": { + "type": "procedure", + "description": "Create or update the site configuration for a repository and trigger a deployment.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid", "branch", "dir"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "branch": { + "type": "string", + "description": "Branch to deploy from." + }, + "dir": { + "type": "string", + "description": "Directory within the repository to deploy (e.g. '/' or '/docs')." + }, + "isIndex": { + "type": "boolean", + "description": "Whether this repo should serve as the index (root) for the claimed domain." + } + } + } + }, + "errors": [ + { "name": "NoDomainClaim", "description": "The account does not have an active domain claim." } + ] + } + } +} diff --git a/lexicons/temp/repo/updateWebhook.json b/lexicons/temp/repo/updateWebhook.json new file mode 100644 index 00000000..fb046717 --- /dev/null +++ b/lexicons/temp/repo/updateWebhook.json @@ -0,0 +1,47 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.repo.updateWebhook", + "defs": { + "main": { + "type": "procedure", + "description": "Update an existing webhook. Only supplied fields are changed.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repoDid", "id"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "id": { + "type": "integer", + "description": "Webhook ID to update." + }, + "url": { + "type": "string", + "format": "uri" + }, + "secret": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "errors": [ + { "name": "WebhookNotFound" } + ] + } + } +} diff --git a/lexicons/temp/search/searchCode.json b/lexicons/temp/search/searchCode.json new file mode 100644 index 00000000..92256cfb --- /dev/null +++ b/lexicons/temp/search/searchCode.json @@ -0,0 +1,118 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.search.searchCode", + "defs": { + "main": { + "type": "query", + "description": "Full-text code search across indexed repositories, backed by Zoekt.", + "parameters": { + "type": "params", + "required": ["q"], + "properties": { + "q": { + "type": "string", + "description": "Search query string." + }, + "repoDid": { + "type": "string", + "format": "did", + "description": "Restrict search to a specific repository, by its DID." + }, + "lang": { + "type": "string", + "description": "Restrict search to a specific programming language (e.g. 'go', 'rust')." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["results"], + "properties": { + "results": { + "type": "array", + "items": { + "type": "ref", + "ref": "#fileResult" + } + }, + "cursor": { + "type": "string" + } + } + } + } + }, + "fileResult": { + "type": "object", + "required": ["repoDid", "path", "chunks"], + "properties": { + "repoDid": { + "type": "string", + "format": "did", + "description": "DID of the repository as minted by the knot." + }, + "path": { + "type": "string", + "description": "File path relative to repository root." + }, + "language": { + "type": "string", + "description": "Detected programming language." + }, + "chunks": { + "type": "array", + "items": { + "type": "ref", + "ref": "#chunk" + } + } + } + }, + "chunk": { + "type": "object", + "required": ["content", "lineStart"], + "properties": { + "content": { + "type": "string", + "description": "Source lines for this match chunk." + }, + "lineStart": { + "type": "integer", + "description": "1-based line number of the first line in content." + }, + "highlights": { + "type": "array", + "description": "Byte-offset ranges within content that match the query.", + "items": { + "type": "ref", + "ref": "#highlight" + } + } + } + }, + "highlight": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "integer", + "description": "Start byte offset within the chunk content string." + }, + "end": { + "type": "integer", + "description": "End byte offset (exclusive) within the chunk content string." + } + } + } + } +} diff --git a/lexicons/temp/site/claimDomain.json b/lexicons/temp/site/claimDomain.json new file mode 100644 index 00000000..25d4e82b --- /dev/null +++ b/lexicons/temp/site/claimDomain.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.site.claimDomain", + "defs": { + "main": { + "type": "procedure", + "description": "Claim a subdomain under the sites domain (e.g. 'alice' → alice.sites.tangled.sh) for the authenticated user.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["subdomain"], + "properties": { + "subdomain": { + "type": "string", + "description": "Desired subdomain label (lowercase letters, digits, and hyphens; 4–63 characters)." + } + } + } + }, + "errors": [ + { "name": "DomainTaken", "description": "That subdomain is already claimed by another user." }, + { "name": "DomainCooldown", "description": "That subdomain was recently released and is in a cooldown period." }, + { "name": "AlreadyClaimed", "description": "You already have a domain claimed. Release it before claiming a new one." }, + { "name": "InvalidSubdomain", "description": "The subdomain label is invalid." } + ] + } + } +} diff --git a/lexicons/temp/site/getDomainClaim.json b/lexicons/temp/site/getDomainClaim.json new file mode 100644 index 00000000..ad6c539b --- /dev/null +++ b/lexicons/temp/site/getDomainClaim.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.site.getDomainClaim", + "defs": { + "main": { + "type": "query", + "description": "Get the active sites domain claim for the authenticated user, if any.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The claimed domain (e.g. 'alice.sites.tangled.sh'). Absent if no active claim." + } + } + } + } + } + } +} diff --git a/lexicons/temp/site/releaseDomain.json b/lexicons/temp/site/releaseDomain.json new file mode 100644 index 00000000..747aa517 --- /dev/null +++ b/lexicons/temp/site/releaseDomain.json @@ -0,0 +1,26 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.site.releaseDomain", + "defs": { + "main": { + "type": "procedure", + "description": "Release the authenticated user's active domain claim, removing all associated site data.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["domain"], + "properties": { + "domain": { + "type": "string", + "description": "Full domain to release (must match the user's active claim)." + } + } + } + }, + "errors": [ + { "name": "DomainNotFound", "description": "No active claim found for the given domain under this account." } + ] + } + } +} -- 2.51.2 From 6fcb5c4eaf7e54941e0437a2801755abf4c0a624 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 21 Jul 2026 11:18:49 +0300 Subject: [PATCH 02/25] appview/xrpc: handlers for "private" xrpc service Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 5 + appview/db/webhooks.go | 52 +++ appview/notify/webhook/notifier.go | 23 +- appview/notify/webhook/notifier_test.go | 2 +- appview/settings/settings.go | 3 +- appview/signup/signup.go | 45 +-- appview/state/router.go | 25 ++ appview/state/state.go | 2 +- .../userutil}/moderation.go | 4 +- appview/state/userutil/userutil.go | 47 +++ appview/xrpc/account.go | 173 ++++++++ appview/xrpc/focus.go | 119 ++++++ appview/xrpc/notifications.go | 279 +++++++++++++ appview/xrpc/search.go | 157 +++++++ appview/xrpc/signup.go | 306 ++++++++++++++ appview/xrpc/sites.go | 329 +++++++++++++++ appview/xrpc/webhooks.go | 382 ++++++++++++++++++ appview/xrpc/xrpc.go | 184 +++++++++ appview/xrpc/xrpc_test.go | 158 ++++++++ hostutil/safedial.go | 84 ++++ 20 files changed, 2326 insertions(+), 53 deletions(-) rename appview/{settings => state/userutil}/moderation.go (99%) create mode 100644 appview/xrpc/account.go create mode 100644 appview/xrpc/focus.go create mode 100644 appview/xrpc/notifications.go create mode 100644 appview/xrpc/search.go create mode 100644 appview/xrpc/signup.go create mode 100644 appview/xrpc/sites.go create mode 100644 appview/xrpc/webhooks.go create mode 100644 appview/xrpc/xrpc.go create mode 100644 appview/xrpc/xrpc_test.go create mode 100644 hostutil/safedial.go diff --git a/appview/config/config.go b/appview/config/config.go index a8f24463..d8db366a 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -23,6 +23,11 @@ type CoreConfig struct { Dev bool `env:"DEV, default=false"` DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` + // origin allowed to call the xrpc endpoints from the browser (the svelte + // frontend). empty allows any origin, which is safe here since xrpc uses + // bearer service-auth tokens rather than cookies. + XrpcCorsOrigin string `env:"XRPC_CORS_ORIGIN"` + // temporarily, to add users to default knot and spindle AppPassword string `env:"APP_PASSWORD"` diff --git a/appview/db/webhooks.go b/appview/db/webhooks.go index 6ec9ea8d..e7b6b121 100644 --- a/appview/db/webhooks.go +++ b/appview/db/webhooks.go @@ -283,6 +283,58 @@ func GetWebhookDeliveries(e Execer, webhookId int64, limit int) ([]models.Webhoo return deliveries, nil } +// GetWebhookDelivery returns a single delivery by its delivery_id (a uuid). +func GetWebhookDelivery(e Execer, deliveryId string) (*models.WebhookDelivery, error) { + var d models.WebhookDelivery + var createdAt string + var success int + var responseCode sql.NullInt64 + var responseBody sql.NullString + + err := e.QueryRow(` + select + id, + webhook_id, + event, + delivery_id, + url, + request_body, + response_code, + response_body, + success, + created_at + from webhook_deliveries + where delivery_id = ? + `, deliveryId).Scan( + &d.Id, + &d.WebhookId, + &d.Event, + &d.DeliveryId, + &d.Url, + &d.RequestBody, + &responseCode, + &responseBody, + &success, + &createdAt, + ) + if err != nil { + return nil, err + } + + d.Success = success == 1 + if responseCode.Valid { + d.ResponseCode = int(responseCode.Int64) + } + if responseBody.Valid { + d.ResponseBody = responseBody.String + } + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + d.CreatedAt = t + } + + return &d, nil +} + // GetWebhooksForRepo is a convenience function to get all webhooks for a repository func GetWebhooksForRepo(e Execer, repoDid string) ([]models.Webhook, error) { return GetWebhooks(e, orm.FilterEq("repo_did", repoDid)) diff --git a/appview/notify/webhook/notifier.go b/appview/notify/webhook/notifier.go index bf0ebe9c..21732a79 100644 --- a/appview/notify/webhook/notifier.go +++ b/appview/notify/webhook/notifier.go @@ -19,6 +19,7 @@ import ( "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/notify" + "tangled.org/core/hostutil" "tangled.org/core/log" "tangled.org/core/orm" ) @@ -31,14 +32,14 @@ type Notifier struct { client *http.Client } -func NewNotifier(database *db.DB, baseUrl string) *Notifier { +func NewNotifier(database *db.DB, baseUrl string, dev bool) *Notifier { return &Notifier{ db: database, baseUrl: baseUrl, logger: log.New("webhook-notifier"), - client: &http.Client{ - Timeout: 30 * time.Second, - }, + // user-supplied webhook URLs are untrusted: block internal address + // ranges and don't follow redirects to guard against SSRF. + client: hostutil.SafeClient(dev, 30*time.Second), } } @@ -198,6 +199,20 @@ func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull } } +// Redeliver re-sends a stored delivery via the live send-and-record path, +// signing with the webhook's current secret. +func (w *Notifier) Redeliver(ctx context.Context, webhook models.Webhook, prev models.WebhookDelivery) { + // recover the repo full name (X-Tangled-Repo header) from the stored payload + var meta struct { + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } + _ = json.Unmarshal([]byte(prev.RequestBody), &meta) + + w.sendWebhook(ctx, webhook, prev.Event, meta.Repository.FullName, "Tangled-Hook/retry", []byte(prev.RequestBody)) +} + func (w *Notifier) activeWebhooksForEvent(repoDid string, event models.WebhookEvent) ([]models.Webhook, error) { webhooks, err := db.GetActiveWebhooksForRepo(w.db, repoDid) if err != nil { diff --git a/appview/notify/webhook/notifier_test.go b/appview/notify/webhook/notifier_test.go index 129fbfa5..aa788ec0 100644 --- a/appview/notify/webhook/notifier_test.go +++ b/appview/notify/webhook/notifier_test.go @@ -224,7 +224,7 @@ func newNotifierTestEnv(t *testing.T, events []string) *notifierTestEnv { } return ¬ifierTestEnv{ - notifier: NewNotifier(d, "https://tangled.org"), + notifier: NewNotifier(d, "https://tangled.org", true), webhook: webhook, db: d, received: received, diff --git a/appview/settings/settings.go b/appview/settings/settings.go index 8a1a25e3..26c94849 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -24,6 +24,7 @@ import ( "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/sites" + "tangled.org/core/appview/state/userutil" "tangled.org/core/idresolver" "tangled.org/core/tid" @@ -145,7 +146,7 @@ func (s *Settings) claimSitesDomain(w http.ResponseWriter, r *http.Request) { return } - if subdomainHasSlur(subdomain) { + if userutil.HasSlur(subdomain) { s.Pages.Notice(w, "settings-sites-error", "That subdomain is not allowed.") return } diff --git a/appview/signup/signup.go b/appview/signup/signup.go index 3eb0ac90..5b4b584a 100644 --- a/appview/signup/signup.go +++ b/appview/signup/signup.go @@ -1,7 +1,6 @@ package signup import ( - "bufio" "context" "encoding/json" "errors" @@ -9,7 +8,6 @@ import ( "log/slog" "net/http" "net/url" - "os" "strings" "github.com/go-chi/chi/v5" @@ -45,7 +43,7 @@ func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idr } } - disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) + disallowedNicknames := userutil.LoadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) return &Signup{ config: cfg, @@ -59,47 +57,6 @@ func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idr } } -func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { - disallowed := make(map[string]bool) - - if filepath == "" { - logger.Warn("no disallowed nicknames file configured") - return disallowed - } - - file, err := os.Open(filepath) - if err != nil { - logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) - return disallowed - } - defer file.Close() - - scanner := bufio.NewScanner(file) - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue // skip empty lines and comments - } - - nickname := strings.ToLower(line) - if userutil.IsValidSubdomain(nickname) { - disallowed[nickname] = true - } else { - logger.Warn("invalid nickname format in disallowed nicknames file", - "file", filepath, "line", lineNum, "nickname", nickname) - } - } - - if err := scanner.Err(); err != nil { - logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) - } - - logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) - return disallowed -} - // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) func (s *Signup) isNicknameAllowed(nickname string) bool { return !s.disallowedNicknames[strings.ToLower(nickname)] diff --git a/appview/state/router.go b/appview/state/router.go index c87d4b65..a45e2eb1 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -25,10 +25,13 @@ import ( "tangled.org/core/appview/signup" "tangled.org/core/appview/spindles" "tangled.org/core/appview/state/userutil" + whnotify "tangled.org/core/appview/notify/webhook" avstrings "tangled.org/core/appview/strings" avtimeline "tangled.org/core/appview/timeline" + avxrpc "tangled.org/core/appview/xrpc" "tangled.org/core/blog" "tangled.org/core/log" + "tangled.org/core/xrpc/serviceauth" ) func (s *State) Router() http.Handler { @@ -296,6 +299,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/focus", s.FocusRouter(mw)) r.Mount("/signup", s.SignupRouter()) + r.Mount("/xrpc", s.XrpcRouter()) r.Mount("/", s.oauth.Router()) r.Get("/terms", s.TermsOfService) @@ -479,3 +483,24 @@ func (s *State) SignupRouter() http.Handler { sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) return sig.Router() } + +// XrpcRouter serves the org.tangled.* methods owned by the go service; callers +// authenticate with atproto service auth, audience did:web: +func (s *State) XrpcRouter() http.Handler { + audience := serviceauth.DidWeb(s.config.Core.AppviewHost).String() + sa := serviceauth.NewServiceAuth(s.logger, s.idResolver.Directory(), audience) + + xlogger := log.SubLogger(s.logger, "xrpc") + x := &avxrpc.Xrpc{ + DB: s.db, + Config: s.config, + Logger: xlogger, + ServiceAuth: sa, + IdResolver: s.idResolver, + Cloudflare: s.cfClient, + CodeSearch: s.codesearch, + Webhooks: whnotify.NewNotifier(s.db, s.config.Core.BaseUrl(), s.config.Core.Dev), + DisallowedNicknames: userutil.LoadDisallowedNicknames(s.config.Core.DisallowedNicknamesFile, xlogger), + } + return x.Router() +} diff --git a/appview/state/state.go b/appview/state/state.go index ed72bed5..dad7623b 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -178,7 +178,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { } notifiers = append(notifiers, indexer) - notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl())) + notifiers = append(notifiers, whnotify.NewNotifier(d, config.Core.BaseUrl(), config.Core.Dev)) notifier := notify.NewMergedNotifier(notifiers) notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) diff --git a/appview/settings/moderation.go b/appview/state/userutil/moderation.go similarity index 99% rename from appview/settings/moderation.go rename to appview/state/userutil/moderation.go index da3df2b0..5a1b865c 100644 --- a/appview/settings/moderation.go +++ b/appview/state/userutil/moderation.go @@ -1,4 +1,4 @@ -package settings +package userutil import ( "regexp" @@ -98,7 +98,7 @@ func foldToASCII(s string) string { // 3. trailing digits stripped // 4. separators stripped + trailing digits stripped // 5. leetspeak normalised variants of all of the above -func subdomainHasSlur(subdomain string) bool { +func HasSlur(subdomain string) bool { lower := strings.ToLower(subdomain) normalized := strings.NewReplacer(".", "", "-", "", "_", "").Replace(lower) stripped := stripTrailingDigits(lower) diff --git a/appview/state/userutil/userutil.go b/appview/state/userutil/userutil.go index dfc69dcc..31044298 100644 --- a/appview/state/userutil/userutil.go +++ b/appview/state/userutil/userutil.go @@ -1,6 +1,9 @@ package userutil import ( + "bufio" + "log/slog" + "os" "regexp" "strings" ) @@ -57,3 +60,47 @@ var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) func IsValidSubdomain(name string) bool { return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) } + +// LoadDisallowedNicknames reads a newline-separated list of reserved nicknames +// from filepath (blank lines and #-comments ignored). An empty filepath or a +// read error yields an empty set. Invalid entries are logged and skipped. +func LoadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { + disallowed := make(map[string]bool) + + if filepath == "" { + logger.Warn("no disallowed nicknames file configured") + return disallowed + } + + file, err := os.Open(filepath) + if err != nil { + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) + return disallowed + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + nickname := strings.ToLower(line) + if IsValidSubdomain(nickname) { + disallowed[nickname] = true + } else { + logger.Warn("invalid nickname format in disallowed nicknames file", + "file", filepath, "line", lineNum, "nickname", nickname) + } + } + + if err := scanner.Err(); err != nil { + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) + } + + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) + return disallowed +} diff --git a/appview/xrpc/account.go b/appview/xrpc/account.go new file mode 100644 index 00000000..8aef97ee --- /dev/null +++ b/appview/xrpc/account.go @@ -0,0 +1,173 @@ +package xrpc + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "strings" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/email" + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) AccountListEmails(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountListEmails") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + emails, err := db.GetAllEmails(x.DB, did) + if err != nil { + l.Error("failed to get emails", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + items := make([]*tangled.TempAccountListEmails_Email, 0, len(emails)) + for _, e := range emails { + items = append(items, &tangled.TempAccountListEmails_Email{ + Address: e.Address, + Verified: e.Verified, + Primary: e.Primary, + CreatedAt: e.CreatedAt.UTC().Format(timeFormat), + }) + } + + x.writeJSON(w, &tangled.TempAccountListEmails_Output{Emails: items}) +} + +func (x *Xrpc) AccountDeleteEmail(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountDeleteEmail") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempAccountDeleteEmail_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + addr := strings.TrimSpace(input.Email) + + existing, err := db.GetEmail(x.DB, did, addr) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, xrpcErrorTag("EmailNotFound", "the email address is not associated with this account"), http.StatusNotFound) + return + } + l.Error("failed to get email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if existing.Primary { + writeError(w, xrpcErrorTag("CannotDeletePrimary", "the primary email address cannot be deleted; set another address as primary first"), http.StatusBadRequest) + return + } + + if err := db.DeleteEmail(x.DB, did, addr); err != nil { + l.Error("failed to delete email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) AccountSetPrimaryEmail(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountSetPrimaryEmail") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempAccountSetPrimaryEmail_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + addr := strings.TrimSpace(input.Email) + + existing, err := db.GetEmail(x.DB, did, addr) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, xrpcErrorTag("EmailNotFound", "the email address is not associated with this account"), http.StatusNotFound) + return + } + l.Error("failed to get email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if !existing.Verified { + writeError(w, xrpcErrorTag("EmailNotVerified", "the email address must be verified before it can be made primary"), http.StatusBadRequest) + return + } + + if err := db.MakeEmailPrimary(x.DB, did, addr); err != nil { + l.Error("failed to set primary email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) AccountSubscribeNewsletter(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountSubscribeNewsletter") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + primary, err := db.GetPrimaryEmail(x.DB, did) + if err != nil || primary.Address == "" { + writeError(w, xrpcErrorTag("NoVerifiedEmail", "a primary email address is required to subscribe"), http.StatusBadRequest) + return + } + + if err := db.UpsertNewsletterPref(x.DB, did, db.NewsletterStatusSubscribed, primary.Address); err != nil { + l.Error("failed to persist newsletter preference", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if x.Config.Resend.ApiKey != "" && x.Config.Resend.NewsletterSegmentId != "" { + go func() { + if err := email.AddNewsletterContact(x.Config.Resend.ApiKey, x.Config.Resend.NewsletterSegmentId, primary.Address); err != nil { + l.Error("failed to add newsletter contact", "err", err) + } + }() + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) AccountDismissNewsletter(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountDismissNewsletter") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + if err := db.UpsertNewsletterPref(x.DB, did, db.NewsletterStatusDismissed, ""); err != nil { + l.Error("failed to persist newsletter dismissal", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/appview/xrpc/focus.go b/appview/xrpc/focus.go new file mode 100644 index 00000000..bde72db9 --- /dev/null +++ b/appview/xrpc/focus.go @@ -0,0 +1,119 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) FocusBegin(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "FocusBegin") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + if err := db.BeginFocus(x.DB, did); err != nil { + l.Error("failed to begin focus", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + item, err := db.GetNextFocusItem(x.DB, did) + if err != nil { + l.Error("failed to get first focus item", "err", err) + _ = db.EndFocus(x.DB, did) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if item == nil { + _ = db.EndFocus(x.DB, did) + x.writeJSON(w, &tangled.TempFocusBeginSession_Output{}) + return + } + + out := &tangled.TempFocusBeginSession_Output{NotificationId: &item.ID} + setFocusSubject(item, &out.RepoDid, &out.IssueAt, &out.PullAt) + + x.writeJSON(w, out) +} + +func (x *Xrpc) FocusNext(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "FocusNext") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempFocusNextItem_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + if err := db.MarkNotificationRead(x.DB, input.CurrentId, did); err != nil { + l.Warn("failed to mark notification read", "id", input.CurrentId, "err", err) + } + + item, err := db.GetNextFocusItem(x.DB, did) + if err != nil { + l.Error("failed to get next focus item", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if item == nil { + _ = db.EndFocus(x.DB, did) + x.writeJSON(w, &tangled.TempFocusNextItem_Output{}) + return + } + + out := &tangled.TempFocusNextItem_Output{NotificationId: &item.ID} + setFocusSubject(item, &out.RepoDid, &out.IssueAt, &out.PullAt) + + x.writeJSON(w, out) +} + +func (x *Xrpc) FocusEnd(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "FocusEnd") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + if err := db.EndFocus(x.DB, did); err != nil { + l.Error("failed to end focus", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// setFocusSubject fills the repo did and (issue|pull) at-uri of a focus item so +// the client can navigate to it. +func setFocusSubject(n *models.NotificationWithEntity, repoDid, issueAt, pullAt **string) { + if n.Repo != nil { + s := n.Repo.RepoDid + *repoDid = &s + } + if n.Issue != nil { + s := n.Issue.AtUri().String() + *issueAt = &s + } + if n.Pull != nil { + s := n.Pull.AtUri().String() + *pullAt = &s + } +} diff --git a/appview/xrpc/notifications.go b/appview/xrpc/notifications.go new file mode 100644 index 00000000..fa7604d6 --- /dev/null +++ b/appview/xrpc/notifications.go @@ -0,0 +1,279 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pagination" + "tangled.org/core/orm" + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) NotificationList(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationList") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + q := r.URL.Query() + readFilter := q.Get("read") + categoryFilter := q.Get("category") + + filters := []orm.Filter{orm.FilterEq("recipient_did", did)} + if readFilter == "unread" { + filters = append(filters, orm.FilterEq("read", 0)) + } + switch categoryFilter { + case "social": + filters = append(filters, orm.FilterIn("type", models.SocialNotificationTypes)) + case "work": + filters = append(filters, orm.FilterIn("type", models.WorkNotificationTypes)) + } + + limit := 50 + if s := q.Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + notifications, err := db.GetNotificationsWithEntities(x.DB, pagination.Page{Limit: limit}, filters...) + if err != nil { + l.Error("failed to get notifications", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + unreadBase := []orm.Filter{ + orm.FilterEq("recipient_did", did), + orm.FilterEq("read", 0), + } + workUnread, _ := db.CountNotifications(x.DB, + append(unreadBase, orm.FilterIn("type", models.WorkNotificationTypes))...) + socialUnread, _ := db.CountNotifications(x.DB, + append(unreadBase, orm.FilterIn("type", models.SocialNotificationTypes))...) + + items := make([]*tangled.TempNotificationListNotifications_Notification, 0, len(notifications)) + for _, n := range notifications { + item := &tangled.TempNotificationListNotifications_Notification{ + Id: n.ID, + Type: string(n.Type), + Category: notificationCategory(n.Type), + ActorDid: n.ActorDid, + Read: n.Read, + CreatedAt: n.Created.UTC().Format("2006-01-02T15:04:05.000Z"), + } + if n.Repo != nil { + s := n.Repo.RepoDid + item.RepoDid = &s + } + if n.Issue != nil { + s := n.Issue.AtUri().String() + item.IssueAt = &s + } + if n.Pull != nil { + s := n.Pull.AtUri().String() + item.PullAt = &s + } + items = append(items, item) + } + + x.writeJSON(w, &tangled.TempNotificationListNotifications_Output{ + Notifications: items, + WorkUnreadCount: workUnread, + SocialUnreadCount: socialUnread, + }) +} + +func (x *Xrpc) NotificationGetUnreadCount(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationGetUnreadCount") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + count, err := db.CountNotifications(x.DB, + orm.FilterEq("recipient_did", did), + orm.FilterEq("read", 0), + ) + if err != nil { + l.Error("failed to count unread notifications", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + x.writeJSON(w, &tangled.TempNotificationGetUnreadCount_Output{Count: count}) +} + +func (x *Xrpc) NotificationUpdateSeen(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationUpdateSeen") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempNotificationUpdateSeen_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + var err error + if input.Read { + err = db.MarkNotificationRead(x.DB, input.Id, did) + } else { + err = db.MarkNotificationUnread(x.DB, input.Id, did) + } + if err != nil { + l.Error("failed to update notification read state", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) NotificationMarkAllRead(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationMarkAllRead") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + if err := db.MarkAllNotificationsRead(x.DB, did); err != nil { + l.Error("failed to mark all notifications read", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) NotificationDelete(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationDelete") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempNotificationDeleteNotification_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + if err := db.DeleteNotification(x.DB, input.Id, did); err != nil { + l.Error("failed to delete notification", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) NotificationGetPreferences(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationGetPreferences") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + prefs, err := db.GetNotificationPreference(x.DB, did) + if err != nil { + l.Error("failed to get notification preferences", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + x.writeJSON(w, &tangled.TempNotificationGetPreferences_Preferences{ + EmailNotifications: prefs.EmailNotifications, + Followed: prefs.Followed, + IssueClosed: prefs.IssueClosed, + IssueCommented: prefs.IssueCommented, + IssueCreated: prefs.IssueCreated, + PullCommented: prefs.PullCommented, + PullCreated: prefs.PullCreated, + PullMerged: prefs.PullMerged, + RepoStarred: prefs.RepoStarred, + UserMentioned: prefs.UserMentioned, + }) +} + +func (x *Xrpc) NotificationUpdatePreferences(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationUpdatePreferences") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempNotificationUpdatePreferences_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + existing, err := db.GetNotificationPreference(x.DB, did) + if err != nil { + l.Error("failed to get existing notification preferences", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + prefs := &models.NotificationPreferences{ + UserDid: syntax.DID(did), + RepoStarred: applyBoolPtr(existing.RepoStarred, input.RepoStarred), + IssueCreated: applyBoolPtr(existing.IssueCreated, input.IssueCreated), + IssueCommented: applyBoolPtr(existing.IssueCommented, input.IssueCommented), + IssueClosed: applyBoolPtr(existing.IssueClosed, input.IssueClosed), + PullCreated: applyBoolPtr(existing.PullCreated, input.PullCreated), + PullCommented: applyBoolPtr(existing.PullCommented, input.PullCommented), + PullMerged: applyBoolPtr(existing.PullMerged, input.PullMerged), + Followed: applyBoolPtr(existing.Followed, input.Followed), + UserMentioned: applyBoolPtr(existing.UserMentioned, input.UserMentioned), + EmailNotifications: applyBoolPtr(existing.EmailNotifications, input.EmailNotifications), + } + + if err := x.DB.UpdateNotificationPreferences(r.Context(), prefs); err != nil { + l.Error("failed to update notification preferences", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func notificationCategory(t models.NotificationType) string { + for _, st := range models.SocialNotificationTypes { + if st == t { + return "social" + } + } + return "work" +} + +func applyBoolPtr(existing bool, update *bool) bool { + if update != nil { + return *update + } + return existing +} diff --git a/appview/xrpc/search.go b/appview/xrpc/search.go new file mode 100644 index 00000000..a9cfe288 --- /dev/null +++ b/appview/xrpc/search.go @@ -0,0 +1,157 @@ +package xrpc + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/sourcegraph/zoekt" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/codesearch" + "tangled.org/core/appview/pagination" +) + +func (x *Xrpc) SearchSearchCode(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SearchSearchCode") + + if x.CodeSearch == nil { + writeError(w, notImplementedError("code search is not configured"), http.StatusNotImplemented) + return + } + + q := r.URL.Query() + rawQuery := strings.TrimSpace(q.Get("q")) + if rawQuery == "" { + writeError(w, badRequestError("missing required parameter: q"), http.StatusBadRequest) + return + } + + // scope by the repo's own did (meta.did) and language; post-filtered below + var scope []string + var repoFilter syntax.DID + if raw := strings.TrimSpace(q.Get("repoDid")); raw != "" { + repoDid, err := syntax.ParseDID(raw) + if err != nil { + writeError(w, badRequestError("invalid repoDid"), http.StatusBadRequest) + return + } + repoFilter = repoDid + scope = append(scope, fmt.Sprintf("meta.did:%s", repoDid)) + } + if lang := strings.TrimSpace(q.Get("lang")); lang != "" { + scope = append(scope, fmt.Sprintf("lang:%s", lang)) + } + queryStr := strings.TrimSpace(strings.Join(scope, " ") + " " + rawQuery) + + page := pagination.Page{Limit: 50} + if s := q.Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { + page.Limit = n + } + } + if s := q.Get("cursor"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n >= 0 { + page.Offset = n + } + } + + res, err := x.CodeSearch.Search(r.Context(), queryStr, page) + if err != nil { + var repoErr *codesearch.RepoOnlyError + if errors.As(err, &repoErr) { + writeError(w, badRequestError("query only filters by repo name; use repo search instead"), http.StatusBadRequest) + return + } + l.Error("code search failed", "err", err, "query", queryStr) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + // meta.did is best-effort, so drop anything that isn't the requested repo + filtered := res.Results + if repoFilter != "" { + filtered = filtered[:0] + for _, item := range res.Results { + if item.RepoDID == repoFilter { + filtered = append(filtered, item) + } + } + } + + results := make([]*tangled.TempSearchSearchCode_FileResult, 0, len(filtered)) + for _, item := range filtered { + fr := &tangled.TempSearchSearchCode_FileResult{ + RepoDid: item.RepoDID.String(), + Path: item.FilePath, + } + if item.Language != "" { + lang := item.Language + fr.Language = &lang + } + for _, c := range item.Chunks { + fr.Chunks = append(fr.Chunks, &tangled.TempSearchSearchCode_Chunk{ + Content: c.Content, + LineStart: int64(c.ContentStartLine), + Highlights: chunkHighlights(c.Content, c.ContentStartLine, c.Ranges), + }) + } + results = append(results, fr) + } + + out := &tangled.TempSearchSearchCode_Output{Results: results} + if res.HasMore { + cursor := strconv.Itoa(page.Offset + page.Limit) + out.Cursor = &cursor + } + + x.writeJSON(w, out) +} + +// chunkHighlights maps zoekt (line, rune-column) ranges to byte-offset ranges +// within the chunk's content string +func chunkHighlights(content string, startLine int, ranges []zoekt.Range) []*tangled.TempSearchSearchCode_Highlight { + if startLine < 1 { + startLine = 1 + } + lines := strings.SplitAfter(content, "\n") + lineOffset := make([]int, len(lines)) + off := 0 + for i, ln := range lines { + lineOffset[i] = off + off += len(ln) + } + + // byteAt maps a 1-based (line, rune column) to a byte offset in content + byteAt := func(lineNum, runeCol int) int { + idx := lineNum - startLine + if idx < 0 || idx >= len(lines) { + return -1 + } + col := runeCol - 1 + if col < 0 { + col = 0 + } + r := 0 + for bi := range lines[idx] { + if r == col { + return lineOffset[idx] + bi + } + r++ + } + return lineOffset[idx] + len(strings.TrimSuffix(lines[idx], "\n")) + } + + var out []*tangled.TempSearchSearchCode_Highlight + for _, rg := range ranges { + s := byteAt(int(rg.Start.LineNumber), int(rg.Start.Column)) + e := byteAt(int(rg.End.LineNumber), int(rg.End.Column)) + if s < 0 || e < 0 || e <= s { + continue + } + out = append(out, &tangled.TempSearchSearchCode_Highlight{Start: int64(s), End: int64(e)}) + } + return out +} diff --git a/appview/xrpc/signup.go b/appview/xrpc/signup.go new file mode 100644 index 00000000..07c39635 --- /dev/null +++ b/appview/xrpc/signup.go @@ -0,0 +1,306 @@ +package xrpc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/email" + "tangled.org/core/appview/models" + "tangled.org/core/appview/state/userutil" +) + +func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountBeginSignup") + + // signup is gated on cloudflare being configured, mirroring appview/signup + if x.Cloudflare == nil { + writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) + return + } + + var input tangled.TempAccountBeginSignup_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + if err := x.validateTurnstile(input.TurnstileToken, r); err != nil { + l.Warn("turnstile validation failed", "err", err, "email", input.Email) + writeError(w, xrpcErrorTag("InvalidTurnstileToken", "captcha validation failed"), http.StatusForbidden) + return + } + + if !email.IsValidEmail(input.Email) { + writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) + return + } + + exists, err := db.CheckEmailExistsAtAll(x.DB, input.Email) + if err != nil { + l.Error("failed to check email existence", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if exists { + writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) + return + } + + // the verification code is an invite code minted by the PDS + code, err := x.pdsCreateInviteCode() + if err != nil { + l.Error("failed to create invite code", "err", err) + writeError(w, errUpstream, http.StatusBadGateway) + return + } + + em := email.Email{ + APIKey: x.Config.Resend.ApiKey, + From: x.Config.Resend.SentFrom, + To: input.Email, + Subject: "Verify your Tangled account", + Text: "Copy and paste this code below to verify your account on Tangled.\n" + code, + Html: "

Copy and paste this code below to verify your account on Tangled.

\n

" + code + "

", + } + if err := email.SendEmail(em); err != nil { + l.Error("failed to send verification email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: input.Email, InviteCode: code}); err != nil { + l.Error("failed to add inflight signup", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountCompleteSignup") + + if x.Cloudflare == nil { + writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) + return + } + + var input tangled.TempAccountCompleteSignup_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + if !userutil.IsValidSubdomain(input.Username) { + writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest) + return + } + if x.DisallowedNicknames[strings.ToLower(input.Username)] { + writeError(w, xrpcErrorTag("UsernameUnavailable", "this username is not available"), http.StatusConflict) + return + } + + emailAddr, err := db.GetEmailForCode(x.DB, input.Code) + if err != nil { + l.Error("failed to get email for code", "err", err) + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest) + return + } + + did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code) + if err != nil { + l.Error("failed to provision account", "err", err) + writeError(w, errUpstream, http.StatusBadGateway) + return + } + + go func() { + if err := db.DeleteInflightSignup(x.DB, emailAddr); err != nil { + l.Error("failed to delete inflight signup", "err", err) + } + }() + + x.writeJSON(w, &tangled.TempAccountCompleteSignup_Output{Did: did, Handle: handle}) +} + +// provisionAccount creates the pds account, records its verified primary email, +// and auto-claims the sites subdomain, rolling back on failure. +func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did, handle string, err error) { + success := false + emailAdded := false + defer func() { + if success { + return + } + x.Logger.Info("rolling back signup", "username", username, "did", did) + if did != "" { + if derr := x.pdsDeleteAccount(did); derr != nil { + x.Logger.Error("failed to roll back PDS account", "err", derr, "did", did) + } + } + if emailAdded { + if derr := db.DeleteEmail(x.DB, did, emailAddr); derr != nil { + x.Logger.Error("failed to roll back email row", "err", derr, "email", emailAddr) + } + } + }() + + did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code) + if err != nil { + return "", "", err + } + + if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil { + return "", "", err + } + emailAdded = true + + // auto-claim .: the only way to get a pds-domain site + pdsDomain := strings.TrimPrefix(x.Config.Pds.Host, "https://") + pdsDomain = strings.TrimPrefix(pdsDomain, "http://") + autoClaim := username + "." + pdsDomain + if err := db.ClaimDomain(x.DB, did, autoClaim); err != nil { + x.Logger.Warn("failed to auto-claim sites domain at signup", "domain", autoClaim, "did", did, "err", err) + } + + success = true + return did, handle, nil +} + +func (x *Xrpc) validateTurnstile(token string, r *http.Request) error { + if token == "" { + return errors.New("captcha token is empty") + } + if x.Config.Cloudflare.Turnstile.SecretKey == "" { + return errors.New("turnstile secret key not configured") + } + + data := url.Values{} + data.Set("secret", x.Config.Cloudflare.Turnstile.SecretKey) + data.Set("response", token) + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + data.Set("remoteip", ip) + } else if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + if parts := strings.Split(fwd, ","); len(parts) > 0 { + data.Set("remoteip", strings.TrimSpace(parts[0])) + } + } else { + data.Set("remoteip", r.RemoteAddr) + } + + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) + if err != nil { + return fmt.Errorf("failed to verify turnstile token: %w", err) + } + defer resp.Body.Close() + + var tr struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return fmt.Errorf("failed to decode turnstile response: %w", err) + } + if !tr.Success { + return fmt.Errorf("turnstile validation failed: %v", tr.ErrorCodes) + } + return nil +} + +// pdsRequest posts to a pds xrpc endpoint; useAuth sends the admin secret via +// basic auth. these are unauth'd or admin-authed, so they use raw http. +func (x *Xrpc) pdsRequest(endpoint string, body any, useAuth bool) (*http.Response, error) { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, err + } + u := fmt.Sprintf("%s/xrpc/%s", x.Config.Pds.Host, endpoint) + req, err := http.NewRequest(http.MethodPost, u, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if useAuth { + req.SetBasicAuth("admin", x.Config.Pds.AdminSecret) + } + return http.DefaultClient.Do(req) +} + +func pdsError(resp *http.Response, action string) error { + var e struct { + Error string `json:"error"` + Message string `json:"message"` + } + b, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(b, &e); err == nil && e.Message != "" { + return fmt.Errorf("failed to %s: %s - %s", action, e.Error, e.Message) + } + return fmt.Errorf("failed to %s, status %d", action, resp.StatusCode) +} + +func (x *Xrpc) pdsCreateInviteCode() (string, error) { + resp, err := x.pdsRequest("com.atproto.server.createInviteCode", map[string]any{"useCount": 1}, true) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", pdsError(resp, "create invite code") + } + var result map[string]string + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode invite code response: %w", err) + } + return result["code"], nil +} + +func (x *Xrpc) pdsCreateAccount(username, password, emailAddr, code string) (did, handle string, err error) { + parsed, err := url.Parse(x.Config.Pds.Host) + if err != nil { + return "", "", fmt.Errorf("invalid PDS host URL: %w", err) + } + handle = fmt.Sprintf("%s.%s", username, parsed.Hostname()) + + body := map[string]string{ + "email": emailAddr, + "handle": handle, + "password": password, + "inviteCode": code, + } + resp, err := x.pdsRequest("com.atproto.server.createAccount", body, false) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", pdsError(resp, "create account") + } + + var result struct { + DID string `json:"did"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", "", fmt.Errorf("failed to decode create account response: %w", err) + } + return result.DID, handle, nil +} + +func (x *Xrpc) pdsDeleteAccount(did string) error { + resp, err := x.pdsRequest("com.atproto.admin.deleteAccount", map[string]string{"did": did}, true) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return pdsError(resp, "delete account") + } + return nil +} diff --git a/appview/xrpc/sites.go b/appview/xrpc/sites.go new file mode 100644 index 00000000..15210f39 --- /dev/null +++ b/appview/xrpc/sites.go @@ -0,0 +1,329 @@ +package xrpc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "path" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/sites" + "tangled.org/core/appview/state/userutil" + xrpcerr "tangled.org/core/xrpc/errors" +) + +func (x *Xrpc) SiteGetDomainClaim(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SiteGetDomainClaim") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + claim, err := db.GetActiveDomainClaimForDid(x.DB, did) + if err != nil { + l.Error("failed to get domain claim", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + out := &tangled.TempSiteGetDomainClaim_Output{} + if claim != nil { + out.Domain = &claim.Domain + } + x.writeJSON(w, out) +} + +func (x *Xrpc) SiteClaimDomain(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SiteClaimDomain") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempSiteClaimDomain_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + subdomain := strings.TrimSpace(input.Subdomain) + if len(subdomain) < 4 { + writeError(w, xrpcErrorTag("InvalidSubdomain", "subdomain must be at least 4 characters long"), http.StatusBadRequest) + return + } + if !userutil.IsValidSubdomain(subdomain) { + writeError(w, xrpcErrorTag("InvalidSubdomain", "use only lowercase letters, digits, and hyphens; cannot start or end with a hyphen"), http.StatusBadRequest) + return + } + if userutil.HasSlur(subdomain) { + writeError(w, xrpcErrorTag("InvalidSubdomain", "that subdomain is not allowed"), http.StatusBadRequest) + return + } + + sitesDomain := x.Config.Sites.Domain + if subdomain == sitesDomain { + writeError(w, xrpcErrorTag("InvalidSubdomain", "cannot claim the root domain"), http.StatusBadRequest) + return + } + fullDomain := subdomain + "." + sitesDomain + + if err := db.ClaimDomain(x.DB, did, fullDomain); err != nil { + switch { + case errors.Is(err, db.ErrDomainTaken): + writeError(w, xrpcErrorTag("DomainTaken", err.Error()), http.StatusConflict) + case errors.Is(err, db.ErrDomainCooldown): + writeError(w, xrpcErrorTag("DomainCooldown", err.Error()), http.StatusConflict) + case errors.Is(err, db.ErrAlreadyClaimed): + writeError(w, xrpcErrorTag("AlreadyClaimed", err.Error()), http.StatusConflict) + default: + l.Error("claiming domain", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + } + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) SiteReleaseDomain(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SiteReleaseDomain") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempSiteReleaseDomain_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + domain := strings.TrimSpace(input.Domain) + if domain == "" { + writeError(w, badRequestError("domain cannot be empty"), http.StatusBadRequest) + return + } + + // a tngl.sh handle's sites domain is auto-claimed at signup and handle-bound + if isTngl, err := x.isTnglHandle(r.Context(), did); err != nil { + l.Error("resolving identity", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } else if isTngl { + writeError(w, xrpcErrorTag("HandleBoundDomain", "your tngl.sh domain is tied to your handle and cannot be released"), http.StatusBadRequest) + return + } + + if err := db.ReleaseDomain(x.DB, did, domain); err != nil { + l.Error("releasing domain", "err", err) + writeError(w, xrpcErrorTag("DomainNotFound", "unable to release domain; ensure it belongs to your account"), http.StatusNotFound) + return + } + + // clean up all site data for this did asynchronously + if x.Cloudflare != nil && x.Cloudflare.Enabled() { + siteConfigs, err := db.GetRepoSiteConfigsForDid(x.DB, did) + if err != nil { + l.Error("fetching site configs for cleanup", "err", err) + } + if err := db.DeleteRepoSiteConfigsForDid(x.DB, did); err != nil { + l.Error("deleting site configs from db", "err", err) + } + + go func() { + ctx := context.Background() + for _, sc := range siteConfigs { + if err := sites.Delete(ctx, x.Cloudflare, did, sc.RepoRkey); err != nil { + l.Error("R2 delete failed", "did", did, "repo", sc.RepoRkey, "err", err) + } + } + if err := sites.DeleteAllDomainMappings(ctx, x.Cloudflare, domain); err != nil { + l.Error("KV delete failed", "domain", domain, "err", err) + } + }() + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) SiteGetRepoSiteConfig(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SiteGetRepoSiteConfig") + + repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + config, err := db.GetRepoSiteConfig(x.DB, repo.RepoDid) + if err != nil { + l.Error("failed to get repo site config", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + out := &tangled.TempRepoGetSiteConfig_Output{} + if config != nil { + out.Config = &tangled.TempRepoGetSiteConfig_SiteConfig{ + Branch: config.Branch, + Dir: config.Dir, + IsIndex: config.IsIndex, + } + } + x.writeJSON(w, out) +} + +func (x *Xrpc) SiteUpdateRepoSiteConfig(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SiteUpdateRepoSiteConfig") + + var input tangled.TempRepoUpdateSiteConfig_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + branch := strings.TrimSpace(input.Branch) + if branch == "" { + writeError(w, badRequestError("branch cannot be empty"), http.StatusBadRequest) + return + } + + dir := strings.TrimSpace(input.Dir) + if dir == "" { + dir = "/" + } + dir = path.Clean("/" + dir) + if dir != "/" && strings.Contains(dir, "..") { + writeError(w, badRequestError("invalid directory path"), http.StatusBadRequest) + return + } + + isIndex := input.IsIndex != nil && *input.IsIndex + + // check the claim before persisting, so a failed call leaves no state + ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) + if ownerClaim == nil { + writeError(w, xrpcErrorTag("NoDomainClaim", "the account does not have an active domain claim"), http.StatusBadRequest) + return + } + + if err := db.SetRepoSiteConfig(x.DB, repo.RepoDid, branch, dir, isIndex); err != nil { + l.Error("failed to save site config", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if x.Cloudflare != nil && x.Cloudflare.Enabled() { + go x.deploySite(repo, branch, dir, isIndex, ownerClaim.Domain) + } else { + l.Warn("cloudflare integration disabled; site won't be deployed", "repo", repo.RepoIdentifier()) + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) SiteDisableRepoSite(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "SiteDisableRepoSite") + + var input tangled.TempRepoDisableSite_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + existingConfig, _ := db.GetRepoSiteConfig(x.DB, repo.RepoDid) + if existingConfig == nil { + writeError(w, xrpcErrorTag("SiteNotFound", "no site configuration exists for this repository"), http.StatusNotFound) + return + } + + if err := db.DeleteRepoSiteConfig(x.DB, repo.RepoDid); err != nil { + l.Error("failed to delete site config", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + if x.Cloudflare != nil && x.Cloudflare.Enabled() { + ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) + go func() { + ctx := context.Background() + if err := sites.Delete(ctx, x.Cloudflare, repo.Did, repo.Rkey); err != nil { + l.Error("R2 delete failed", "repo", repo.RepoIdentifier(), "err", err) + } + if ownerClaim != nil { + if err := sites.DeleteDomainMapping(ctx, x.Cloudflare, ownerClaim.Domain, repo.Name); err != nil { + l.Error("KV delete failed", "domain", ownerClaim.Domain, "err", err) + } + } + }() + } + + w.WriteHeader(http.StatusOK) +} + +// deploySite syncs a repo's site to r2 and writes the domain mapping, mirroring +// the appview's SaveRepoSiteConfig deploy path +func (x *Xrpc) deploySite(repo *models.Repo, branch, dir string, isIndex bool, domain string) { + l := x.Logger.With("handler", "deploySite", "repo", repo.RepoIdentifier()) + ctx := context.Background() + + deploy := &models.SiteDeploy{ + RepoDid: syntax.DID(repo.RepoDid), + Branch: branch, + Dir: dir, + Trigger: models.SiteDeployTriggerConfigChange, + } + + deployErr := sites.Deploy(ctx, x.Cloudflare, x.Config, repo, branch, dir) + if deployErr != nil { + l.Error("initial R2 sync failed", "err", deployErr) + deploy.Status = models.SiteDeployStatusFailure + deploy.Error = deployErr.Error() + } else { + deploy.Status = models.SiteDeployStatusSuccess + } + + if err := db.AddSiteDeploy(x.DB, deploy); err != nil { + l.Error("failed to record deploy", "err", err) + } + + if deployErr == nil { + if err := sites.PutDomainMapping(ctx, x.Cloudflare, domain, repo.Did, repo.Name, repo.Rkey, isIndex); err != nil { + l.Error("KV write failed", "domain", domain, "err", err) + } + } +} + +// isTnglHandle reports whether the account's handle sits under the PDS user +// domain (e.g. *.tngl.sh). Such users have a handle-bound sites domain that was +// auto-claimed at signup and must not be released. +func (x *Xrpc) isTnglHandle(ctx context.Context, did string) (bool, error) { + ident, err := x.IdResolver.ResolveIdent(ctx, did) + if err != nil { + return false, err + } + return strings.HasSuffix(ident.Handle.String(), x.Config.Pds.UserDomain), nil +} diff --git a/appview/xrpc/webhooks.go b/appview/xrpc/webhooks.go new file mode 100644 index 00000000..50b9b40b --- /dev/null +++ b/appview/xrpc/webhooks.go @@ -0,0 +1,382 @@ +package xrpc + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/hostutil" + xrpcerr "tangled.org/core/xrpc/errors" +) + +// resolveOwnedRepo loads the repo by its DID and checks the actor owns it +func (x *Xrpc) resolveOwnedRepo(r *http.Request, repoDid string) (*models.Repo, *xrpcerr.XrpcError, int) { + did, ok := actorDid(r) + if !ok { + e := xrpcerr.MissingActorDidError + return nil, &e, http.StatusForbidden + } + + repo, err := db.GetRepoByDid(x.DB, repoDid) + if err != nil { + e := notFoundError("repo not found") + return nil, &e, http.StatusNotFound + } + + if repo.Did != did { + e := xrpcerr.AccessControlError(did) + return nil, &e, http.StatusForbidden + } + + return repo, nil, http.StatusOK +} + +func (x *Xrpc) WebhookList(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "WebhookList") + + repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + webhooks, err := db.GetWebhooksForRepo(x.DB, string(repo.RepoDid)) + if err != nil { + l.Error("failed to get webhooks", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + items := make([]*tangled.TempRepoListWebhooks_Webhook, 0, len(webhooks)) + for i := range webhooks { + wh := &webhooks[i] + updated := wh.UpdatedAt.UTC().Format(timeFormat) + items = append(items, &tangled.TempRepoListWebhooks_Webhook{ + Id: wh.Id, + Url: wh.Url, + Active: wh.Active, + Events: wh.Events, + CreatedAt: wh.CreatedAt.UTC().Format(timeFormat), + UpdatedAt: &updated, + }) + } + + x.writeJSON(w, &tangled.TempRepoListWebhooks_Output{Webhooks: items}) +} + +func (x *Xrpc) WebhookCreate(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "WebhookCreate") + + var input tangled.TempRepoCreateWebhook_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + url := strings.TrimSpace(input.Url) + if err := hostutil.ValidateExternalURL(url, x.Config.Core.Dev); err != nil { + writeError(w, badRequestError(err.Error()), http.StatusBadRequest) + return + } + if len(input.Events) == 0 { + writeError(w, xrpcErrorTag("NoEventsSelected", "at least one event must be specified"), http.StatusBadRequest) + return + } + + active := true + if input.Active != nil { + active = *input.Active + } + secret := "" + if input.Secret != nil { + secret = strings.TrimSpace(*input.Secret) + } + + webhook := &models.Webhook{ + RepoDid: syntax.DID(repo.RepoDid), + Url: url, + Secret: secret, + Active: active, + Events: input.Events, + } + + tx, err := x.DB.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if err := db.AddWebhook(tx, webhook); err != nil { + l.Error("failed to add webhook", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + x.writeJSON(w, &tangled.TempRepoCreateWebhook_Output{Id: webhook.Id}) +} + +func (x *Xrpc) WebhookUpdate(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "WebhookUpdate") + + var input tangled.TempRepoUpdateWebhook_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + webhook, err := db.GetWebhook(x.DB, input.Id) + if err != nil || string(webhook.RepoDid) != repo.RepoDid { + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) + return + } + + if input.Url != nil { + url := strings.TrimSpace(*input.Url) + if url != "" { + if err := hostutil.ValidateExternalURL(url, x.Config.Core.Dev); err != nil { + writeError(w, badRequestError(err.Error()), http.StatusBadRequest) + return + } + webhook.Url = url + } + } + if input.Secret != nil { + webhook.Secret = strings.TrimSpace(*input.Secret) + } + if input.Active != nil { + webhook.Active = *input.Active + } + if len(input.Events) > 0 { + webhook.Events = input.Events + } + + tx, err := x.DB.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if err := db.UpdateWebhook(tx, webhook); err != nil { + l.Error("failed to update webhook", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) WebhookDelete(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "WebhookDelete") + + var input tangled.TempRepoDeleteWebhook_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + webhook, err := db.GetWebhook(x.DB, input.Id) + if err != nil || string(webhook.RepoDid) != repo.RepoDid { + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) + return + } + + tx, err := x.DB.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if err := db.DeleteWebhook(tx, input.Id); err != nil { + l.Error("failed to delete webhook", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (x *Xrpc) WebhookToggle(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "WebhookToggle") + + var input tangled.TempRepoToggleWebhook_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + webhook, err := db.GetWebhook(x.DB, input.Id) + if err != nil || string(webhook.RepoDid) != repo.RepoDid { + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) + return + } + + webhook.Active = !webhook.Active + + tx, err := x.DB.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if err := db.UpdateWebhook(tx, webhook); err != nil { + l.Error("failed to toggle webhook", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + x.writeJSON(w, &tangled.TempRepoToggleWebhook_Output{Active: webhook.Active}) +} + +func (x *Xrpc) WebhookListDeliveries(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "WebhookListDeliveries") + + q := r.URL.Query() + repo, xerr, status := x.resolveOwnedRepo(r, q.Get("repoDid")) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + id, err := strconv.ParseInt(q.Get("id"), 10, 64) + if err != nil { + writeError(w, badRequestError("invalid webhook id"), http.StatusBadRequest) + return + } + + webhook, err := db.GetWebhook(x.DB, id) + if err != nil || string(webhook.RepoDid) != repo.RepoDid { + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) + return + } + + limit := 100 + if s := q.Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + deliveries, err := db.GetWebhookDeliveries(x.DB, webhook.Id, limit) + if err != nil { + l.Error("failed to get webhook deliveries", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + items := make([]*tangled.TempRepoListWebhookDeliveries_Delivery, 0, len(deliveries)) + for i := range deliveries { + d := &deliveries[i] + item := &tangled.TempRepoListWebhookDeliveries_Delivery{ + Id: d.Id, + DeliveryId: d.DeliveryId, + Event: d.Event, + Url: d.Url, + Success: d.Success, + CreatedAt: d.CreatedAt.UTC().Format(timeFormat), + } + if d.RequestBody != "" { + rb := d.RequestBody + item.RequestBody = &rb + } + if d.ResponseBody != "" { + rb := d.ResponseBody + item.ResponseBody = &rb + } + if d.ResponseCode != 0 { + rc := int64(d.ResponseCode) + item.ResponseCode = &rc + } + items = append(items, item) + } + + x.writeJSON(w, &tangled.TempRepoListWebhookDeliveries_Output{Deliveries: items}) +} + +func (x *Xrpc) WebhookRetryDelivery(w http.ResponseWriter, r *http.Request) { + var input tangled.TempRepoRetryWebhookDelivery_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + + repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) + if xerr != nil { + writeError(w, *xerr, status) + return + } + + webhook, err := db.GetWebhook(x.DB, input.WebhookId) + if err != nil || string(webhook.RepoDid) != repo.RepoDid { + writeError(w, xrpcErrorTag("WebhookNotFound", "webhook not found"), http.StatusNotFound) + return + } + + delivery, err := db.GetWebhookDelivery(x.DB, input.DeliveryId) + if err != nil || delivery.WebhookId != webhook.Id { + writeError(w, xrpcErrorTag("DeliveryNotFound", "delivery not found"), http.StatusNotFound) + return + } + + // re-dispatch async; the new attempt is recorded as its own delivery + go x.Webhooks.Redeliver(context.Background(), *webhook, *delivery) + + w.WriteHeader(http.StatusOK) +} diff --git a/appview/xrpc/xrpc.go b/appview/xrpc/xrpc.go new file mode 100644 index 00000000..8c358e31 --- /dev/null +++ b/appview/xrpc/xrpc.go @@ -0,0 +1,184 @@ +package xrpc + +import ( + "encoding/json" + "log/slog" + "net/http" + "runtime/debug" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-chi/chi/v5" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/cloudflare" + "tangled.org/core/appview/codesearch" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + whnotify "tangled.org/core/appview/notify/webhook" + "tangled.org/core/idresolver" + xrpcerr "tangled.org/core/xrpc/errors" + "tangled.org/core/xrpc/serviceauth" +) + +const ActorDid = serviceauth.ActorDid + +type Xrpc struct { + DB *db.DB + Config *config.Config + Logger *slog.Logger + ServiceAuth *serviceauth.ServiceAuth + IdResolver *idresolver.Resolver + Cloudflare *cloudflare.Client + CodeSearch *codesearch.CodeSearch + Webhooks *whnotify.Notifier + + // reserved usernames rejected at signup completion + DisallowedNicknames map[string]bool +} + +func (x *Xrpc) Router() http.Handler { + r := chi.NewRouter() + + r.Use(x.cors) + + // health check, atproto _health convention + r.Get("/_health", x.health) + + // open endpoints: signup happens pre-identity, so no service auth + r.Post("/"+tangled.TempAccountBeginSignupNSID, x.AccountBeginSignup) + r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup) + + // authenticated endpoints + r.Group(func(r chi.Router) { + r.Use(x.ServiceAuth.VerifyServiceAuth) + + // code search is gated on login, matching the appview ui + r.Get("/"+tangled.TempSearchSearchCodeNSID, x.SearchSearchCode) + + // notifications + r.Get("/"+tangled.TempNotificationListNotificationsNSID, x.NotificationList) + r.Get("/"+tangled.TempNotificationGetUnreadCountNSID, x.NotificationGetUnreadCount) + r.Post("/"+tangled.TempNotificationUpdateSeenNSID, x.NotificationUpdateSeen) + r.Post("/"+tangled.TempNotificationMarkAllReadNSID, x.NotificationMarkAllRead) + r.Post("/"+tangled.TempNotificationDeleteNotificationNSID, x.NotificationDelete) + r.Get("/"+tangled.TempNotificationGetPreferencesNSID, x.NotificationGetPreferences) + r.Post("/"+tangled.TempNotificationUpdatePreferencesNSID, x.NotificationUpdatePreferences) + + // focus mode + r.Post("/"+tangled.TempFocusBeginSessionNSID, x.FocusBegin) + r.Post("/"+tangled.TempFocusNextItemNSID, x.FocusNext) + r.Post("/"+tangled.TempFocusEndSessionNSID, x.FocusEnd) + + // account management + r.Get("/"+tangled.TempAccountListEmailsNSID, x.AccountListEmails) + r.Post("/"+tangled.TempAccountDeleteEmailNSID, x.AccountDeleteEmail) + r.Post("/"+tangled.TempAccountSetPrimaryEmailNSID, x.AccountSetPrimaryEmail) + r.Post("/"+tangled.TempAccountSubscribeNewsletterNSID, x.AccountSubscribeNewsletter) + r.Post("/"+tangled.TempAccountDismissNewsletterNSID, x.AccountDismissNewsletter) + + // webhooks + r.Get("/"+tangled.TempRepoListWebhooksNSID, x.WebhookList) + r.Post("/"+tangled.TempRepoCreateWebhookNSID, x.WebhookCreate) + r.Post("/"+tangled.TempRepoUpdateWebhookNSID, x.WebhookUpdate) + r.Post("/"+tangled.TempRepoDeleteWebhookNSID, x.WebhookDelete) + r.Post("/"+tangled.TempRepoToggleWebhookNSID, x.WebhookToggle) + r.Get("/"+tangled.TempRepoListWebhookDeliveriesNSID, x.WebhookListDeliveries) + r.Post("/"+tangled.TempRepoRetryWebhookDeliveryNSID, x.WebhookRetryDelivery) + + // sites + r.Get("/"+tangled.TempSiteGetDomainClaimNSID, x.SiteGetDomainClaim) + r.Post("/"+tangled.TempSiteClaimDomainNSID, x.SiteClaimDomain) + r.Post("/"+tangled.TempSiteReleaseDomainNSID, x.SiteReleaseDomain) + r.Get("/"+tangled.TempRepoGetSiteConfigNSID, x.SiteGetRepoSiteConfig) + r.Post("/"+tangled.TempRepoUpdateSiteConfigNSID, x.SiteUpdateRepoSiteConfig) + r.Post("/"+tangled.TempRepoDisableSiteNSID, x.SiteDisableRepoSite) + }) + + return r +} + +// timeFormat is the datetime format used across lexicon output fields +const timeFormat = "2006-01-02T15:04:05.000Z" + +// health responds to /xrpc/_health with the running version +func (x *Xrpc) health(w http.ResponseWriter, r *http.Request) { + x.writeJSON(w, map[string]string{"version": serviceVersion()}) +} + +// serviceVersion returns the build's vcs revision, or "dev" +func serviceVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "dev" + } + for _, s := range info.Settings { + if s.Key == "vcs.revision" && s.Value != "" { + return s.Value + } + } + return "dev" +} + +// cors allows the browser origin to call the xrpc endpoints. auth is via +// bearer tokens, not cookies, so a wildcard origin is safe. +func (x *Xrpc) cors(next http.Handler) http.Handler { + origin := x.Config.Core.XrpcCorsOrigin + if origin == "" { + origin = "*" + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + if origin != "*" { + w.Header().Add("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(e) +} + +func (x *Xrpc) writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func actorDid(r *http.Request) (string, bool) { + did, ok := r.Context().Value(ActorDid).(syntax.DID) + if !ok { + return "", false + } + return did.String(), true +} + +// stable client-facing errors; handlers log the real cause and return these +var ( + errInternal = xrpcErrorTag("InternalError", "internal server error") + errBadRequestBody = xrpcErrorTag("InvalidRequest", "invalid request body") + errUpstream = xrpcErrorTag("UpstreamError", "an upstream service failed") +) + +func xrpcErrorTag(tag, message string) xrpcerr.XrpcError { + return xrpcerr.NewXrpcError(xrpcerr.WithTag(tag), xrpcerr.WithMessage(message)) +} + +func badRequestError(message string) xrpcerr.XrpcError { + return xrpcErrorTag("InvalidRequest", message) +} + +func notFoundError(message string) xrpcerr.XrpcError { + return xrpcErrorTag("NotFound", message) +} + +func notImplementedError(message string) xrpcerr.XrpcError { + return xrpcErrorTag("MethodNotImplemented", message) +} diff --git a/appview/xrpc/xrpc_test.go b/appview/xrpc/xrpc_test.go new file mode 100644 index 00000000..33e034ba --- /dev/null +++ b/appview/xrpc/xrpc_test.go @@ -0,0 +1,158 @@ +package xrpc + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/auth" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/xrpc/serviceauth" +) + +const ( + testActor = "did:plc:tester" + testAudience = "did:web:test.example" +) + +// newTestXrpc builds an Xrpc backed by a fresh temp DB, with service auth wired +// to a mock directory holding testActor's key. It returns the router, the DB, +// and a function that signs a service-auth token for a given lexicon method. +func newTestXrpc(t *testing.T) (http.Handler, *db.DB, func(nsid string) string) { + t.Helper() + + d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("db.Make: %v", err) + } + t.Cleanup(func() { d.Close() }) + + priv, err := atcrypto.GeneratePrivateKeyP256() + if err != nil { + t.Fatalf("generate key: %v", err) + } + pub, err := priv.PublicKey() + if err != nil { + t.Fatalf("derive pubkey: %v", err) + } + + dir := identity.NewMockDirectory() + dir.Insert(identity.Identity{ + DID: syntax.DID(testActor), + Keys: map[string]identity.VerificationMethod{ + "atproto": {Type: "Multikey", PublicKeyMultibase: pub.Multibase()}, + }, + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + x := &Xrpc{ + DB: d, + Config: &config.Config{}, + Logger: logger, + ServiceAuth: serviceauth.NewServiceAuth(logger, dir, testAudience), + } + + sign := func(nsid string) string { + lxm := syntax.NSID(nsid) + token, err := auth.SignServiceAuth(syntax.DID(testActor), testAudience, time.Minute, &lxm, priv) + if err != nil { + t.Fatalf("sign service auth: %v", err) + } + return token + } + + return x.Router(), d, sign +} + +func TestHealth(t *testing.T) { + router, _, _ := newTestXrpc(t) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_health", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if body["version"] == "" { + t.Fatalf("missing version in %s", rec.Body.String()) + } +} + +func TestServiceAuthRequired(t *testing.T) { + router, _, _ := newTestXrpc(t) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil)) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 without a service-auth token", rec.Code) + } +} + +func TestNotificationGetUnreadCount(t *testing.T) { + router, d, sign := newTestXrpc(t) + + nsid := "org.tangled.temp.notification.getUnreadCount" + call := func() int { + req := httptest.NewRequest(http.MethodGet, "/"+nsid, nil) + req.Header.Set("Authorization", "Bearer "+sign(nsid)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var out struct { + Count int `json:"count"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + return out.Count + } + + if got := call(); got != 0 { + t.Fatalf("empty db count = %d, want 0", got) + } + + if err := db.CreateNotification(d, &models.Notification{ + RecipientDid: testActor, + ActorDid: "did:plc:someone", + Type: models.NotificationTypeRepoStarred, + Read: false, + }); err != nil { + t.Fatalf("CreateNotification: %v", err) + } + + if got := call(); got != 1 { + t.Fatalf("count after one unread = %d, want 1", got) + } +} + +func TestWrongLexiconTokenRejected(t *testing.T) { + router, _, sign := newTestXrpc(t) + + // a token minted for a different method must not authorize this call + req := httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil) + req.Header.Set("Authorization", "Bearer "+sign("org.tangled.temp.notification.listNotifications")) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for a token bound to a different method", rec.Code) + } +} diff --git a/hostutil/safedial.go b/hostutil/safedial.go new file mode 100644 index 00000000..f11685b8 --- /dev/null +++ b/hostutil/safedial.go @@ -0,0 +1,84 @@ +package hostutil + +import ( + "fmt" + "net" + "net/http" + "net/url" + "syscall" + "time" +) + +// isBlockedIP reports whether ip is loopback, private, link-local (incl. the +// 169.254.169.254 metadata endpoint), multicast, or unspecified. +func isBlockedIP(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsMulticast() || + ip.IsUnspecified() +} + +// safeDialer rejects dials to non-public addresses. the Control hook runs after +// dns resolution, so it also covers rebinding and redirects. disabled in dev. +func safeDialer(dev bool) *net.Dialer { + d := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + if dev { + return d + } + d.Control = func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("invalid dial address %q: %w", address, err) + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("dial address %q did not resolve to an IP", address) + } + if isBlockedIP(ip) { + return fmt.Errorf("refusing to dial %s: reserved or private address", ip) + } + return nil + } + return d +} + +// ValidateExternalURL checks raw is a well-formed http(s) url and rejects +// ip-literal hosts in blocked ranges; dns hosts are re-checked at dial time. +func ValidateExternalURL(raw string, dev bool) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL must use http or https") + } + if u.Hostname() == "" { + return fmt.Errorf("URL must include a host") + } + if dev { + return nil + } + if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedIP(ip) { + return fmt.Errorf("URL host is a reserved or private address") + } + return nil +} + +// SafeClient returns an http.Client for fetching untrusted urls (e.g. +// webhooks): it blocks internal address ranges and won't follow redirects. +func SafeClient(dev bool, timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + DialContext: safeDialer(dev).DialContext, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} -- 2.51.2 From bebc427408bdc836350e59d73c1879a0de6982b9 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Mon, 27 Jul 2026 16:14:54 +0300 Subject: [PATCH 03/25] appview/{config,state}: gate xrpc router behind TANGLED_XRPC_ENABLED Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 4 ++++ appview/state/router.go | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/appview/config/config.go b/appview/config/config.go index d8db366a..202f81b2 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -23,6 +23,10 @@ type CoreConfig struct { Dev bool `env:"DEV, default=false"` DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` + // gates the org.tangled.* xrpc router (/xrpc). off by default; the svelte + // frontend is the only consumer and isn't shipped yet. + XrpcEnabled bool `env:"XRPC_ENABLED, default=false"` + // origin allowed to call the xrpc endpoints from the browser (the svelte // frontend). empty allows any origin, which is safe here since xrpc uses // bearer service-auth tokens rather than cookies. diff --git a/appview/state/router.go b/appview/state/router.go index a45e2eb1..ebec0041 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -18,6 +18,7 @@ import ( "tangled.org/core/appview/middleware" "tangled.org/core/appview/migration" "tangled.org/core/appview/notifications" + whnotify "tangled.org/core/appview/notify/webhook" "tangled.org/core/appview/pipelines" "tangled.org/core/appview/pulls" "tangled.org/core/appview/repo" @@ -25,7 +26,6 @@ import ( "tangled.org/core/appview/signup" "tangled.org/core/appview/spindles" "tangled.org/core/appview/state/userutil" - whnotify "tangled.org/core/appview/notify/webhook" avstrings "tangled.org/core/appview/strings" avtimeline "tangled.org/core/appview/timeline" avxrpc "tangled.org/core/appview/xrpc" @@ -299,7 +299,9 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/focus", s.FocusRouter(mw)) r.Mount("/signup", s.SignupRouter()) - r.Mount("/xrpc", s.XrpcRouter()) + if s.config.Core.XrpcEnabled { + r.Mount("/xrpc", s.XrpcRouter()) + } r.Mount("/", s.oauth.Router()) r.Get("/terms", s.TermsOfService) -- 2.51.2 From e4840a7646f670355109dc82ada190538fbe7131 Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 27 Jul 2026 11:34:07 +0300 Subject: [PATCH 04/25] nix: package knot-server and knot-migrate, add a nixos module Lewis: May this revision serve well! --- flake.nix | 22 ++ knot2/crates/knot-server/src/main.rs | 59 +++- nix/modules/knot-rs.nix | 481 +++++++++++++++++++++++++++ nix/pkgs/knot-crate.nix | 28 ++ 4 files changed, 579 insertions(+), 11 deletions(-) create mode 100644 nix/modules/knot-rs.nix create mode 100644 nix/pkgs/knot-crate.nix diff --git a/flake.nix b/flake.nix index 6a2e3816..9a78a564 100644 --- a/flake.nix +++ b/flake.nix @@ -173,6 +173,14 @@ }; knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; knot = self.callPackage ./nix/pkgs/knot.nix {}; + knot-rs = self.callPackage ./nix/pkgs/knot-crate.nix { + src = self.rustSrc; + crate = "knot-server"; + }; + knot-migrate = self.callPackage ./nix/pkgs/knot-crate.nix { + src = self.rustSrc; + crate = "knot-migrate"; + }; dolly = self.callPackage ./nix/pkgs/dolly.nix {}; tap = self.callPackage ./nix/pkgs/tap.nix {}; knotmirror = self.callPackage ./nix/pkgs/knotmirror.nix {}; @@ -191,6 +199,8 @@ shuttle knot-unwrapped knot + knot-rs + knot-migrate appview docs dolly @@ -219,6 +229,8 @@ spindle knot knot-unwrapped + knot-rs + knot-migrate sqlite-lib docs shuttle @@ -596,6 +608,16 @@ services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot; }; + nixosModules.knot-rs = { + lib, + pkgs, + ... + }: { + imports = [./nix/modules/knot-rs.nix]; + + services.tangled.knot-rs.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot-rs; + services.tangled.knot-rs.migratePackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot-migrate; + }; nixosModules.spindle = { lib, pkgs, diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index 81892a82..32d2934f 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -10,7 +10,7 @@ pub static malloc_conf: &[u8] = use std::collections::BTreeSet; use std::num::{NonZeroU32, NonZeroU64}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -74,22 +74,59 @@ fn init_tracing() { .init(); } +const VALIDATE_CONFIG_ONLY: &str = "--config-only"; + +enum ValidateScope { + ConfigOnly, + Environment, +} + +impl ValidateScope { + fn verify(self, config: &knot_config::Validated) -> anyhow::Result<()> { + match self { + Self::ConfigOnly => Ok(()), + Self::Environment => config + .verify_environment() + .context("verify runtime environment"), + } + } +} + +fn validate(args: impl Iterator) -> anyhow::Result<()> { + let (flags, paths): (Vec, Vec) = args.partition(|arg| arg.starts_with("--")); + flags + .iter() + .find(|flag| flag.as_str() != VALIDATE_CONFIG_ONLY) + .map_or(Ok(()), |unknown| { + Err(anyhow::anyhow!( + "unrecognized flag {unknown}, expected {VALIDATE_CONFIG_ONLY}" + )) + })?; + let scope = match flags.is_empty() { + true => ValidateScope::Environment, + false => ValidateScope::ConfigOnly, + }; + let path = match paths.as_slice() { + [] => None, + [path] => Some(Path::new(path)), + extra => anyhow::bail!( + "expected at most one configuration path, got {}", + extra.len() + ), + }; + let config = knot_config::load(path).context("load configuration")?; + scope.verify(&config)?; + println!("configuration is valid"); + Ok(()) +} + fn subcommand(name: &str) -> Option> { match name { "config-template" => { print!("{}", knot_config::template()); Some(Ok(())) } - "validate" => Some( - knot_config::load(std::env::args().nth(2).map(PathBuf::from).as_deref()) - .context("load configuration") - .and_then(|config| { - config - .verify_environment() - .context("verify runtime environment") - }) - .map(|()| println!("configuration is valid")), - ), + "validate" => Some(validate(std::env::args().skip(2))), _ => None, } } diff --git a/nix/modules/knot-rs.nix b/nix/modules/knot-rs.nix new file mode 100644 index 00000000..2b3092c3 --- /dev/null +++ b/nix/modules/knot-rs.nix @@ -0,0 +1,481 @@ +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.services.tangled.knot-rs; + + inherit (lib) literalExpression mkEnableOption mkOption types; + + settingsFormat = pkgs.formats.toml {}; + + addrType = types.strMatching "^([[][0-9a-fA-F:]+[]]|[^:]+):[0-9]+$"; + absPathType = types.strMatching "^/.+"; + + portOf = addr: lib.toInt (lib.last (lib.splitString ":" addr)); + hostOf = addr: lib.concatStringsSep ":" (lib.init (lib.splitString ":" addr)); + isLoopback = addr: lib.elem (hostOf addr) ["127.0.0.1" "[::1]"]; + + inherit (cfg.settings) server tls; + + sshPort = portOf server.ssh_listen_addr; + listenPort = portOf server.listen_addr; + internalPort = portOf server.internal_listen_addr; + + tlsEnabled = tls.acme_enabled || tls.cert_path != null; + + publicTcpPorts = + lib.optional (!isLoopback server.ssh_listen_addr) sshPort + ++ lib.optional (!isLoopback server.listen_addr) listenPort + ++ lib.optional (tls.mtls_enabled && !isLoopback server.internal_listen_addr) internalPort; + + publicUdpPorts = + lib.optional (tlsEnabled && tls.http3 && !isLoopback server.listen_addr) listenPort; + + bindsPrivilegedPort = + lib.any (port: port < 1024) + ([sshPort listenPort] ++ lib.optional tls.mtls_enabled internalPort); + + stateDirs = lib.unique ( + [cfg.stateDir cfg.settings.repo.scan_path] + ++ lib.optional (cfg.settings.lfs.store_path != null) cfg.settings.lfs.store_path + ++ lib.optional tls.acme_enabled tls.acme_cache_dir + ); + + keyDirs = lib.subtractLists stateDirs (lib.unique [ + (dirOf cfg.settings.secrets.sealed_key_file) + (dirOf server.ssh_host_key_file) + ]); + + writablePaths = stateDirs ++ keyDirs; + + usesHomePath = + lib.any + (path: lib.any (prefix: lib.hasPrefix prefix "${path}/") ["/home/" "/root/"]) + writablePaths; + + populated = lib.filterAttrsRecursive (_: value: value != null) cfg.settings; + + rendered = + settingsFormat.generate "knot.toml" + (lib.filterAttrs (_: value: value != {}) populated); + + configFile = + if pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform + then + pkgs.runCommandLocal "knot-config.toml" { + nativeBuildInputs = [cfg.package]; + } '' + knot-server validate --config-only ${rendered} + ln -s ${rendered} $out + '' + else rendered; +in { + _class = "nixos"; + + options.services.tangled.knot-rs = { + enable = mkEnableOption "the knot git server"; + + package = mkOption { + type = types.package; + description = "Package providing the knot-server binary"; + }; + + migratePackage = mkOption { + type = types.package; + description = "Package providing the knot-migrate binary"; + }; + + installMigrateTool = mkOption { + type = types.bool; + default = false; + description = '' + Whether to instlal {option}`migratePackage` system-wide. + Only needed if doing a one-time migration from the Go knot. + ''; + }; + + user = mkOption { + type = types.str; + default = "knot"; + description = "User the knot runs as and the owner of the repositories"; + }; + + group = mkOption { + type = types.str; + default = cfg.user; + description = "Group the knot runs as"; + }; + + stateDir = mkOption { + type = absPathType; + default = "/var/lib/knot"; + description = "Directory the knot stores its repositories, sealed key, and ssh host key in"; + }; + + openFirewall = mkOption { + type = types.bool; + default = true; + description = '' + Whether to open the port of each listen address that isn't loopback, + plus the matching UDP port when HTTP3 serves over TLS. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr absPathType; + default = null; + example = "/etc/secrets/knot.env"; + description = '' + Environment file as defined in {manpage}`systemd.exec(5)`, + which sets the master key and any other secret + so they stay out of the nix store. + Every `KNOT_*` variable it sets + also overrides the matching key in {option}`settings`. + ''; + }; + + settings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + + options = { + server = { + hostname = mkOption { + type = types.str; + example = "knot.oyster.cafe"; + description = "Public hostname, which is also the knot's did:web identity"; + }; + + admins = mkOption { + type = types.nonEmptyListOf types.str; + example = ["did:plc:boltless"]; + description = '' + DIDs with knot-admin authority. + The knot reports the first entry as its service owner, + so reordering this list changes the owner it advertises. + ''; + }; + + listen_addr = mkOption { + type = addrType; + default = "127.0.0.1:5555"; + description = '' + Address the HTTP surface listens on. + The module default suits a reverse proxy in front, + while the binary's own default is `[::]:5555`. + ''; + }; + + internal_listen_addr = mkOption { + type = addrType; + default = "[::1]:5444"; + description = "Address the mTLS admin surface listens on when {option}`settings.tls.mtls_enabled` is set"; + }; + + ssh_listen_addr = mkOption { + type = addrType; + default = "[::]:2222"; + description = '' + Address the knot's own ssh server listens on. + Moving this to port 22 collides with {option}`services.openssh` + unless that also moves. + ''; + }; + + ssh_host_key_file = mkOption { + type = absPathType; + default = "${cfg.stateDir}/ssh_host_ed25519_key"; + defaultText = literalExpression ''"''${stateDir}/ssh_host_ed25519_key"''; + description = '' + Private ssh host key the knot presents. + The knot creates one on first start when the file is absent, + so its directory must be writable. + Keep this off the nix store. + ''; + }; + }; + + acl.admission = mkOption { + type = types.enum ["closed" "open"]; + default = "closed"; + description = "Whether repository creation needs knot membership"; + }; + + repo.scan_path = mkOption { + type = absPathType; + default = "${cfg.stateDir}/repos"; + defaultText = literalExpression ''"''${stateDir}/repos"''; + description = "Directory the knot serves repositories from"; + }; + + git.object_format = mkOption { + type = types.enum ["sha1" "sha256"]; + default = "sha256"; + description = "Object format for repositories the knot creates"; + }; + + secrets = { + sealed_key_file = mkOption { + type = absPathType; + default = "${cfg.stateDir}/knot.sealed"; + defaultText = literalExpression ''"''${stateDir}/knot.sealed"''; + description = '' + Sealed store for the knot signing key. + The knot creates one on first start when the file is absent, + so its directory must be writable. + ''; + }; + + master_key_env = mkOption { + type = types.strMatching "^[A-Z_][A-Z0-9_]*$"; + default = "KNOT_MASTER_KEY"; + description = '' + Name of the environment variable with the base64 master key that unseals + {option}`settings.secrets.sealed_key_file`. + Set the value itself in {option}`environmentFile`. + Losing it makes every sealed key unreadable. + ''; + }; + }; + + atproto.plc_directory = mkOption { + type = types.str; + example = "https://plc.directory"; + description = "atproto PLC directory. This has no default so that the plcdir is an explicit choice."; + }; + + xrpc.trusted_proxy_header = mkOption { + type = types.nullOr types.str; + default = null; + example = "x-forwarded-for"; + description = '' + Header a trusted reverse proxy appends the client address to. + Rate limiting keys every request on the proxy's own address while this is null. + Only set it if a trusted proxy overwrites the header, + since a client can forge it otherwise. + ''; + }; + + lfs.store_path = mkOption { + type = types.nullOr absPathType; + default = null; + description = "Directory for Git LFS objects. The knot won't serve LFS while this is null."; + }; + + tls = { + cert_path = mkOption { + type = types.nullOr absPathType; + default = null; + example = "/etc/knot/tls/cert.pem"; + description = '' + Certificate chain the knot presents. + The knot serves plain HTTP + while this and {option}`settings.tls.acme_enabled` are both unset. + That suits a reverse proxy in front. + ''; + }; + + key_path = mkOption { + type = types.nullOr absPathType; + default = null; + example = "/etc/knot/tls/key.pem"; + description = '' + Private key for {option}`settings.tls.cert_path`. + Set both or neither. + Keep this off the nix store. + ''; + }; + + http3 = mkOption { + type = types.bool; + default = true; + description = "Whether to serve HTTP/3 over QUIC on the UDP port matching {option}`settings.server.listen_addr`"; + }; + + acme_enabled = mkOption { + type = types.bool; + default = false; + description = "Whether to obtain certificates over ACME instead of reading {option}`settings.tls.cert_path`"; + }; + + acme_cache_dir = mkOption { + type = absPathType; + default = "${cfg.stateDir}/acme"; + defaultText = literalExpression ''"''${stateDir}/acme"''; + description = "Directory for the ACME account key and issued certificates"; + }; + + acme_contact = mkOption { + type = types.nullOr types.str; + default = null; + example = "nel@oyster.cafe"; + description = "Contact email the knot registers the ACME account with, required when ACME is enabled"; + }; + + acme_staging = mkOption { + type = types.bool; + default = false; + description = '' + Whether to use the Let's Encrypt staging directory. + Set it while testing so a typo doesn't exhaust the production rate limit. + ''; + }; + + mtls_enabled = mkOption { + type = types.bool; + default = false; + description = "Whether to serve the mTLS admin surface on {option}`settings.server.internal_listen_addr`"; + }; + + mtls_client_ca_path = mkOption { + type = types.nullOr absPathType; + default = null; + example = "/etc/knot/tls/admin-ca.pem"; + description = "CA that signs admin client certificates, required when mTLS is enabled"; + }; + + mtls_admin_spki_pin = mkOption { + type = types.nullOr types.str; + default = null; + description = "Base64 SHA-256 SPKI pin of the admin client certificate, required when mTLS is enabled"; + }; + }; + }; + }; + + description = '' + Configuration the module renders to `/etc/knot/config.toml`. + The knot reads that file on startup. + Keys beyond the ones declared here pass through unchanged, + and `knot-server validate --config-only` checks the result at build time + when the build platform can run the knot binary. + Run `nix run .#knot-rs -- config-template` for the full key list. + Put secrets in {option}`environmentFile`. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.environmentFile != null; + message = "services.tangled.knot-rs.environmentFile must be set, since the knot reads its master key from ${cfg.settings.secrets.master_key_env} in the environment and won't start without it"; + } + { + assertion = !config.services.openssh.enable || !(lib.elem sshPort config.services.openssh.ports); + message = "services.tangled.knot-rs.settings.server.ssh_listen_addr takes port ${toString sshPort}, which services.openssh already listens on"; + } + { + assertion = (tls.cert_path == null) == (tls.key_path == null); + message = "services.tangled.knot-rs.settings.tls.cert_path and tls.key_path must both be set or both unset"; + } + { + assertion = !(tls.acme_enabled && tls.cert_path != null); + message = "services.tangled.knot-rs.settings.tls.acme_enabled can't combine with a static tls.cert_path"; + } + { + assertion = !tls.acme_enabled || tls.acme_contact != null; + message = "services.tangled.knot-rs.settings.tls.acme_contact is required when tls.acme_enabled is set"; + } + { + assertion = !tls.acme_enabled || !isLoopback server.listen_addr; + message = "services.tangled.knot-rs.settings.tls.acme_enabled needs a certificate authority to reach settings.server.listen_addr, and ${server.listen_addr} is loopback"; + } + { + assertion = !tls.mtls_enabled || tlsEnabled; + message = "services.tangled.knot-rs.settings.tls.mtls_enabled requires a certificate from tls.cert_path or ACME"; + } + { + assertion = !tls.mtls_enabled || (tls.mtls_client_ca_path != null && tls.mtls_admin_spki_pin != null); + message = "services.tangled.knot-rs.settings.tls.mtls_enabled requires tls.mtls_client_ca_path and tls.mtls_admin_spki_pin"; + } + ]; + + warnings = + lib.optional (tls.acme_enabled && listenPort != 443) + "services.tangled.knot-rs validates over TLS-ALPN-01, which a certificate authority reaches on TCP 443, and settings.server.listen_addr uses port ${toString listenPort}. Map 443 to that port."; + + environment.systemPackages = + [cfg.package] + ++ lib.optional cfg.installMigrateTool cfg.migratePackage; + + environment.etc."knot/config.toml".source = configFile; + + users.users.${cfg.user} = { + isSystemUser = true; + home = cfg.stateDir; + inherit (cfg) group; + }; + + users.groups.${cfg.group} = {}; + + systemd.tmpfiles.settings."10-knot-rs" = lib.genAttrs stateDirs (path: { + d = { + mode = + if path == tls.acme_cache_dir + then "0700" + else "0750"; + inherit (cfg) user group; + }; + }); + + systemd.services.knot-rs = { + description = "knot git server"; + after = ["network-online.target"]; + wants = ["network-online.target"]; + wantedBy = ["multi-user.target"]; + + restartTriggers = [configFile]; + + startLimitIntervalSec = 60; + startLimitBurst = 5; + + serviceConfig = { + User = cfg.user; + Group = cfg.group; + UMask = "0077"; + WorkingDirectory = cfg.stateDir; + EnvironmentFile = cfg.environmentFile; + ExecStart = "${lib.getExe cfg.package} /etc/knot/config.toml"; + Restart = "on-failure"; + RestartSec = 5; + TimeoutStopSec = 120; + LimitNOFILE = 65536; + AmbientCapabilities = lib.mkIf bindsPrivilegedPort ["CAP_NET_BIND_SERVICE"]; + CapabilityBoundingSet = + if bindsPrivilegedPort + then ["CAP_NET_BIND_SERVICE"] + else []; + ReadWritePaths = stateDirs ++ map (dir: "-${dir}") keyDirs; + NoNewPrivileges = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + ProtectHome = !usesHomePath; + PrivateTmp = true; + PrivateDevices = true; + PrivateUsers = !bindsPrivilegedPort; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_NETLINK" "AF_UNIX"]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + PrivateMounts = true; + SystemCallFilter = ["@system-service" "~@privileged @resources"]; + SystemCallArchitectures = "native"; + }; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall publicTcpPorts; + networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall publicUdpPorts; + }; +} diff --git a/nix/pkgs/knot-crate.nix b/nix/pkgs/knot-crate.nix new file mode 100644 index 00000000..d8d39f77 --- /dev/null +++ b/nix/pkgs/knot-crate.nix @@ -0,0 +1,28 @@ +{ + rustPlatform, + src, + crate, + cmake, + perl, + ... +}: +rustPlatform.buildRustPackage { + pname = crate; + version = "2.0.0"; + + inherit src; + + cargoLock.lockFile = "${src}/Cargo.lock"; + + nativeBuildInputs = [ + cmake + perl + ]; + + dontUseCmakeConfigure = true; + + cargoBuildFlags = ["--bin" crate "--package" crate]; + doCheck = false; + + meta.mainProgram = crate; +} -- 2.51.2 From 6b5b544c4820b542251330f1bd568e645b5d157c Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 27 Jul 2026 17:07:50 +0300 Subject: [PATCH 05/25] knot2/xrpc: forks take upstream's object format Lewis: May this revision serve well! --- knot2/crates/knot-git/src/repo.rs | 18 ++-- knot2/crates/knot-pack/src/fetch.rs | 86 +++++++++++++++--- knot2/crates/knot-xrpc/src/forks.rs | 41 ++++++++- knot2/crates/knot-xrpc/src/repos.rs | 69 +++++++------- knot2/crates/knot-xrpc/src/tests.rs | 136 +++++++++++++++++++++++----- 5 files changed, 269 insertions(+), 81 deletions(-) diff --git a/knot2/crates/knot-git/src/repo.rs b/knot2/crates/knot-git/src/repo.rs index 2b214c0a..3f3f09bc 100644 --- a/knot2/crates/knot-git/src/repo.rs +++ b/knot2/crates/knot-git/src/repo.rs @@ -212,7 +212,17 @@ impl Layout { } pub fn create(&self, did: &RepoDid) -> Result { - self.init_repo(self.guarded_path(did)?) + self.create_with_format(did, self.object_format) + } + + pub fn create_with_format( + &self, + did: &RepoDid, + format: ObjectFormat, + ) -> Result { + let repo = Repo::create_with_format(self.guarded_path(did)?, format)?; + repo.set_head(&self.head)?; + Ok(repo) } pub fn remove(&self, did: &RepoDid) -> Result<(), GitError> { @@ -233,12 +243,6 @@ impl Layout { pub fn bootstrap_meta(&self, knot: &KnotId) -> Result { init_bare_idempotent(self.meta_path(knot)?) } - - fn init_repo(&self, path: PathBuf) -> Result { - let repo = Repo::create_with_format(path, self.object_format)?; - repo.set_head(&self.head)?; - Ok(repo) - } } pub(crate) fn init_bare_with_format( diff --git a/knot2/crates/knot-pack/src/fetch.rs b/knot2/crates/knot-pack/src/fetch.rs index d692caf3..bdf3165b 100644 --- a/knot2/crates/knot-pack/src/fetch.rs +++ b/knot2/crates/knot-pack/src/fetch.rs @@ -3,7 +3,7 @@ use std::io::Write; use axum::http::{HeaderMap, HeaderValue, Method, header}; use knot_git::{Filter, RefRecord, Repo}; use knot_runtime::{HttpRequest, HttpResponse, HttpTransport, NetworkError}; -use knot_types::{HttpStatus, Oid, RefName}; +use knot_types::{HttpStatus, ObjectFormat, Oid, RefName}; use url::Url; use crate::error::PackError; @@ -30,6 +30,7 @@ pub enum FetchError { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpstreamRefs { + pub object_format: ObjectFormat, pub head_symref: Option, pub refs: Vec, } @@ -81,7 +82,7 @@ async fn execute( Ok(response) } -pub fn parse_advertisement(body: &[u8]) -> Result<(), FetchError> { +pub fn parse_advertisement(body: &[u8]) -> Result { let lines = pkt::data_payloads_all(body).map_err(|error| protocol(error.to_string()))?; let lines: Vec<&str> = lines .iter() @@ -98,7 +99,14 @@ pub fn parse_advertisement(body: &[u8]) -> Result<(), FetchError> { if !has("ls-refs") || !has("fetch") { return Err(protocol("upstream is missing ls-refs or fetch v2 command")); } - Ok(()) + lines + .iter() + .find_map(|line| line.strip_prefix("object-format=")) + .map_or(Ok(ObjectFormat::SHA1), |token| { + ObjectFormat::from_capability(token).ok_or_else(|| { + protocol(format!("upstream advertises unknown object format {token}")) + }) + }) } pub fn ls_refs_request(prefixes: &[&str]) -> Result, PackError> { @@ -114,10 +122,11 @@ pub fn ls_refs_request(prefixes: &[&str]) -> Result, PackError> { Ok(buf) } -pub fn parse_ls_refs(body: &[u8]) -> Result { +pub fn parse_ls_refs(body: &[u8], object_format: ObjectFormat) -> Result { let lines = pkt::data_payloads(body).map_err(|error| protocol(error.to_string()))?; lines.iter().try_fold( UpstreamRefs { + object_format, head_symref: None, refs: Vec::new(), }, @@ -133,6 +142,12 @@ pub fn parse_ls_refs(body: &[u8]) -> Result { .ok_or_else(|| protocol(format!("malformed ref line: {text}")))?; let target = Oid::from_hex(oid).map_err(|_| protocol(format!("malformed ref oid: {oid}")))?; + if target.object_id().kind() != object_format.kind() { + return Err(protocol(format!( + "ref oid {oid} doesn't match advertised object format {}", + object_format.capability() + ))); + } let mut attributes = rest.split(' '); match attributes.next() { Some("HEAD") => { @@ -228,7 +243,7 @@ pub async fn remote_refs( }, ) .await?; - parse_advertisement(&response.body)?; + let object_format = parse_advertisement(&response.body)?; let upload = endpoint(base, "git-upload-pack")?; let response = execute( @@ -241,7 +256,7 @@ pub async fn remote_refs( }, ) .await?; - parse_ls_refs(&response.body) + parse_ls_refs(&response.body, object_format) } pub async fn remote_pack( @@ -277,6 +292,7 @@ pub fn local_refs(source: &Repo, prefixes: &[&str]) -> Result| { + let mut buf = Vec::new(); + data(&mut buf, b"version 2\n"); + data(&mut buf, b"ls-refs\n"); + data(&mut buf, b"fetch\n"); + if let Some(extra) = extra { + data(&mut buf, format!("{extra}\n").as_bytes()); + } + pkt::write_flush(&mut buf).unwrap(); + parse_advertisement(&buf) + }; + assert_eq!( + advertise(Some("object-format=sha256")).unwrap(), + ObjectFormat::SHA256 + ); + assert_eq!( + advertise(None).unwrap(), + ObjectFormat::SHA1, + "protocol v2 treats an absent object-format as sha1" + ); + assert!(matches!( + advertise(Some("object-format=sha3")), + Err(FetchError::Protocol(_)) + )); let mut v0 = Vec::new(); data(&mut v0, b"# service=git-upload-pack\n"); @@ -373,7 +414,7 @@ mod tests { } #[test] - fn ls_refs_lines_parse_with_symref_and_skip_head() { + fn ls_refs_lines_parse_with_symref_skip_head_and_enforce_the_object_format() { let mut body = Vec::new(); data( &mut body, @@ -384,7 +425,7 @@ mod tests { b"95d09f2b10159347eece71399a7e2e907ea3df4f refs/heads/main\n", ); pkt::write_flush(&mut body).unwrap(); - let refs = parse_ls_refs(&body).unwrap(); + let refs = parse_ls_refs(&body, ObjectFormat::SHA1).unwrap(); assert_eq!( refs.head_symref.as_ref().map(RefName::as_str), Some("refs/heads/main") @@ -392,6 +433,24 @@ mod tests { assert_eq!(refs.refs.len(), 1); assert_eq!(refs.refs[0].name.as_str(), "refs/heads/main"); assert_eq!(refs.tips().len(), 1); + + let single = |oid: &str| { + let mut buf = Vec::new(); + data(&mut buf, format!("{oid} refs/heads/main\n").as_bytes()); + pkt::write_flush(&mut buf).unwrap(); + buf + }; + let sha1 = "95d09f2b10159347eece71399a7e2e907ea3df4f"; + let sha256 = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; + assert!(parse_ls_refs(&single(sha256), ObjectFormat::SHA256).is_ok()); + assert!(matches!( + parse_ls_refs(&single(sha256), ObjectFormat::SHA1), + Err(FetchError::Protocol(_)) + )); + assert!(matches!( + parse_ls_refs(&single(sha1), ObjectFormat::SHA256), + Err(FetchError::Protocol(_)) + )); } #[test] @@ -399,7 +458,10 @@ mod tests { let mut body = Vec::new(); data(&mut body, b"zzzz refs/heads/main\n"); pkt::write_flush(&mut body).unwrap(); - assert!(matches!(parse_ls_refs(&body), Err(FetchError::Protocol(_)))); + assert!(matches!( + parse_ls_refs(&body, ObjectFormat::SHA1), + Err(FetchError::Protocol(_)) + )); } #[test] @@ -408,7 +470,7 @@ mod tests { data(&mut body, b"ERR access denied\n"); pkt::write_flush(&mut body).unwrap(); assert!(matches!( - parse_ls_refs(&body), + parse_ls_refs(&body, ObjectFormat::SHA1), Err(FetchError::Remote(message)) if message == "access denied" )); } diff --git a/knot2/crates/knot-xrpc/src/forks.rs b/knot2/crates/knot-xrpc/src/forks.rs index 0fb3dc1f..56c1adec 100644 --- a/knot2/crates/knot-xrpc/src/forks.rs +++ b/knot2/crates/knot-xrpc/src/forks.rs @@ -14,7 +14,7 @@ use knot_index::Resolved; use knot_pack::{FetchError, HaveOids, PackLimits, UpstreamRefs, WantOids}; use knot_postreceive::{Actor, Ci}; use knot_runtime::{Clock, HttpTransport}; -use knot_types::{BranchName, Oid, OwnerDid, RefName, RepoDid}; +use knot_types::{BranchName, ObjectFormat, Oid, OwnerDid, RefName, RepoDid}; use crate::body::{ForkRef, RemoteRef, RepoAtUri, RepoNameArg, Revspec, SourceUrl}; use crate::branches::resolve_at_uri; @@ -84,6 +84,31 @@ pub(crate) fn resolve_upstream( Ok(Upstream::Remote(url.clone())) } +pub(crate) struct ForkSource { + pub(crate) origin: SourceUrl, + pub(crate) upstream: Upstream, + pub(crate) refs: UpstreamRefs, +} + +impl ForkSource { + pub(crate) async fn resolve( + state: &XrpcState, + origin: &SourceUrl, + ) -> Result { + let upstream = resolve_upstream(state, origin)?; + let prefixes = ["HEAD", "refs/heads/", "refs/tags/"] + .iter() + .map(|prefix| prefix.to_string()) + .collect(); + let refs = upstream_refs(state, &upstream, prefixes).await?; + Ok(Self { + origin: origin.clone(), + upstream, + refs, + }) + } +} + fn map_fetch(error: FetchError) -> XrpcError { match error { FetchError::PackTooLarge { limit } => XrpcError::request_too_large(format!( @@ -272,6 +297,7 @@ const FORK_DENIED: &str = "only repository owner or a collaborator may operate o struct ForkState { origin: SourceUrl, haves: Vec, + object_format: ObjectFormat, } fn load_fork_state(repo: &Repo) -> Result { @@ -285,7 +311,11 @@ fn load_fork_state(repo: &Repo) -> Result { .into_iter() .map(|record| record.target) .collect(); - Ok(ForkState { origin, haves }) + Ok(ForkState { + origin, + haves, + object_format: repo.object_format(), + }) } pub(crate) struct SyncResult { @@ -339,6 +369,13 @@ async fn pull_upstream_branch( let upstream = resolve_upstream(state, &fork.origin)?; let refs = upstream_refs(state, &upstream, vec![branch.as_str().to_string()]).await?; + if refs.object_format != fork.object_format { + return Err(XrpcError::conflict(format!( + "upstream stores {} objects but this fork stores {}", + refs.object_format.capability(), + fork.object_format.capability() + ))); + } let tip = refs .find(branch) .ok_or_else(|| XrpcError::not_found("upstream repository doesn't have that branch"))?; diff --git a/knot2/crates/knot-xrpc/src/repos.rs b/knot2/crates/knot-xrpc/src/repos.rs index b7248ff0..e8852650 100644 --- a/knot2/crates/knot-xrpc/src/repos.rs +++ b/knot2/crates/knot-xrpc/src/repos.rs @@ -167,13 +167,6 @@ pub(crate) async fn create_repo( } let input: CreateInput = decode(&body)?; - let source = input - .source - .as_ref() - .map(|source| { - crate::forks::resolve_upstream(&state, source).map(|upstream| (source, upstream)) - }) - .transpose()?; let head = input.default_branch.map(|branch| branch.head_ref()); let owner = OwnerDid::new(actor.as_str()).expect("account DID is always a valid owner DID"); @@ -195,6 +188,12 @@ pub(crate) async fn create_repo( Resolved::Ready(None) => {} } + let fork = match &input.source { + Some(origin) => Some(crate::forks::ForkSource::resolve(&state, origin).await?), + None => None, + }; + let object_format = fork.as_ref().map(|fork| fork.refs.object_format); + let (repo_did, provisioning) = provision_repo_did(&state, &actor, &input.rkey, input.repo_did).await?; let now = state.now(); @@ -214,7 +213,11 @@ pub(crate) async fn create_repo( let placed = repo_did.clone(); let lfs = lfs_store.clone(); let provisioning = run_blocking(move || { - let repo = layout.create(&placed).map_err(|error| match error { + let repo = match object_format { + Some(format) => layout.create_with_format(&placed, format), + None => layout.create(&placed), + } + .map_err(|error| match error { GitError::AlreadyExists(_) => XrpcError::conflict("repository already exists on disk"), GitError::ReservedDid(_) => { XrpcError::invalid_request("repoDid mustn't be knot's own identity") @@ -229,23 +232,21 @@ pub(crate) async fn create_repo( }) .await?; - let lfs_missing = match &source { - Some((source_url, upstream)) => { - match populate(&state, &repo_did, source_url, upstream).await { - Ok(missing) => missing, - Err(error) => { - let layout = state.layout.clone(); - let placed = repo_did.clone(); - let lfs = lfs_store.clone(); - let _ = run_blocking(move || { - rollback_local(&layout, lfs.as_deref(), &placed); - Ok(()) - }) - .await; - return Err(error); - } + let lfs_missing = match &fork { + Some(fork) => match populate(&state, &repo_did, fork).await { + Ok(missing) => missing, + Err(error) => { + let layout = state.layout.clone(); + let placed = repo_did.clone(); + let lfs = lfs_store.clone(); + let _ = run_blocking(move || { + rollback_local(&layout, lfs.as_deref(), &placed); + Ok(()) + }) + .await; + return Err(error); } - } + }, None => Vec::new(), }; @@ -333,26 +334,20 @@ pub(crate) async fn create_repo( async fn populate( state: &Arc>, repo_did: &RepoDid, - source: &SourceUrl, - upstream: &crate::forks::Upstream, + fork: &crate::forks::ForkSource, ) -> Result, XrpcError> { - let prefixes = vec![ - "HEAD".to_string(), - "refs/heads/".to_string(), - "refs/tags/".to_string(), - ]; - let refs = crate::forks::upstream_refs(state, upstream, prefixes).await?; - let tips = refs.tips(); + let tips = fork.refs.tips(); let pack = crate::forks::upstream_pack( state, - upstream, - knot_pack::WantOids::new(refs.tips()), + &fork.upstream, + knot_pack::WantOids::new(tips.clone()), knot_pack::HaveOids::default(), ) .await?; let layout = state.layout.clone(); let placed = repo_did.clone(); - let origin = source.clone(); + let origin = fork.origin.clone(); + let refs = fork.refs.clone(); run_blocking(move || { let repo = layout.open(&placed)?; crate::forks::populate_fork(&repo, &refs, &pack, &origin) @@ -360,7 +355,7 @@ async fn populate( .await?; crate::lfs::mirror_fork_objects( Arc::clone(state), - upstream.clone(), + fork.upstream.clone(), repo_did.clone(), knot_pack::WantOids::new(tips), knot_pack::HaveOids::default(), diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index 55344707..f7d256c9 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -267,11 +267,18 @@ fn only_git_event(world: &World) -> std::sync::Arc { events.remove(0) } -fn bootstrap(dir: &TempDir, rebuild: bool) -> (Layout, Arc, PathBuf) { +fn bootstrap( + dir: &TempDir, + rebuild: bool, + object_format: knot_types::ObjectFormat, +) -> (Layout, Arc, PathBuf) { let scan_path = dir.path().join("repos"); std::fs::create_dir_all(&scan_path).unwrap(); let knot = knot_did(); - let layout = Layout::new(&scan_path).reserving_meta(&knot).unwrap(); + let layout = Layout::new(&scan_path) + .with_object_format(object_format) + .reserving_meta(&knot) + .unwrap(); layout.bootstrap_meta(&knot).unwrap(); let meta_path = layout.meta_path(&knot).unwrap(); let index = Arc::new(Index::new(meta_path.clone(), layout.clone())); @@ -425,23 +432,56 @@ struct World { impl World { fn new() -> Self { - Self::build(256, 256, AdmissionPolicy::Closed, None) + Self::build( + 256, + 256, + AdmissionPolicy::Closed, + None, + knot_types::ObjectFormat::SHA1, + ) } fn open() -> Self { - Self::build(256, 256, AdmissionPolicy::Open, None) + Self::build( + 256, + 256, + AdmissionPolicy::Open, + None, + knot_types::ObjectFormat::SHA1, + ) } fn with_pending_limit(limit: usize) -> Self { - Self::build(limit, limit, AdmissionPolicy::Closed, None) + Self::build( + limit, + limit, + AdmissionPolicy::Closed, + None, + knot_types::ObjectFormat::SHA1, + ) } fn with_limits(global: usize, per_actor: usize) -> Self { - Self::build(global, per_actor, AdmissionPolicy::Closed, None) + Self::build( + global, + per_actor, + AdmissionPolicy::Closed, + None, + knot_types::ObjectFormat::SHA1, + ) } - fn with_git_http(git_http: Arc) -> Self { - Self::build(256, 256, AdmissionPolicy::Closed, Some(git_http)) + fn with_git_http( + git_http: Arc, + object_format: knot_types::ObjectFormat, + ) -> Self { + Self::build( + 256, + 256, + AdmissionPolicy::Closed, + Some(git_http), + object_format, + ) } fn build( @@ -449,9 +489,10 @@ impl World { per_actor: usize, admission: AdmissionPolicy, git_http: Option>, + object_format: knot_types::ObjectFormat, ) -> Self { let dir = tempfile::tempdir().unwrap(); - let (layout, index, meta_path) = bootstrap(&dir, true); + let (layout, index, meta_path) = bootstrap(&dir, true, object_format); let admin = signer(1); let member = signer(2); let stranger = signer(3); @@ -514,7 +555,7 @@ impl World { fn build_state(responder: Responder, rebuild: bool) -> (TempDir, SharedState) { let dir = tempfile::tempdir().unwrap(); - let (layout, index, meta_path) = bootstrap(&dir, rebuild); + let (layout, index, meta_path) = bootstrap(&dir, rebuild, knot_types::ObjectFormat::SHA1); let state = state_from( &dir, (layout, index, meta_path), @@ -2634,7 +2675,17 @@ mod fork_endpoints { use knot_runtime::HttpTransport; use knot_types::{Oid, RefName, UnixSeconds}; - const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + const EMPTY_TREE_SHA256: &str = + "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; + + fn empty_tree(repo: &Repo) -> Oid { + let hex = match repo.object_format() { + knot_types::ObjectFormat::SHA256 => EMPTY_TREE_SHA256, + _ => EMPTY_TREE_SHA1, + }; + Oid::from_hex(hex).unwrap() + } fn ident(time: i64) -> Identity { Identity { @@ -2648,7 +2699,7 @@ mod fork_endpoints { fn put_commit(repo: &Repo, parent: Option, path: &str, content: &str, time: i64) -> Oid { let base_tree = match parent { Some(parent) => repo.find_commit(parent).unwrap().tree, - None => Oid::from_hex(EMPTY_TREE).unwrap(), + None => empty_tree(repo), }; let staged = vec![StagedChange { path: knot_types::RepoPath::new(path).unwrap(), @@ -2991,21 +3042,20 @@ mod fork_endpoints { } #[tokio::test] - async fn a_fork_from_a_remote_knot_fetches_over_http() { + async fn a_fork_over_http_takes_the_upstream_object_format_and_conflicts_when_it_changes() { let upstream_dir = tempfile::tempdir().unwrap(); let upstream_path = upstream_dir.path().join("uni.git"); - let upstream = Repo::create(&upstream_path).unwrap(); - upstream - .set_head(&RefName::new("refs/heads/main").unwrap()) - .unwrap(); + let upstream = + Repo::create_with_format(&upstream_path, knot_types::ObjectFormat::SHA1).unwrap(); + upstream.set_head(&main_ref()).unwrap(); advance(&upstream, &main_ref(), "reef.txt", "kelp forest\n", 1_000); let tip = advance(&upstream, &main_ref(), "tide.txt", "rock pool\n", 1_001); - let served = upstream_path.clone(); + let served = Arc::new(std::sync::RwLock::new(upstream_path.clone())); + let path = Arc::clone(&served); let git_http: Arc = Arc::new(FakeHttp::new(move |request: &HttpRequest| { - let _keep = &upstream_dir; - let repo = Repo::open(&served).unwrap(); + let repo = Repo::open(path.read().unwrap().clone()).unwrap(); let body = if request.url.path().ends_with("/info/refs") { knot_pack::advertise_upload(&repo).unwrap() } else { @@ -3019,13 +3069,35 @@ mod fork_endpoints { }) })); - let world = World::with_git_http(git_http); + let world = World::with_git_http(git_http, knot_types::ObjectFormat::SHA256); add_member_helper(&world).await; + let plain = create_repo_helper(&world, "kelp").await; + assert_eq!( + world.layout.open(&plain).unwrap().object_format(), + knot_types::ObjectFormat::SHA256, + "a repo with no upstream still uses this knot's configured format" + ); + let remote = "https://barnacle.nel.pet/did:plc:squid/uni"; let fork_did = fork_repo(&world, remote, "uni").await; let fork = world.layout.open(&fork_did).unwrap(); + assert_eq!( + fork.object_format(), + knot_types::ObjectFormat::SHA1, + "a fork of a sha1 upstream must be sha1 so the upstream's objects ingest" + ); assert_eq!(fork.find_ref(&main_ref()).unwrap(), Some(tip)); assert_eq!(fork.origin_url().as_deref(), Some(remote)); + assert_eq!( + fork.read_blob( + fork.entry_at(tip, &knot_types::RepoPath::new("tide.txt").unwrap()) + .unwrap() + .unwrap() + .oid + ) + .unwrap(), + b"rock pool\n" + ); let new_tip = advance( &Repo::open(&upstream_path).unwrap(), @@ -3038,8 +3110,26 @@ mod fork_endpoints { sync_fork(&world, &world.member, MEMBER_HOST, "main").await, StatusCode::OK ); - let fork = world.layout.open(&fork_did).unwrap(); - assert_eq!(fork.find_ref(&main_ref()).unwrap(), Some(new_tip)); + assert_eq!( + world + .layout + .open(&fork_did) + .unwrap() + .find_ref(&main_ref()) + .unwrap(), + Some(new_tip) + ); + + let replaced = upstream_dir.path().join("replaced.git"); + let sha256 = Repo::create_with_format(&replaced, knot_types::ObjectFormat::SHA256).unwrap(); + sha256.set_head(&main_ref()).unwrap(); + advance(&sha256, &main_ref(), "reef.txt", "kelp forest\n", 1_000); + *served.write().unwrap() = replaced; + assert_eq!( + sync_fork(&world, &world.member, MEMBER_HOST, "main").await, + StatusCode::CONFLICT, + "the fork reports a format mismatch as a conflict" + ); } } -- 2.51.2 From eca1e96ecaad519286b1e07200b2909833718217 Mon Sep 17 00:00:00 2001 From: "re:fi.64" Date: Tue, 21 Jul 2026 20:43:19 -0500 Subject: [PATCH 06/25] spindle: fix skipped tests not actually being skipped `if` effectively neuters `set -e`: ``` $ bash -c 'set -ex; f() { false; echo 123; }; if f; then echo 456; fi' + f + false + echo 123 123 + echo 456 456 ``` so the test keeps on running. Instead, we can rely on `exit` (which is not going to break as easily) + a subshell to make sure we actually catch error codes. (This might also cause other forms of test "failure" to not get detected, as well.) --- spindle/engines/microvm/test-spindle-microvm.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spindle/engines/microvm/test-spindle-microvm.sh b/spindle/engines/microvm/test-spindle-microvm.sh index 308e5a01..68e2ee73 100755 --- a/spindle/engines/microvm/test-spindle-microvm.sh +++ b/spindle/engines/microvm/test-spindle-microvm.sh @@ -139,7 +139,7 @@ print_summary() { skip_test() { echo "skipped: $*" - return "$SKIP_TEST_RC" + exit "$SKIP_TEST_RC" } host_is_nixos() { @@ -391,7 +391,7 @@ run_test_job() { log "[$name] start (vsock port $port)" local status="Passed" - if "$func" > "$logfile" 2>&1; then + if ("$func") > "$logfile" 2>&1; then status="Passed" else local rc=$? -- 2.51.2 From 2df65b74413c29e2d7fe10ef9a1b7578926bd124 Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 28 Jul 2026 17:13:48 +0300 Subject: [PATCH 07/25] knot2/xrpc: hidden staging ref read Lewis: May this revision serve well! --- knot2/crates/knot-git/src/repo.rs | 10 ++++ knot2/crates/knot-xrpc/src/reads.rs | 11 +++++ knot2/crates/knot-xrpc/tests/reads.rs | 71 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/knot2/crates/knot-git/src/repo.rs b/knot2/crates/knot-git/src/repo.rs index 3f3f09bc..fb167510 100644 --- a/knot2/crates/knot-git/src/repo.rs +++ b/knot2/crates/knot-git/src/repo.rs @@ -566,6 +566,16 @@ impl Repo { } } + pub fn hidden_ref_commit(&self, spec: &str) -> Option { + let name = match spec.starts_with("refs/") { + true => RefName::new(spec.to_string()), + false => RefName::new(format!("refs/{spec}")), + } + .ok() + .filter(is_hidden)?; + self.find_ref(&name).ok().flatten() + } + fn direct_target(&self, reference: &gix::Reference<'_>, depth: usize) -> Option { match (depth, reference.follow()) { (_, None) => reference.try_id().map(|id| id.detach()), diff --git a/knot2/crates/knot-xrpc/src/reads.rs b/knot2/crates/knot-xrpc/src/reads.rs index db3bd400..a0200ec7 100644 --- a/knot2/crates/knot-xrpc/src/reads.rs +++ b/knot2/crates/knot-xrpc/src/reads.rs @@ -137,6 +137,9 @@ fn commit_for(repo: &Repo, refspec: &Revspec) -> Result { } .ok_or_else(ref_not_found)?; let commit = repo.peel_to_commit(oid).map_err(|_| ref_not_found())?; + if hidden_staging_commit(repo, refspec) == Some(commit) { + return Ok(commit); + } match repo.reachable_from_public(commit) { Ok(true) => Ok(commit), Ok(false) => Err(ref_not_found()), @@ -144,6 +147,11 @@ fn commit_for(repo: &Repo, refspec: &Revspec) -> Result { } } +fn hidden_staging_commit(repo: &Repo, refspec: &str) -> Option { + repo.hidden_ref_commit(refspec) + .and_then(|oid| repo.peel_to_commit(oid).ok()) +} + struct LimitWriter { buf: Vec, limit: usize, @@ -989,6 +997,9 @@ pub(crate) async fn repo_compare( .resolve_revision(rev) .and_then(|oid| repo.peel_to_commit(oid).ok()) .ok_or_else(revision_not_found)?; + if hidden_staging_commit(&repo, rev) == Some(commit) { + return Ok(commit); + } match repo.reachable_from_public(commit) { Ok(true) => Ok(commit), Ok(false) => Err(revision_not_found()), diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index 594d09fd..d7885e7b 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -893,6 +893,77 @@ async fn list_refs_reports_paginates_and_drains() { assert!(repos["cursor"].is_null()); } +#[tokio::test] +async fn a_hidden_staging_ref_resolves_for_fork_comparison_reads() { + let world = World::new(); + let (did, work) = seeded(&world, "limpet"); + let bare = world.layout.repo_path(&did).unwrap(); + + sh_git(work.path(), &["checkout", "-q", "-b", "upstream"]); + commit_file( + work.path(), + "upstream.txt", + b"upstream\n", + "upstream moved", + "2026-06-01T12:50:00+02:00", + ); + let upstream = sh_git(work.path(), &["rev-parse", "HEAD"]); + sh_git( + work.path(), + &[ + "push", + "-q", + bare.to_str().unwrap(), + "HEAD:refs/hidden/main/main", + ], + ); + sh_git(work.path(), &["checkout", "-q", "main"]); + commit_file( + work.path(), + "ours.txt", + b"ours\n", + "fork work", + "2026-06-01T12:55:00+02:00", + ); + sh_git( + work.path(), + &["push", "-q", bare.to_str().unwrap(), "HEAD:refs/heads/main"], + ); + + let comparison = get_json( + &world, + &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=hidden/main/main&rev2=main"), + ) + .await; + assert_eq!(comparison["rev1"].as_str().unwrap(), upstream); + assert!(!comparison["format_patch"].as_array().unwrap().is_empty()); + + let log = get_json( + &world, + &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=refs/hidden/main/main"), + ) + .await; + assert!(!log["commits"].as_array().unwrap().is_empty()); + + let (status, _) = get_error( + &world, + &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref={upstream}"), + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "a raw oid reachable only through the hidden ref mustn't resolve" + ); + let (status, error) = get_error( + &world, + &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={upstream}&rev2=main"), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(error, "RevisionNotFound"); +} + #[tokio::test] async fn the_cob_ref_namespace_is_invisible_across_every_read() { let world = World::new(); -- 2.51.2 From 5c6eb63a90a0007229056fd307938d19cffd44c3 Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Sun, 26 Jul 2026 21:01:04 +0200 Subject: [PATCH 08/25] lexicons: add blobs field to `repo.{issue,pull}` Ideally both `body` and `blobs` should be wrapped as single `body` field with `sh.tangled.markup.markdown` type like `sh.tangled.feed.comment` records, but that breaking change is deferred for later `org.tangled.*` migration. Signed-off-by: Wilhelm Berggren Signed-off-by: Seongmin Lee --- api/tangled/cbor_gen.go | 168 +++++++++++++++++++++++++++++++++++++- api/tangled/repoissue.go | 15 ++-- api/tangled/repopull.go | 1 + lexicons/issue/issue.json | 8 ++ lexicons/pulls/pull.json | 8 ++ 5 files changed, 191 insertions(+), 9 deletions(-) diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 36312457..eed70520 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -11386,7 +11386,11 @@ func (t *RepoIssue) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 7 + fieldCount := 8 + + if t.Blobs == nil { + fieldCount-- + } if t.Body == nil { fieldCount-- @@ -11478,6 +11482,35 @@ func (t *RepoIssue) MarshalCBOR(w io.Writer) error { return err } + // t.Blobs ([]*util.LexBlob) (slice) + if t.Blobs != nil { + + if len("blobs") > 1000000 { + return xerrors.Errorf("Value in field \"blobs\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("blobs"))); err != nil { + return err + } + if _, err := cw.WriteString(string("blobs")); err != nil { + return err + } + + if len(t.Blobs) > 8192 { + return xerrors.Errorf("Slice value in field t.Blobs was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Blobs))); err != nil { + return err + } + for _, v := range t.Blobs { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + // t.Title (string) (string) if len("title") > 1000000 { return xerrors.Errorf("Value in field \"title\" was too long") @@ -11682,6 +11715,55 @@ func (t *RepoIssue) UnmarshalCBOR(r io.Reader) (err error) { t.LexiconTypeID = string(sval) } + // t.Blobs ([]*util.LexBlob) (slice) + case "blobs": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Blobs: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Blobs = make([]*util.LexBlob, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Blobs[i] = new(util.LexBlob) + if err := t.Blobs[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Blobs[i] pointer: %w", err) + } + } + + } + + } + } // t.Title (string) (string) case "title": @@ -12416,7 +12498,11 @@ func (t *RepoPull) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 10 + fieldCount := 11 + + if t.Blobs == nil { + fieldCount-- + } if t.Body == nil { fieldCount-- @@ -12493,6 +12579,35 @@ func (t *RepoPull) MarshalCBOR(w io.Writer) error { return err } + // t.Blobs ([]*util.LexBlob) (slice) + if t.Blobs != nil { + + if len("blobs") > 1000000 { + return xerrors.Errorf("Value in field \"blobs\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("blobs"))); err != nil { + return err + } + if _, err := cw.WriteString(string("blobs")); err != nil { + return err + } + + if len(t.Blobs) > 8192 { + return xerrors.Errorf("Slice value in field t.Blobs was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Blobs))); err != nil { + return err + } + for _, v := range t.Blobs { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + // t.Title (string) (string) if len("title") > 1000000 { return xerrors.Errorf("Value in field \"title\" was too long") @@ -12779,6 +12894,55 @@ func (t *RepoPull) UnmarshalCBOR(r io.Reader) (err error) { t.LexiconTypeID = string(sval) } + // t.Blobs ([]*util.LexBlob) (slice) + case "blobs": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Blobs: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Blobs = make([]*util.LexBlob, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Blobs[i] = new(util.LexBlob) + if err := t.Blobs[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Blobs[i] pointer: %w", err) + } + } + + } + + } + } // t.Title (string) (string) case "title": diff --git a/api/tangled/repoissue.go b/api/tangled/repoissue.go index 3d02d83d..f2279f2b 100644 --- a/api/tangled/repoissue.go +++ b/api/tangled/repoissue.go @@ -17,11 +17,12 @@ func init() { } // // RECORDTYPE: RepoIssue type RepoIssue struct { - LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` - CreatedAt string `json:"createdAt" cborgen:"createdAt"` - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` - References []string `json:"references,omitempty" cborgen:"references,omitempty"` - Repo string `json:"repo" cborgen:"repo"` - Title string `json:"title" cborgen:"title"` + LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` + Blobs []*util.LexBlob `json:"blobs,omitempty" cborgen:"blobs,omitempty"` + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` + References []string `json:"references,omitempty" cborgen:"references,omitempty"` + Repo string `json:"repo" cborgen:"repo"` + Title string `json:"title" cborgen:"title"` } diff --git a/api/tangled/repopull.go b/api/tangled/repopull.go index 7e878f3a..e2106ff5 100644 --- a/api/tangled/repopull.go +++ b/api/tangled/repopull.go @@ -18,6 +18,7 @@ func init() { // RECORDTYPE: RepoPull type RepoPull struct { LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` + Blobs []*util.LexBlob `json:"blobs,omitempty" cborgen:"blobs,omitempty"` Body *string `json:"body,omitempty" cborgen:"body,omitempty"` CreatedAt string `json:"createdAt" cborgen:"createdAt"` DependentOn *string `json:"dependentOn,omitempty" cborgen:"dependentOn,omitempty"` diff --git a/lexicons/issue/issue.json b/lexicons/issue/issue.json index cd484feb..e1800649 100644 --- a/lexicons/issue/issue.json +++ b/lexicons/issue/issue.json @@ -38,6 +38,14 @@ "type": "string", "format": "at-uri" } + }, + "blobs": { + "type": "array", + "items": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + } } } } diff --git a/lexicons/pulls/pull.json b/lexicons/pulls/pull.json index e4934808..47393a03 100644 --- a/lexicons/pulls/pull.json +++ b/lexicons/pulls/pull.json @@ -58,6 +58,14 @@ "dependentOn": { "type": "string", "format": "at-uri" + }, + "blobs": { + "type": "array", + "items": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + } } } } -- 2.51.2 From 20cd0602a4e1311da861982e439d2abbb92d1532 Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Sun, 26 Jul 2026 21:01:04 +0200 Subject: [PATCH 09/25] appview/pages/templates: rename blob field name Signed-off-by: Wilhelm Berggren Signed-off-by: Seongmin Lee --- .../templates/fragments/comment/reply.html | 1 - .../templates/fragments/markdownEditor.html | 20 +++++++++++-------- .../repo/issues/fragments/newComment.html | 1 - .../repo/issues/fragments/putIssue.html | 1 - .../repo/pulls/fragments/pullActions.html | 1 - .../repo/pulls/fragments/pullEdit.html | 1 - .../repo/pulls/fragments/pullStepReview.html | 8 +++++--- appview/pages/templates/strings/string.html | 1 - 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/appview/pages/templates/fragments/comment/reply.html b/appview/pages/templates/fragments/comment/reply.html index d33701b6..7f2f3b6a 100644 --- a/appview/pages/templates/fragments/comment/reply.html +++ b/appview/pages/templates/fragments/comment/reply.html @@ -10,7 +10,6 @@ {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} {{ template "fragments/markdownEditor" (dict "Name" "body" - "BlobName" "blob" "Rows" 3 "AutoFocus" true "Placeholder" "Leave a reply...") }} diff --git a/appview/pages/templates/fragments/markdownEditor.html b/appview/pages/templates/fragments/markdownEditor.html index 0284bee0..259d0a25 100644 --- a/appview/pages/templates/fragments/markdownEditor.html +++ b/appview/pages/templates/fragments/markdownEditor.html @@ -1,14 +1,15 @@ {{ define "fragments/markdownEditor" }} - {{ $name := .Name }} - {{ $value := .Value }} - {{ $blobName := .BlobName }} - {{ $blobValues := .BlobValues }} - {{ $rows := (or .Rows 5) }} - {{ $required := .Required }} - {{ $autofocus := .AutoFocus }} - {{ $placeholder := .Placeholder }} + {{ $name := .Name }} + {{ $value := .Value }} + {{ $blobName := (or .BlobName "blobs") }} + {{ $blobValues := .BlobValues }} + {{ $rows := (or .Rows 5) }} + {{ $required := .Required }} + {{ $autofocus := .AutoFocus }} + {{ $placeholder := .Placeholder }} @@ -30,6 +31,9 @@ Preview + {{ range $blobValues }} + + {{ end }}