forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+3997 -6054
api
appview
cmd
docs
knotclient
knotserver
lexicons
nix
modules
rbac
spindle
xrpc
errors
serviceauth
+2 -252
api/tangled/cbor_gen.go
··· 2141 2141 2142 2142 return nil 2143 2143 } 2144 - func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 - if t == nil { 2146 - _, err := w.Write(cbg.CborNull) 2147 - return err 2148 - } 2149 - 2150 - cw := cbg.NewCborWriter(w) 2151 - 2152 - if _, err := cw.Write([]byte{162}); err != nil { 2153 - return err 2154 - } 2155 - 2156 - // t.LexiconTypeID (string) (string) 2157 - if len("$type") > 1000000 { 2158 - return xerrors.Errorf("Value in field \"$type\" was too long") 2159 - } 2160 - 2161 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 - return err 2163 - } 2164 - if _, err := cw.WriteString(string("$type")); err != nil { 2165 - return err 2166 - } 2167 - 2168 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 - return err 2170 - } 2171 - if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 - return err 2173 - } 2174 - 2175 - // t.CreatedAt (string) (string) 2176 - if len("createdAt") > 1000000 { 2177 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 - } 2179 - 2180 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 - return err 2182 - } 2183 - if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 - return err 2185 - } 2186 - 2187 - if len(t.CreatedAt) > 1000000 { 2188 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 - } 2190 - 2191 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 - return err 2193 - } 2194 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 - return err 2196 - } 2197 - return nil 2198 - } 2199 - 2200 - func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 - *t = Knot{} 2202 - 2203 - cr := cbg.NewCborReader(r) 2204 - 2205 - maj, extra, err := cr.ReadHeader() 2206 - if err != nil { 2207 - return err 2208 - } 2209 - defer func() { 2210 - if err == io.EOF { 2211 - err = io.ErrUnexpectedEOF 2212 - } 2213 - }() 2214 - 2215 - if maj != cbg.MajMap { 2216 - return fmt.Errorf("cbor input should be of type map") 2217 - } 2218 - 2219 - if extra > cbg.MaxLength { 2220 - return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 - } 2222 - 2223 - n := extra 2224 - 2225 - nameBuf := make([]byte, 9) 2226 - for i := uint64(0); i < n; i++ { 2227 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 - if err != nil { 2229 - return err 2230 - } 2231 - 2232 - if !ok { 2233 - // Field doesn't exist on this type, so ignore it 2234 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 - return err 2236 - } 2237 - continue 2238 - } 2239 - 2240 - switch string(nameBuf[:nameLen]) { 2241 - // t.LexiconTypeID (string) (string) 2242 - case "$type": 2243 - 2244 - { 2245 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 - if err != nil { 2247 - return err 2248 - } 2249 - 2250 - t.LexiconTypeID = string(sval) 2251 - } 2252 - // t.CreatedAt (string) (string) 2253 - case "createdAt": 2254 - 2255 - { 2256 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2257 - if err != nil { 2258 - return err 2259 - } 2260 - 2261 - t.CreatedAt = string(sval) 2262 - } 2263 - 2264 - default: 2265 - // Field doesn't exist on this type, so ignore it 2266 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2267 - return err 2268 - } 2269 - } 2270 - } 2271 - 2272 - return nil 2273 - } 2274 2144 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2275 2145 if t == nil { 2276 2146 _, err := w.Write(cbg.CborNull) ··· 5642 5512 } 5643 5513 5644 5514 cw := cbg.NewCborWriter(w) 5645 - fieldCount := 7 5515 + fieldCount := 6 5646 5516 5647 5517 if t.Body == nil { 5648 5518 fieldCount-- ··· 5772 5642 return err 5773 5643 } 5774 5644 5775 - // t.IssueId (int64) (int64) 5776 - if len("issueId") > 1000000 { 5777 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5778 - } 5779 - 5780 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 5781 - return err 5782 - } 5783 - if _, err := cw.WriteString(string("issueId")); err != nil { 5784 - return err 5785 - } 5786 - 5787 - if t.IssueId >= 0 { 5788 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 5789 - return err 5790 - } 5791 - } else { 5792 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 5793 - return err 5794 - } 5795 - } 5796 - 5797 5645 // t.CreatedAt (string) (string) 5798 5646 if len("createdAt") > 1000000 { 5799 5647 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5925 5773 5926 5774 t.Title = string(sval) 5927 5775 } 5928 - // t.IssueId (int64) (int64) 5929 - case "issueId": 5930 - { 5931 - maj, extra, err := cr.ReadHeader() 5932 - if err != nil { 5933 - return err 5934 - } 5935 - var extraI int64 5936 - switch maj { 5937 - case cbg.MajUnsignedInt: 5938 - extraI = int64(extra) 5939 - if extraI < 0 { 5940 - return fmt.Errorf("int64 positive overflow") 5941 - } 5942 - case cbg.MajNegativeInt: 5943 - extraI = int64(extra) 5944 - if extraI < 0 { 5945 - return fmt.Errorf("int64 negative overflow") 5946 - } 5947 - extraI = -1 - extraI 5948 - default: 5949 - return fmt.Errorf("wrong type for int64 field: %d", maj) 5950 - } 5951 - 5952 - t.IssueId = int64(extraI) 5953 - } 5954 5776 // t.CreatedAt (string) (string) 5955 5777 case "createdAt": 5956 5778 ··· 5980 5802 } 5981 5803 5982 5804 cw := cbg.NewCborWriter(w) 5983 - fieldCount := 7 5984 - 5985 - if t.CommentId == nil { 5986 - fieldCount-- 5987 - } 5805 + fieldCount := 6 5988 5806 5989 5807 if t.Owner == nil { 5990 5808 fieldCount-- ··· 6127 5945 } 6128 5946 } 6129 5947 6130 - // t.CommentId (int64) (int64) 6131 - if t.CommentId != nil { 6132 - 6133 - if len("commentId") > 1000000 { 6134 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6135 - } 6136 - 6137 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6138 - return err 6139 - } 6140 - if _, err := cw.WriteString(string("commentId")); err != nil { 6141 - return err 6142 - } 6143 - 6144 - if t.CommentId == nil { 6145 - if _, err := cw.Write(cbg.CborNull); err != nil { 6146 - return err 6147 - } 6148 - } else { 6149 - if *t.CommentId >= 0 { 6150 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6151 - return err 6152 - } 6153 - } else { 6154 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6155 - return err 6156 - } 6157 - } 6158 - } 6159 - 6160 - } 6161 - 6162 5948 // t.CreatedAt (string) (string) 6163 5949 if len("createdAt") > 1000000 { 6164 5950 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6298 6084 } 6299 6085 6300 6086 t.Owner = (*string)(&sval) 6301 - } 6302 - } 6303 - // t.CommentId (int64) (int64) 6304 - case "commentId": 6305 - { 6306 - 6307 - b, err := cr.ReadByte() 6308 - if err != nil { 6309 - return err 6310 - } 6311 - if b != cbg.CborNull[0] { 6312 - if err := cr.UnreadByte(); err != nil { 6313 - return err 6314 - } 6315 - maj, extra, err := cr.ReadHeader() 6316 - if err != nil { 6317 - return err 6318 - } 6319 - var extraI int64 6320 - switch maj { 6321 - case cbg.MajUnsignedInt: 6322 - extraI = int64(extra) 6323 - if extraI < 0 { 6324 - return fmt.Errorf("int64 positive overflow") 6325 - } 6326 - case cbg.MajNegativeInt: 6327 - extraI = int64(extra) 6328 - if extraI < 0 { 6329 - return fmt.Errorf("int64 negative overflow") 6330 - } 6331 - extraI = -1 - extraI 6332 - default: 6333 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6334 - } 6335 - 6336 - t.CommentId = (*int64)(&extraI) 6337 6087 } 6338 6088 } 6339 6089 // t.CreatedAt (string) (string)
-1
api/tangled/issuecomment.go
··· 19 19 type RepoIssueComment struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 23 Issue string `json:"issue" cborgen:"issue"` 25 24 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-34
api/tangled/repocreate.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.create 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoCreateNSID = "sh.tangled.repo.create" 15 - ) 16 - 17 - // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 - type RepoCreate_Input struct { 19 - // defaultBranch: Default branch to push to 20 - DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 - // rkey: Rkey of the repository record 22 - Rkey string `json:"rkey" cborgen:"rkey"` 23 - // source: A source URL to clone from, populate this when forking or importing a repository. 24 - Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 - } 26 - 27 - // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 - func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 - return err 31 - } 32 - 33 - return nil 34 - }
-34
api/tangled/repodelete.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.delete 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoDeleteNSID = "sh.tangled.repo.delete" 15 - ) 16 - 17 - // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. 18 - type RepoDelete_Input struct { 19 - // did: DID of the repository owner 20 - Did string `json:"did" cborgen:"did"` 21 - // name: Name of the repository to delete 22 - Name string `json:"name" cborgen:"name"` 23 - // rkey: Rkey of the repository record 24 - Rkey string `json:"rkey" cborgen:"rkey"` 25 - } 26 - 27 - // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 28 - func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 29 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 30 - return err 31 - } 32 - 33 - return nil 34 - }
-45
api/tangled/repoforkStatus.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.forkStatus 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 - ) 16 - 17 - // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 - type RepoForkStatus_Input struct { 19 - // branch: Branch to check status for 20 - Branch string `json:"branch" cborgen:"branch"` 21 - // did: DID of the fork owner 22 - Did string `json:"did" cborgen:"did"` 23 - // hiddenRef: Hidden ref to use for comparison 24 - HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 - // name: Name of the forked repository 26 - Name string `json:"name" cborgen:"name"` 27 - // source: Source repository URL 28 - Source string `json:"source" cborgen:"source"` 29 - } 30 - 31 - // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 - type RepoForkStatus_Output struct { 33 - // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 - Status int64 `json:"status" cborgen:"status"` 35 - } 36 - 37 - // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 - func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 - var out RepoForkStatus_Output 40 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 - return nil, err 42 - } 43 - 44 - return &out, nil 45 - }
-36
api/tangled/repoforkSync.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.forkSync 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 - ) 16 - 17 - // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 - type RepoForkSync_Input struct { 19 - // branch: Branch to sync 20 - Branch string `json:"branch" cborgen:"branch"` 21 - // did: DID of the fork owner 22 - Did string `json:"did" cborgen:"did"` 23 - // name: Name of the forked repository 24 - Name string `json:"name" cborgen:"name"` 25 - // source: AT-URI of the source repository 26 - Source string `json:"source" cborgen:"source"` 27 - } 28 - 29 - // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 - func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 - return err 33 - } 34 - 35 - return nil 36 - }
-45
api/tangled/repohiddenRef.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.hiddenRef 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 - ) 16 - 17 - // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 - type RepoHiddenRef_Input struct { 19 - // forkRef: Fork reference name 20 - ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 - // remoteRef: Remote reference name 22 - RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 - // repo: AT-URI of the repository 24 - Repo string `json:"repo" cborgen:"repo"` 25 - } 26 - 27 - // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 - type RepoHiddenRef_Output struct { 29 - // error: Error message if creation failed 30 - Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 - // ref: The created hidden ref name 32 - Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 - // success: Whether the hidden ref was created successfully 34 - Success bool `json:"success" cborgen:"success"` 35 - } 36 - 37 - // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 - func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 - var out RepoHiddenRef_Output 40 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 - return nil, err 42 - } 43 - 44 - return &out, nil 45 - }
-1
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 23 Owner string `json:"owner" cborgen:"owner"` 25 24 Repo string `json:"repo" cborgen:"repo"` 26 25 Title string `json:"title" cborgen:"title"`
-44
api/tangled/repomerge.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.merge 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoMergeNSID = "sh.tangled.repo.merge" 15 - ) 16 - 17 - // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 - type RepoMerge_Input struct { 19 - // authorEmail: Author email for the merge commit 20 - AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 - // authorName: Author name for the merge commit 22 - AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 - // branch: Target branch to merge into 24 - Branch string `json:"branch" cborgen:"branch"` 25 - // commitBody: Additional commit message body 26 - CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 - // commitMessage: Merge commit message 28 - CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 - // did: DID of the repository owner 30 - Did string `json:"did" cborgen:"did"` 31 - // name: Name of the repository 32 - Name string `json:"name" cborgen:"name"` 33 - // patch: Patch content to merge 34 - Patch string `json:"patch" cborgen:"patch"` 35 - } 36 - 37 - // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 - func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 - return err 41 - } 42 - 43 - return nil 44 - }
-57
api/tangled/repomergeCheck.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.mergeCheck 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 - ) 16 - 17 - // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 - type RepoMergeCheck_ConflictInfo struct { 19 - // filename: Name of the conflicted file 20 - Filename string `json:"filename" cborgen:"filename"` 21 - // reason: Reason for the conflict 22 - Reason string `json:"reason" cborgen:"reason"` 23 - } 24 - 25 - // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 - type RepoMergeCheck_Input struct { 27 - // branch: Target branch to merge into 28 - Branch string `json:"branch" cborgen:"branch"` 29 - // did: DID of the repository owner 30 - Did string `json:"did" cborgen:"did"` 31 - // name: Name of the repository 32 - Name string `json:"name" cborgen:"name"` 33 - // patch: Patch or pull request to check for merge conflicts 34 - Patch string `json:"patch" cborgen:"patch"` 35 - } 36 - 37 - // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 - type RepoMergeCheck_Output struct { 39 - // conflicts: List of files with merge conflicts 40 - Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 - // error: Error message if check failed 42 - Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 - // is_conflicted: Whether the merge has conflicts 44 - Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 - // message: Additional message about the merge check 46 - Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 - } 48 - 49 - // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 - func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 - var out RepoMergeCheck_Output 52 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 - return nil, err 54 - } 55 - 56 - return &out, nil 57 - }
-22
api/tangled/tangledknot.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.knot 6 - 7 - import ( 8 - "github.com/bluesky-social/indigo/lex/util" 9 - ) 10 - 11 - const ( 12 - KnotNSID = "sh.tangled.knot" 13 - ) 14 - 15 - func init() { 16 - util.RegisterType("sh.tangled.knot", &Knot{}) 17 - } // 18 - // RECORDTYPE: Knot 19 - type Knot struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 - }
+1 -1
appview/config/config.go
··· 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 19 20 - // temporarily, to add users to default knot and spindle 20 + // temporarily, to add users to default spindle 21 21 AppPassword string `env:"APP_PASSWORD"` 22 22 } 23 23
-25
appview/db/db.go
··· 612 612 return nil 613 613 }) 614 614 615 - // drop all knot secrets, add unique constraint to knots 616 - // 617 - // knots will henceforth use service auth for signed requests 618 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 - _, err := tx.Exec(` 620 - create table registrations_new ( 621 - id integer primary key autoincrement, 622 - domain text not null, 623 - did text not null, 624 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 - registered text, 626 - read_only integer not null default 0, 627 - unique(domain, did) 628 - ); 629 - 630 - insert into registrations_new (id, domain, did, created, registered, read_only) 631 - select id, domain, did, created, registered, 1 from registrations 632 - where registered is not null; 633 - 634 - drop table registrations; 635 - alter table registrations_new rename to registrations; 636 - `) 637 - return err 638 - }) 639 - 640 615 // recreate and add rkey + created columns with default constraint 641 616 runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 642 617 // create new table
+41 -144
appview/db/follow.go
··· 1 1 package db 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 - "strings" 7 5 "time" 8 6 ) 9 7 ··· 55 53 return err 56 54 } 57 55 58 - type FollowStats struct { 59 - Followers int 60 - Following int 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 56 + func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 64 57 followers, following := 0, 0 65 58 err := e.QueryRow( 66 - `SELECT 59 + `SELECT 67 60 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 68 61 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 62 FROM follows;`, did, did).Scan(&followers, &following) 70 63 if err != nil { 71 - return FollowStats{}, err 64 + return 0, 0, err 72 65 } 73 - return FollowStats{ 74 - Followers: followers, 75 - Following: following, 76 - }, nil 66 + return followers, following, nil 77 67 } 78 68 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 - if len(dids) == 0 { 81 - return nil, nil 82 - } 69 + type FollowStatus int 83 70 84 - placeholders := make([]string, len(dids)) 85 - for i := range placeholders { 86 - placeholders[i] = "?" 87 - } 88 - placeholderStr := strings.Join(placeholders, ",") 71 + const ( 72 + IsNotFollowing FollowStatus = iota 73 + IsFollowing 74 + IsSelf 75 + ) 89 76 90 - args := make([]any, len(dids)*2) 91 - for i, did := range dids { 92 - args[i] = did 93 - args[i+len(dids)] = did 77 + func (s FollowStatus) String() string { 78 + switch s { 79 + case IsNotFollowing: 80 + return "IsNotFollowing" 81 + case IsFollowing: 82 + return "IsFollowing" 83 + case IsSelf: 84 + return "IsSelf" 85 + default: 86 + return "IsNotFollowing" 94 87 } 95 - 96 - query := fmt.Sprintf(` 97 - select 98 - coalesce(f.did, g.did) as did, 99 - coalesce(f.followers, 0) as followers, 100 - coalesce(g.following, 0) as following 101 - from ( 102 - select subject_did as did, count(*) as followers 103 - from follows 104 - where subject_did in (%s) 105 - group by subject_did 106 - ) f 107 - full outer join ( 108 - select user_did as did, count(*) as following 109 - from follows 110 - where user_did in (%s) 111 - group by user_did 112 - ) g on f.did = g.did`, 113 - placeholderStr, placeholderStr) 114 - 115 - result := make(map[string]FollowStats) 116 - 117 - rows, err := e.Query(query, args...) 118 - if err != nil { 119 - return nil, err 120 - } 121 - defer rows.Close() 88 + } 122 89 123 - for rows.Next() { 124 - var did string 125 - var followers, following int 126 - if err := rows.Scan(&did, &followers, &following); err != nil { 127 - return nil, err 128 - } 129 - result[did] = FollowStats{ 130 - Followers: followers, 131 - Following: following, 132 - } 90 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 + if userDid == subjectDid { 92 + return IsSelf 93 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 + return IsNotFollowing 95 + } else { 96 + return IsFollowing 133 97 } 134 - 135 - for _, did := range dids { 136 - if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 138 - Followers: 0, 139 - Following: 0, 140 - } 141 - } 142 - } 143 - 144 - return result, nil 145 98 } 146 99 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 100 + func GetAllFollows(e Execer, limit int) ([]Follow, error) { 148 101 var follows []Follow 149 102 150 - var conditions []string 151 - var args []any 152 - for _, filter := range filters { 153 - conditions = append(conditions, filter.Condition()) 154 - args = append(args, filter.Arg()...) 155 - } 156 - 157 - whereClause := "" 158 - if conditions != nil { 159 - whereClause = " where " + strings.Join(conditions, " and ") 160 - } 161 - limitClause := "" 162 - if limit > 0 { 163 - limitClause = " limit ?" 164 - args = append(args, limit) 165 - } 166 - 167 - query := fmt.Sprintf( 168 - `select user_did, subject_did, followed_at, rkey 103 + rows, err := e.Query(` 104 + select user_did, subject_did, followed_at, rkey 169 105 from follows 170 - %s 171 106 order by followed_at desc 172 - %s 173 - `, whereClause, limitClause) 174 - 175 - rows, err := e.Query(query, args...) 107 + limit ?`, limit, 108 + ) 176 109 if err != nil { 177 110 return nil, err 178 111 } 112 + defer rows.Close() 113 + 179 114 for rows.Next() { 180 115 var follow Follow 181 116 var followedAt string 182 - err := rows.Scan( 183 - &follow.UserDid, 184 - &follow.SubjectDid, 185 - &followedAt, 186 - &follow.Rkey, 187 - ) 188 - if err != nil { 117 + if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 189 118 return nil, err 190 119 } 120 + 191 121 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 192 122 if err != nil { 193 123 log.Println("unable to determine followed at time") ··· 195 125 } else { 196 126 follow.FollowedAt = followedAtTime 197 127 } 128 + 198 129 follows = append(follows, follow) 199 130 } 200 - return follows, nil 201 - } 202 - 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 204 - return GetFollows(e, 0, FilterEq("subject_did", did)) 205 - } 206 131 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 208 - return GetFollows(e, 0, FilterEq("user_did", did)) 209 - } 210 - 211 - type FollowStatus int 212 - 213 - const ( 214 - IsNotFollowing FollowStatus = iota 215 - IsFollowing 216 - IsSelf 217 - ) 218 - 219 - func (s FollowStatus) String() string { 220 - switch s { 221 - case IsNotFollowing: 222 - return "IsNotFollowing" 223 - case IsFollowing: 224 - return "IsFollowing" 225 - case IsSelf: 226 - return "IsSelf" 227 - default: 228 - return "IsNotFollowing" 132 + if err := rows.Err(); err != nil { 133 + return nil, err 229 134 } 230 - } 231 135 232 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 - if userDid == subjectDid { 234 - return IsSelf 235 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 - return IsNotFollowing 237 - } else { 238 - return IsFollowing 239 - } 136 + return follows, nil 240 137 }
+105
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + mathrand "math/rand/v2" 6 7 "strings" 7 8 "time" 8 9 ··· 47 48 48 49 func (i *Issue) AtUri() syntax.ATURI { 49 50 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 + } 52 + 53 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 + if err != nil { 56 + created = time.Now() 57 + } 58 + 59 + body := "" 60 + if record.Body != nil { 61 + body = *record.Body 62 + } 63 + 64 + return Issue{ 65 + RepoAt: syntax.ATURI(record.Repo), 66 + OwnerDid: record.Owner, 67 + Rkey: rkey, 68 + Created: created, 69 + Title: record.Title, 70 + Body: body, 71 + Open: true, // new issues are open by default 72 + } 73 + } 74 + 75 + func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 + ownerDid := issueUri.Authority().String() 77 + issueRkey := issueUri.RecordKey().String() 78 + 79 + var repoAt string 80 + var issueId int 81 + 82 + query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 + err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 + if err != nil { 85 + return "", 0, err 86 + } 87 + 88 + return syntax.ATURI(repoAt), issueId, nil 89 + } 90 + 91 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + ownerDid := did 98 + if record.Owner != nil { 99 + ownerDid = *record.Owner 100 + } 101 + 102 + issueUri, err := syntax.ParseATURI(record.Issue) 103 + if err != nil { 104 + return Comment{}, err 105 + } 106 + 107 + repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 + if err != nil { 109 + return Comment{}, err 110 + } 111 + 112 + comment := Comment{ 113 + OwnerDid: ownerDid, 114 + RepoAt: repoAt, 115 + Rkey: rkey, 116 + Body: record.Body, 117 + Issue: issueId, 118 + CommentId: mathrand.IntN(1000000), 119 + Created: &created, 120 + } 121 + 122 + return comment, nil 50 123 } 51 124 52 125 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 550 623 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 551 624 where repo_at = ? and issue_id = ? and comment_id = ? 552 625 `, repoAt, issueId, commentId) 626 + return err 627 + } 628 + 629 + func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 + _, err := e.Exec( 631 + ` 632 + update comments 633 + set body = ?, 634 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 + where owner_did = ? and rkey = ? 636 + `, newBody, ownerDid, rkey) 637 + return err 638 + } 639 + 640 + func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 + _, err := e.Exec( 642 + ` 643 + update comments 644 + set body = "", 645 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 + where owner_did = ? and rkey = ? 647 + `, ownerDid, rkey) 648 + return err 649 + } 650 + 651 + func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 + _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 + return err 654 + } 655 + 656 + func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 + _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 553 658 return err 554 659 } 555 660
+7 -2
appview/db/profile.go
··· 348 348 return tx.Commit() 349 349 } 350 350 351 - func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 351 + func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 352 var conditions []string 353 353 var args []any 354 354 for _, filter := range filters { ··· 448 448 idxs[did] = idx + 1 449 449 } 450 450 451 - return profileMap, nil 451 + var profiles []Profile 452 + for _, p := range profileMap { 453 + profiles = append(profiles, *p) 454 + } 455 + 456 + return profiles, nil 452 457 } 453 458 454 459 func GetProfile(e Execer, did string) (*Profile, error) {
+125 -89
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 + "crypto/rand" 4 5 "database/sql" 6 + "encoding/hex" 5 7 "fmt" 6 - "strings" 8 + "log" 7 9 "time" 8 10 ) 9 11 10 - // Registration represents a knot registration. Knot would've been a better 11 - // name but we're stuck with this for historical reasons. 12 12 type Registration struct { 13 13 Id int64 14 14 Domain string 15 15 ByDid string 16 16 Created *time.Time 17 17 Registered *time.Time 18 - ReadOnly bool 19 18 } 20 19 21 20 func (r *Registration) Status() Status { 22 - if r.ReadOnly { 23 - return ReadOnly 24 - } else if r.Registered != nil { 21 + if r.Registered != nil { 25 22 return Registered 26 23 } else { 27 24 return Pending 28 25 } 29 26 } 30 27 31 - func (r *Registration) IsRegistered() bool { 32 - return r.Status() == Registered 33 - } 34 - 35 - func (r *Registration) IsReadOnly() bool { 36 - return r.Status() == ReadOnly 37 - } 38 - 39 - func (r *Registration) IsPending() bool { 40 - return r.Status() == Pending 41 - } 42 - 43 28 type Status uint32 44 29 45 30 const ( 46 31 Registered Status = iota 47 32 Pending 48 - ReadOnly 49 33 ) 50 34 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 35 + // returns registered status, did of owner, error 36 + func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 52 37 var registrations []Registration 53 38 54 - var conditions []string 55 - var args []any 56 - for _, filter := range filters { 57 - conditions = append(conditions, filter.Condition()) 58 - args = append(args, filter.Arg()...) 59 - } 60 - 61 - whereClause := "" 62 - if conditions != nil { 63 - whereClause = " where " + strings.Join(conditions, " and ") 64 - } 65 - 66 - query := fmt.Sprintf(` 67 - select id, domain, did, created, registered, read_only 68 - from registrations 69 - %s 70 - order by created 71 - `, 72 - whereClause, 73 - ) 74 - 75 - rows, err := e.Query(query, args...) 39 + rows, err := e.Query(` 40 + select id, domain, did, created, registered from registrations 41 + where did = ? 42 + `, did) 76 43 if err != nil { 77 44 return nil, err 78 45 } 79 46 80 47 for rows.Next() { 81 - var createdAt string 82 - var registeredAt sql.Null[string] 83 - var readOnly int 84 - var reg Registration 48 + var createdAt *string 49 + var registeredAt *string 50 + var registration Registration 51 + err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 85 52 86 - err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 87 53 if err != nil { 88 - return nil, err 54 + log.Println(err) 55 + } else { 56 + createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 57 + var registeredAtTime *time.Time 58 + if registeredAt != nil { 59 + x, _ := time.Parse(time.RFC3339, *registeredAt) 60 + registeredAtTime = &x 61 + } 62 + 63 + registration.Created = &createdAtTime 64 + registration.Registered = registeredAtTime 65 + registrations = append(registrations, registration) 89 66 } 67 + } 90 68 91 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 - reg.Created = &t 93 - } 69 + return registrations, nil 70 + } 71 + 72 + // returns registered status, did of owner, error 73 + func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 74 + var createdAt *string 75 + var registeredAt *string 76 + var registration Registration 94 77 95 - if registeredAt.Valid { 96 - if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 - reg.Registered = &t 98 - } 99 - } 78 + err := e.QueryRow(` 79 + select id, domain, did, created, registered from registrations 80 + where domain = ? 81 + `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 100 82 101 - if readOnly != 0 { 102 - reg.ReadOnly = true 83 + if err != nil { 84 + if err == sql.ErrNoRows { 85 + return nil, nil 86 + } else { 87 + return nil, err 103 88 } 89 + } 104 90 105 - registrations = append(registrations, reg) 91 + createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 92 + var registeredAtTime *time.Time 93 + if registeredAt != nil { 94 + x, _ := time.Parse(time.RFC3339, *registeredAt) 95 + registeredAtTime = &x 106 96 } 107 97 108 - return registrations, nil 98 + registration.Created = &createdAtTime 99 + registration.Registered = registeredAtTime 100 + 101 + return &registration, nil 102 + } 103 + 104 + func genSecret() string { 105 + key := make([]byte, 32) 106 + rand.Read(key) 107 + return hex.EncodeToString(key) 109 108 } 110 109 111 - func MarkRegistered(e Execer, filters ...filter) error { 112 - var conditions []string 113 - var args []any 114 - for _, filter := range filters { 115 - conditions = append(conditions, filter.Condition()) 116 - args = append(args, filter.Arg()...) 110 + func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 111 + // sanity check: does this domain already have a registration? 112 + reg, err := RegistrationByDomain(e, domain) 113 + if err != nil { 114 + return "", err 117 115 } 118 116 119 - query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 - if len(conditions) > 0 { 121 - query += " where " + strings.Join(conditions, " and ") 117 + // registration is open 118 + if reg != nil { 119 + switch reg.Status() { 120 + case Registered: 121 + // already registered by `owner` 122 + return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 123 + case Pending: 124 + // TODO: be loud about this 125 + log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 126 + } 127 + } 128 + 129 + secret := genSecret() 130 + 131 + _, err = e.Exec(` 132 + insert into registrations (domain, did, secret) 133 + values (?, ?, ?) 134 + on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 135 + `, domain, did, secret) 136 + 137 + if err != nil { 138 + return "", err 122 139 } 123 140 124 - _, err := e.Exec(query, args...) 125 - return err 141 + return secret, nil 126 142 } 127 143 128 - func AddKnot(e Execer, domain, did string) error { 129 - _, err := e.Exec(` 130 - insert into registrations (domain, did) 131 - values (?, ?) 132 - `, domain, did) 133 - return err 144 + func GetRegistrationKey(e Execer, domain string) (string, error) { 145 + res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 146 + 147 + var secret string 148 + err := res.Scan(&secret) 149 + if err != nil || secret == "" { 150 + return "", err 151 + } 152 + 153 + return secret, nil 134 154 } 135 155 136 - func DeleteKnot(e Execer, filters ...filter) error { 137 - var conditions []string 138 - var args []any 139 - for _, filter := range filters { 140 - conditions = append(conditions, filter.Condition()) 141 - args = append(args, filter.Arg()...) 156 + func GetCompletedRegistrations(e Execer) ([]string, error) { 157 + rows, err := e.Query(`select domain from registrations where registered not null`) 158 + if err != nil { 159 + return nil, err 160 + } 161 + 162 + var domains []string 163 + for rows.Next() { 164 + var domain string 165 + err = rows.Scan(&domain) 166 + 167 + if err != nil { 168 + log.Println(err) 169 + } else { 170 + domains = append(domains, domain) 171 + } 142 172 } 143 173 144 - whereClause := "" 145 - if conditions != nil { 146 - whereClause = " where " + strings.Join(conditions, " and ") 174 + if err = rows.Err(); err != nil { 175 + return nil, err 147 176 } 148 177 149 - query := fmt.Sprintf(`delete from registrations %s`, whereClause) 178 + return domains, nil 179 + } 180 + 181 + func Register(e Execer, domain string) error { 182 + _, err := e.Exec(` 183 + update registrations 184 + set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 185 + where domain = ?; 186 + `, domain) 150 187 151 - _, err := e.Exec(query, args...) 152 188 return err 153 189 }
+22 -6
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 + type FollowStats struct { 24 + Followers int 25 + Following int 26 + } 27 + 23 28 const Limit = 50 24 29 25 30 // TODO: this gathers heterogenous events from different sources and aggregates ··· 132 137 } 133 138 134 139 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 140 + follows, err := GetAllFollows(e, Limit) 136 141 if err != nil { 137 142 return nil, err 138 143 } ··· 146 151 return nil, nil 147 152 } 148 153 154 + profileMap := make(map[string]Profile) 149 155 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 150 156 if err != nil { 151 157 return nil, err 158 + } 159 + for _, p := range profiles { 160 + profileMap[p.Did] = p 152 161 } 153 162 154 - followStatMap, err := GetFollowerFollowingCounts(e, subjects) 155 - if err != nil { 156 - return nil, err 163 + followStatMap := make(map[string]FollowStats) 164 + for _, s := range subjects { 165 + followers, following, err := GetFollowerFollowingCount(e, s) 166 + if err != nil { 167 + return nil, err 168 + } 169 + followStatMap[s] = FollowStats{ 170 + Followers: followers, 171 + Following: following, 172 + } 157 173 } 158 174 159 175 var events []TimelineEvent 160 176 for _, f := range follows { 161 - profile, _ := profiles[f.SubjectDid] 177 + profile, _ := profileMap[f.SubjectDid] 162 178 followStatMap, _ := followStatMap[f.SubjectDid] 163 179 164 180 events = append(events, TimelineEvent{ 165 181 Follow: &f, 166 - Profile: profile, 182 + Profile: &profile, 167 183 FollowStats: &followStatMap, 168 184 EventAt: f.FollowedAt, 169 185 })
+111 -98
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 15 "tangled.sh/tangled.sh/core/api/tangled" 15 16 "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/serververify" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 + "tangled.sh/tangled.sh/core/appview/spindleverify" 18 20 "tangled.sh/tangled.sh/core/idresolver" 19 21 "tangled.sh/tangled.sh/core/rbac" 20 22 ) ··· 61 63 case tangled.ActorProfileNSID: 62 64 err = i.ingestProfile(e) 63 65 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 66 + err = i.ingestSpindleMember(ctx, e) 65 67 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 67 - case tangled.KnotMemberNSID: 68 - err = i.ingestKnotMember(e) 69 - case tangled.KnotNSID: 70 - err = i.ingestKnot(e) 68 + err = i.ingestSpindle(ctx, e) 71 69 case tangled.StringNSID: 72 70 err = i.ingestString(e) 71 + case tangled.RepoIssueNSID: 72 + err = i.ingestIssue(ctx, e) 73 + case tangled.RepoIssueCommentNSID: 74 + err = i.ingestIssueComment(e) 73 75 } 74 76 l = i.Logger.With("nsid", e.Commit.Collection) 75 77 } ··· 340 342 return nil 341 343 } 342 344 343 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 345 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 344 346 did := e.Did 345 347 var err error 346 348 ··· 363 365 return fmt.Errorf("failed to enforce permissions: %w", err) 364 366 } 365 367 366 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 368 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 367 369 if err != nil { 368 370 return err 369 371 } ··· 446 448 return nil 447 449 } 448 450 449 - func (i *Ingester) ingestSpindle(e *models.Event) error { 451 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 450 452 did := e.Did 451 453 var err error 452 454 ··· 479 481 return err 480 482 } 481 483 482 - err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 484 + err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 483 485 if err != nil { 484 486 l.Error("failed to add spindle to db", "err", err, "instance", instance) 485 487 return err 486 488 } 487 489 488 - _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 490 + _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 489 491 if err != nil { 490 492 return fmt.Errorf("failed to mark verified: %w", err) 491 493 } ··· 614 616 return nil 615 617 } 616 618 617 - func (i *Ingester) ingestKnotMember(e *models.Event) error { 619 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 618 620 did := e.Did 621 + rkey := e.Commit.RKey 622 + 619 623 var err error 620 624 621 - l := i.Logger.With("handler", "ingestKnotMember") 622 - l = l.With("nsid", e.Commit.Collection) 625 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 626 + l.Info("ingesting record") 627 + 628 + ddb, ok := i.Db.Execer.(*db.DB) 629 + if !ok { 630 + return fmt.Errorf("failed to index issue record, invalid db cast") 631 + } 623 632 624 633 switch e.Commit.Operation { 625 634 case models.CommitOperationCreate: 626 635 raw := json.RawMessage(e.Commit.Record) 627 - record := tangled.KnotMember{} 636 + record := tangled.RepoIssue{} 628 637 err = json.Unmarshal(raw, &record) 629 638 if err != nil { 630 639 l.Error("invalid record", "err", err) 631 640 return err 632 641 } 633 642 634 - // only knot owner can invite to knots 635 - ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 636 - if err != nil || !ok { 637 - return fmt.Errorf("failed to enforce permissions: %w", err) 643 + issue := db.IssueFromRecord(did, rkey, record) 644 + 645 + sanitizer := markup.NewSanitizer() 646 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 647 + return fmt.Errorf("title is empty after HTML sanitization") 648 + } 649 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 650 + return fmt.Errorf("body is empty after HTML sanitization") 651 + } 652 + 653 + tx, err := ddb.BeginTx(ctx, nil) 654 + if err != nil { 655 + l.Error("failed to begin transaction", "err", err) 656 + return err 638 657 } 639 658 640 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 659 + err = db.NewIssue(tx, &issue) 641 660 if err != nil { 661 + l.Error("failed to create issue", "err", err) 642 662 return err 643 663 } 644 664 645 - if memberId.Handle.IsInvalidHandle() { 665 + return nil 666 + 667 + case models.CommitOperationUpdate: 668 + raw := json.RawMessage(e.Commit.Record) 669 + record := tangled.RepoIssue{} 670 + err = json.Unmarshal(raw, &record) 671 + if err != nil { 672 + l.Error("invalid record", "err", err) 646 673 return err 647 674 } 648 675 649 - err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 676 + body := "" 677 + if record.Body != nil { 678 + body = *record.Body 679 + } 680 + 681 + sanitizer := markup.NewSanitizer() 682 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 683 + return fmt.Errorf("title is empty after HTML sanitization") 684 + } 685 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 686 + return fmt.Errorf("body is empty after HTML sanitization") 687 + } 688 + 689 + err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 650 690 if err != nil { 651 - return fmt.Errorf("failed to update ACLs: %w", err) 691 + l.Error("failed to update issue", "err", err) 692 + return err 652 693 } 653 694 654 - l.Info("added knot member") 695 + return nil 696 + 655 697 case models.CommitOperationDelete: 656 - // we don't store knot members in a table (like we do for spindle) 657 - // and we can't remove this just yet. possibly fixed if we switch 658 - // to either: 659 - // 1. a knot_members table like with spindle and store the rkey 660 - // 2. use the knot host as the rkey 661 - // 662 - // TODO: implement member deletion 663 - l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 698 + if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 699 + l.Error("failed to delete", "err", err) 700 + return fmt.Errorf("failed to delete issue record: %w", err) 701 + } 702 + 703 + return nil 664 704 } 665 705 666 - return nil 706 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 667 707 } 668 708 669 - func (i *Ingester) ingestKnot(e *models.Event) error { 709 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 670 710 did := e.Did 711 + rkey := e.Commit.RKey 712 + 671 713 var err error 672 714 673 - l := i.Logger.With("handler", "ingestKnot") 674 - l = l.With("nsid", e.Commit.Collection) 715 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 716 + l.Info("ingesting record") 717 + 718 + ddb, ok := i.Db.Execer.(*db.DB) 719 + if !ok { 720 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 721 + } 675 722 676 723 switch e.Commit.Operation { 677 724 case models.CommitOperationCreate: 678 725 raw := json.RawMessage(e.Commit.Record) 679 - record := tangled.Knot{} 726 + record := tangled.RepoIssueComment{} 680 727 err = json.Unmarshal(raw, &record) 681 728 if err != nil { 682 729 l.Error("invalid record", "err", err) 683 730 return err 684 731 } 685 732 686 - domain := e.Commit.RKey 687 - 688 - ddb, ok := i.Db.Execer.(*db.DB) 689 - if !ok { 690 - return fmt.Errorf("failed to index profile record, invalid db cast") 691 - } 692 - 693 - err := db.AddKnot(ddb, domain, did) 733 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 694 734 if err != nil { 695 - l.Error("failed to add knot to db", "err", err, "domain", domain) 735 + l.Error("failed to parse comment from record", "err", err) 696 736 return err 697 737 } 698 738 699 - err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 700 - if err != nil { 701 - l.Error("failed to verify knot", "err", err, "domain", domain) 702 - return err 739 + sanitizer := markup.NewSanitizer() 740 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 741 + return fmt.Errorf("body is empty after HTML sanitization") 703 742 } 704 743 705 - err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 744 + err = db.NewIssueComment(ddb, &comment) 706 745 if err != nil { 707 - return fmt.Errorf("failed to mark verified: %w", err) 746 + l.Error("failed to create issue comment", "err", err) 747 + return err 708 748 } 709 749 710 750 return nil 711 751 712 - case models.CommitOperationDelete: 713 - domain := e.Commit.RKey 714 - 715 - ddb, ok := i.Db.Execer.(*db.DB) 716 - if !ok { 717 - return fmt.Errorf("failed to index knot record, invalid db cast") 718 - } 719 - 720 - // get record from db first 721 - registrations, err := db.GetRegistrations( 722 - ddb, 723 - db.FilterEq("domain", domain), 724 - db.FilterEq("did", did), 725 - ) 752 + case models.CommitOperationUpdate: 753 + raw := json.RawMessage(e.Commit.Record) 754 + record := tangled.RepoIssueComment{} 755 + err = json.Unmarshal(raw, &record) 726 756 if err != nil { 727 - return fmt.Errorf("failed to get registration: %w", err) 728 - } 729 - if len(registrations) != 1 { 730 - return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 757 + l.Error("invalid record", "err", err) 758 + return err 731 759 } 732 - registration := registrations[0] 733 760 734 - tx, err := ddb.Begin() 735 - if err != nil { 736 - return err 761 + sanitizer := markup.NewSanitizer() 762 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 763 + return fmt.Errorf("body is empty after HTML sanitization") 737 764 } 738 - defer func() { 739 - tx.Rollback() 740 - i.Enforcer.E.LoadPolicy() 741 - }() 742 765 743 - err = db.DeleteKnot( 744 - tx, 745 - db.FilterEq("did", did), 746 - db.FilterEq("domain", domain), 747 - ) 766 + err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 748 767 if err != nil { 768 + l.Error("failed to update issue comment", "err", err) 749 769 return err 750 770 } 751 771 752 - if registration.Registered != nil { 753 - err = i.Enforcer.RemoveKnot(domain) 754 - if err != nil { 755 - return err 756 - } 757 - } 772 + return nil 758 773 759 - err = tx.Commit() 760 - if err != nil { 761 - return err 774 + case models.CommitOperationDelete: 775 + if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 776 + l.Error("failed to delete", "err", err) 777 + return fmt.Errorf("failed to delete issue comment record: %w", err) 762 778 } 763 779 764 - err = i.Enforcer.E.SavePolicy() 765 - if err != nil { 766 - return err 767 - } 780 + return nil 768 781 } 769 782 770 - return nil 783 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 771 784 }
+4 -9
appview/issues/issues.go
··· 278 278 } 279 279 280 280 createdAt := time.Now().Format(time.RFC3339) 281 - commentIdInt64 := int64(commentId) 282 281 ownerDid := user.Did 283 282 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 284 283 if err != nil { ··· 302 301 Val: &tangled.RepoIssueComment{ 303 302 Repo: &atUri, 304 303 Issue: issueAt, 305 - CommentId: &commentIdInt64, 306 304 Owner: &ownerDid, 307 305 Body: body, 308 306 CreatedAt: createdAt, ··· 451 449 repoAt := record["repo"].(string) 452 450 issueAt := record["issue"].(string) 453 451 createdAt := record["createdAt"].(string) 454 - commentIdInt64 := int64(commentIdInt) 455 452 456 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 457 454 Collection: tangled.RepoIssueCommentNSID, ··· 462 459 Val: &tangled.RepoIssueComment{ 463 460 Repo: &repoAt, 464 461 Issue: issueAt, 465 - CommentId: &commentIdInt64, 466 462 Owner: &comment.OwnerDid, 467 463 Body: newBody, 468 464 CreatedAt: createdAt, ··· 687 683 Rkey: issue.Rkey, 688 684 Record: &lexutil.LexiconTypeDecoder{ 689 685 Val: &tangled.RepoIssue{ 690 - Repo: atUri, 691 - Title: title, 692 - Body: &body, 693 - Owner: user.Did, 694 - IssueId: int64(issue.IssueId), 686 + Repo: atUri, 687 + Title: title, 688 + Body: &body, 689 + Owner: user.Did, 695 690 }, 696 691 }, 697 692 })
+217 -444
appview/knots/knots.go
··· 1 1 package knots 2 2 3 3 import ( 4 - "errors" 4 + "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 5 8 "fmt" 6 - "log" 7 9 "log/slog" 8 10 "net/http" 9 - "slices" 11 + "strings" 10 12 "time" 11 13 12 14 "github.com/go-chi/chi/v5" ··· 16 18 "tangled.sh/tangled.sh/core/appview/middleware" 17 19 "tangled.sh/tangled.sh/core/appview/oauth" 18 20 "tangled.sh/tangled.sh/core/appview/pages" 19 - "tangled.sh/tangled.sh/core/appview/serververify" 20 21 "tangled.sh/tangled.sh/core/eventconsumer" 21 22 "tangled.sh/tangled.sh/core/idresolver" 23 + "tangled.sh/tangled.sh/core/knotclient" 22 24 "tangled.sh/tangled.sh/core/rbac" 23 25 "tangled.sh/tangled.sh/core/tid" 24 26 ··· 37 39 Knotstream *eventconsumer.Consumer 38 40 } 39 41 40 - func (k *Knots) Router() http.Handler { 42 + func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 41 43 r := chi.NewRouter() 42 44 43 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 - r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 45 - 46 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 - r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 45 + r.Use(middleware.AuthMiddleware(k.OAuth)) 48 46 49 - r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 - r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 - r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 47 + r.Get("/", k.index) 48 + r.Post("/key", k.generateKey) 52 49 53 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 50 + r.Route("/{domain}", func(r chi.Router) { 51 + r.Post("/init", k.init) 52 + r.Get("/", k.dashboard) 53 + r.Route("/member", func(r chi.Router) { 54 + r.Use(mw.KnotOwner()) 55 + r.Get("/", k.members) 56 + r.Put("/", k.addMember) 57 + r.Delete("/", k.removeMember) 58 + }) 59 + }) 54 60 55 61 return r 56 62 } 57 63 58 - func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 64 + // get knots registered by this user 65 + func (k *Knots) index(w http.ResponseWriter, r *http.Request) { 66 + l := k.Logger.With("handler", "index") 67 + 59 68 user := k.OAuth.GetUser(r) 60 - registrations, err := db.GetRegistrations( 61 - k.Db, 62 - db.FilterEq("did", user.Did), 63 - ) 69 + registrations, err := db.RegistrationsByDid(k.Db, user.Did) 64 70 if err != nil { 65 - k.Logger.Error("failed to fetch knot registrations", "err", err) 66 - w.WriteHeader(http.StatusInternalServerError) 67 - return 71 + l.Error("failed to get registrations by did", "err", err) 68 72 } 69 73 70 74 k.Pages.Knots(w, pages.KnotsParams{ ··· 73 77 }) 74 78 } 75 79 76 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 77 - l := k.Logger.With("handler", "dashboard") 80 + // requires auth 81 + func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 + l := k.Logger.With("handler", "generateKey") 78 83 79 84 user := k.OAuth.GetUser(r) 80 - l = l.With("user", user.Did) 85 + did := user.Did 86 + l = l.With("did", did) 81 87 82 - domain := chi.URLParam(r, "domain") 88 + // check if domain is valid url, and strip extra bits down to just host 89 + domain := r.FormValue("domain") 83 90 if domain == "" { 91 + l.Error("empty domain") 92 + http.Error(w, "Invalid form", http.StatusBadRequest) 84 93 return 85 94 } 86 95 l = l.With("domain", domain) 87 96 88 - registrations, err := db.GetRegistrations( 89 - k.Db, 90 - db.FilterEq("did", user.Did), 91 - db.FilterEq("domain", domain), 92 - ) 93 - if err != nil { 94 - l.Error("failed to get registrations", "err", err) 95 - http.Error(w, "Not found", http.StatusNotFound) 96 - return 97 - } 98 - if len(registrations) != 1 { 99 - l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 100 - return 97 + noticeId := "registration-error" 98 + fail := func() { 99 + k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 101 100 } 102 - registration := registrations[0] 103 101 104 - members, err := k.Enforcer.GetUserByRole("server:member", domain) 102 + key, err := db.GenerateRegistrationKey(k.Db, domain, did) 105 103 if err != nil { 106 - l.Error("failed to get knot members", "err", err) 107 - http.Error(w, "Not found", http.StatusInternalServerError) 104 + l.Error("failed to generate registration key", "err", err) 105 + fail() 108 106 return 109 107 } 110 - slices.Sort(members) 111 108 112 - repos, err := db.GetRepos( 113 - k.Db, 114 - 0, 115 - db.FilterEq("knot", domain), 116 - ) 109 + allRegs, err := db.RegistrationsByDid(k.Db, did) 117 110 if err != nil { 118 - l.Error("failed to get knot repos", "err", err) 119 - http.Error(w, "Not found", http.StatusInternalServerError) 111 + l.Error("failed to generate registration key", "err", err) 112 + fail() 120 113 return 121 114 } 122 115 123 - // organize repos by did 124 - repoMap := make(map[string][]db.Repo) 125 - for _, r := range repos { 126 - repoMap[r.Did] = append(repoMap[r.Did], r) 127 - } 128 - 129 - k.Pages.Knot(w, pages.KnotParams{ 130 - LoggedInUser: user, 131 - Registration: &registration, 132 - Members: members, 133 - Repos: repoMap, 134 - IsOwner: true, 116 + k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 + Registrations: allRegs, 118 + }) 119 + k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 + Secret: key, 135 121 }) 136 122 } 137 123 138 - func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 124 + // create a signed request and check if a node responds to that 125 + func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 + l := k.Logger.With("handler", "init") 139 127 user := k.OAuth.GetUser(r) 140 - l := k.Logger.With("handler", "register") 141 128 142 - noticeId := "register-error" 143 - defaultErr := "Failed to register knot. Try again later." 129 + noticeId := "operation-error" 130 + defaultErr := "Failed to initialize knot. Try again later." 144 131 fail := func() { 145 132 k.Pages.Notice(w, noticeId, defaultErr) 146 133 } 147 134 148 - domain := r.FormValue("domain") 135 + domain := chi.URLParam(r, "domain") 149 136 if domain == "" { 150 - k.Pages.Notice(w, noticeId, "Incomplete form.") 137 + http.Error(w, "malformed url", http.StatusBadRequest) 151 138 return 152 139 } 153 140 l = l.With("domain", domain) 154 - l = l.With("user", user.Did) 155 141 156 - tx, err := k.Db.Begin() 157 - if err != nil { 158 - l.Error("failed to start transaction", "err", err) 159 - fail() 160 - return 161 - } 162 - defer func() { 163 - tx.Rollback() 164 - k.Enforcer.E.LoadPolicy() 165 - }() 142 + l.Info("checking domain") 166 143 167 - err = db.AddKnot(tx, domain, user.Did) 144 + registration, err := db.RegistrationByDomain(k.Db, domain) 168 145 if err != nil { 169 - l.Error("failed to insert", "err", err) 146 + l.Error("failed to get registration for domain", "err", err) 170 147 fail() 171 148 return 172 149 } 173 - 174 - err = k.Enforcer.AddKnot(domain) 175 - if err != nil { 176 - l.Error("failed to create knot", "err", err) 177 - fail() 150 + if registration.ByDid != user.Did { 151 + l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 + w.WriteHeader(http.StatusUnauthorized) 178 153 return 179 154 } 180 155 181 - // create record on pds 182 - client, err := k.OAuth.AuthorizedClient(r) 156 + secret, err := db.GetRegistrationKey(k.Db, domain) 183 157 if err != nil { 184 - l.Error("failed to authorize client", "err", err) 158 + l.Error("failed to get registration key for domain", "err", err) 185 159 fail() 186 160 return 187 161 } 188 162 189 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 190 - var exCid *string 191 - if ex != nil { 192 - exCid = ex.Cid 193 - } 194 - 195 - // re-announce by registering under same rkey 196 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 197 - Collection: tangled.KnotNSID, 198 - Repo: user.Did, 199 - Rkey: domain, 200 - Record: &lexutil.LexiconTypeDecoder{ 201 - Val: &tangled.Knot{ 202 - CreatedAt: time.Now().Format(time.RFC3339), 203 - }, 204 - }, 205 - SwapRecord: exCid, 206 - }) 207 - 163 + client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 208 164 if err != nil { 209 - l.Error("failed to put record", "err", err) 165 + l.Error("failed to create knotclient", "err", err) 210 166 fail() 211 167 return 212 168 } 213 169 214 - err = tx.Commit() 170 + resp, err := client.Init(user.Did) 215 171 if err != nil { 216 - l.Error("failed to commit transaction", "err", err) 217 - fail() 172 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 + l.Error("failed to make init request", "err", err) 218 174 return 219 175 } 220 176 221 - err = k.Enforcer.E.SavePolicy() 222 - if err != nil { 223 - l.Error("failed to update ACL", "err", err) 224 - k.Pages.HxRefresh(w) 177 + if resp.StatusCode == http.StatusConflict { 178 + k.Pages.Notice(w, noticeId, "This knot is already registered") 179 + l.Error("knot already registered", "statuscode", resp.StatusCode) 225 180 return 226 181 } 227 182 228 - // begin verification 229 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 230 - if err != nil { 231 - l.Error("verification failed", "err", err) 232 - k.Pages.HxRefresh(w) 183 + if resp.StatusCode != http.StatusNoContent { 184 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 + l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 233 186 return 234 187 } 235 188 236 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 189 + // verify response mac 190 + signature := resp.Header.Get("X-Signature") 191 + signatureBytes, err := hex.DecodeString(signature) 237 192 if err != nil { 238 - l.Error("failed to mark verified", "err", err) 239 - k.Pages.HxRefresh(w) 240 193 return 241 194 } 242 195 243 - // add this knot to knotstream 244 - go k.Knotstream.AddSource( 245 - r.Context(), 246 - eventconsumer.NewKnotSource(domain), 247 - ) 196 + expectedMac := hmac.New(sha256.New, []byte(secret)) 197 + expectedMac.Write([]byte("ok")) 248 198 249 - // ok 250 - k.Pages.HxRefresh(w) 251 - } 252 - 253 - func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 254 - user := k.OAuth.GetUser(r) 255 - l := k.Logger.With("handler", "delete") 256 - 257 - noticeId := "operation-error" 258 - defaultErr := "Failed to delete knot. Try again later." 259 - fail := func() { 260 - k.Pages.Notice(w, noticeId, defaultErr) 199 + if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 + k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 + l.Error("signature mismatch", "bytes", signatureBytes) 202 + return 261 203 } 262 204 263 - domain := chi.URLParam(r, "domain") 264 - if domain == "" { 265 - l.Error("empty domain") 205 + tx, err := k.Db.BeginTx(r.Context(), nil) 206 + if err != nil { 207 + l.Error("failed to start tx", "err", err) 266 208 fail() 267 209 return 268 210 } 211 + defer func() { 212 + tx.Rollback() 213 + err = k.Enforcer.E.LoadPolicy() 214 + if err != nil { 215 + l.Error("rollback failed", "err", err) 216 + } 217 + }() 269 218 270 - // get record from db first 271 - registrations, err := db.GetRegistrations( 272 - k.Db, 273 - db.FilterEq("did", user.Did), 274 - db.FilterEq("domain", domain), 275 - ) 219 + // mark as registered 220 + err = db.Register(tx, domain) 276 221 if err != nil { 277 - l.Error("failed to get registration", "err", err) 222 + l.Error("failed to register domain", "err", err) 278 223 fail() 279 224 return 280 225 } 281 - if len(registrations) != 1 { 282 - l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 283 - fail() 284 - return 285 - } 286 - registration := registrations[0] 287 226 288 - tx, err := k.Db.Begin() 227 + // set permissions for this did as owner 228 + reg, err := db.RegistrationByDomain(tx, domain) 289 229 if err != nil { 290 - l.Error("failed to start txn", "err", err) 230 + l.Error("failed get registration by domain", "err", err) 291 231 fail() 292 232 return 293 233 } 294 - defer func() { 295 - tx.Rollback() 296 - k.Enforcer.E.LoadPolicy() 297 - }() 298 234 299 - err = db.DeleteKnot( 300 - tx, 301 - db.FilterEq("did", user.Did), 302 - db.FilterEq("domain", domain), 303 - ) 235 + // add basic acls for this domain 236 + err = k.Enforcer.AddKnot(domain) 304 237 if err != nil { 305 - l.Error("failed to delete registration", "err", err) 238 + l.Error("failed to add knot to enforcer", "err", err) 306 239 fail() 307 240 return 308 241 } 309 242 310 - // delete from enforcer if it was registered 311 - if registration.Registered != nil { 312 - err = k.Enforcer.RemoveKnot(domain) 313 - if err != nil { 314 - l.Error("failed to update ACL", "err", err) 315 - fail() 316 - return 317 - } 318 - } 319 - 320 - client, err := k.OAuth.AuthorizedClient(r) 243 + // add this did as owner of this domain 244 + err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 321 245 if err != nil { 322 - l.Error("failed to authorize client", "err", err) 246 + l.Error("failed to add knot owner to enforcer", "err", err) 323 247 fail() 324 248 return 325 249 } 326 250 327 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 328 - Collection: tangled.KnotNSID, 329 - Repo: user.Did, 330 - Rkey: domain, 331 - }) 332 - if err != nil { 333 - // non-fatal 334 - l.Error("failed to delete record", "err", err) 335 - } 336 - 337 251 err = tx.Commit() 338 252 if err != nil { 339 - l.Error("failed to delete knot", "err", err) 253 + l.Error("failed to commit changes", "err", err) 340 254 fail() 341 255 return 342 256 } 343 257 344 258 err = k.Enforcer.E.SavePolicy() 345 259 if err != nil { 346 - l.Error("failed to update ACL", "err", err) 347 - k.Pages.HxRefresh(w) 260 + l.Error("failed to update ACLs", "err", err) 261 + fail() 348 262 return 349 263 } 350 264 351 - shouldRedirect := r.Header.Get("shouldRedirect") 352 - if shouldRedirect == "true" { 353 - k.Pages.HxRedirect(w, "/knots") 354 - return 355 - } 265 + // add this knot to knotstream 266 + go k.Knotstream.AddSource( 267 + context.Background(), 268 + eventconsumer.NewKnotSource(domain), 269 + ) 356 270 357 - w.Write([]byte{}) 271 + k.Pages.KnotListing(w, pages.KnotListingParams{ 272 + Registration: *reg, 273 + }) 358 274 } 359 275 360 - func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 361 - user := k.OAuth.GetUser(r) 362 - l := k.Logger.With("handler", "retry") 363 - 364 - noticeId := "operation-error" 365 - defaultErr := "Failed to verify knot. Try again later." 276 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 + l := k.Logger.With("handler", "dashboard") 366 278 fail := func() { 367 - k.Pages.Notice(w, noticeId, defaultErr) 279 + w.WriteHeader(http.StatusInternalServerError) 368 280 } 369 281 370 282 domain := chi.URLParam(r, "domain") 371 283 if domain == "" { 372 - l.Error("empty domain") 373 - fail() 284 + http.Error(w, "malformed url", http.StatusBadRequest) 374 285 return 375 286 } 376 287 l = l.With("domain", domain) 377 - l = l.With("user", user.Did) 378 288 379 - // get record from db first 380 - registrations, err := db.GetRegistrations( 381 - k.Db, 382 - db.FilterEq("did", user.Did), 383 - db.FilterEq("domain", domain), 384 - ) 289 + user := k.OAuth.GetUser(r) 290 + l = l.With("did", user.Did) 291 + 292 + // dashboard is only available to owners 293 + ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 385 294 if err != nil { 386 - l.Error("failed to get registration", "err", err) 295 + l.Error("failed to query enforcer", "err", err) 387 296 fail() 388 - return 389 297 } 390 - if len(registrations) != 1 { 391 - l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 392 - fail() 298 + if !ok { 299 + http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 393 300 return 394 301 } 395 - registration := registrations[0] 396 302 397 - // begin verification 398 - err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 303 + reg, err := db.RegistrationByDomain(k.Db, domain) 399 304 if err != nil { 400 - l.Error("verification failed", "err", err) 401 - 402 - if errors.Is(err, serververify.FetchError) { 403 - k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 - return 405 - } 406 - 407 - if e, ok := err.(*serververify.OwnerMismatch); ok { 408 - k.Pages.Notice(w, noticeId, e.Error()) 409 - return 410 - } 411 - 305 + l.Error("failed to get registration by domain", "err", err) 412 306 fail() 413 307 return 414 308 } 415 309 416 - err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 417 - if err != nil { 418 - l.Error("failed to mark verified", "err", err) 419 - k.Pages.Notice(w, noticeId, err.Error()) 420 - return 421 - } 422 - 423 - // if this knot was previously read-only, then emit a record too 424 - // 425 - // this is part of migrating from the old knot system to the new one 426 - if registration.ReadOnly { 427 - // re-announce by registering under same rkey 428 - client, err := k.OAuth.AuthorizedClient(r) 310 + var members []string 311 + if reg.Registered != nil { 312 + members, err = k.Enforcer.GetUserByRole("server:member", domain) 429 313 if err != nil { 430 - l.Error("failed to authorize client", "err", err) 314 + l.Error("failed to get members list", "err", err) 431 315 fail() 432 316 return 433 317 } 434 - 435 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 436 - var exCid *string 437 - if ex != nil { 438 - exCid = ex.Cid 439 - } 440 - 441 - // ignore the error here 442 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 443 - Collection: tangled.KnotNSID, 444 - Repo: user.Did, 445 - Rkey: domain, 446 - Record: &lexutil.LexiconTypeDecoder{ 447 - Val: &tangled.Knot{ 448 - CreatedAt: time.Now().Format(time.RFC3339), 449 - }, 450 - }, 451 - SwapRecord: exCid, 452 - }) 453 - if err != nil { 454 - l.Error("non-fatal: failed to reannouce knot", "err", err) 455 - } 456 318 } 457 319 458 - // add this knot to knotstream 459 - go k.Knotstream.AddSource( 460 - r.Context(), 461 - eventconsumer.NewKnotSource(domain), 462 - ) 463 - 464 - shouldRefresh := r.Header.Get("shouldRefresh") 465 - if shouldRefresh == "true" { 466 - k.Pages.HxRefresh(w) 467 - return 468 - } 469 - 470 - // Get updated registration to show 471 - registrations, err = db.GetRegistrations( 320 + repos, err := db.GetRepos( 472 321 k.Db, 473 - db.FilterEq("did", user.Did), 474 - db.FilterEq("domain", domain), 322 + 0, 323 + db.FilterEq("knot", domain), 324 + db.FilterIn("did", members), 475 325 ) 476 326 if err != nil { 477 - l.Error("failed to get registration", "err", err) 327 + l.Error("failed to get repos list", "err", err) 478 328 fail() 479 329 return 480 330 } 481 - if len(registrations) != 1 { 482 - l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 483 - fail() 484 - return 331 + // convert to map 332 + repoByMember := make(map[string][]db.Repo) 333 + for _, r := range repos { 334 + repoByMember[r.Did] = append(repoByMember[r.Did], r) 485 335 } 486 - updatedRegistration := registrations[0] 487 336 488 - log.Println(updatedRegistration) 489 - 490 - w.Header().Set("HX-Reswap", "outerHTML") 491 - k.Pages.KnotListing(w, pages.KnotListingParams{ 492 - Registration: &updatedRegistration, 337 + k.Pages.Knot(w, pages.KnotParams{ 338 + LoggedInUser: user, 339 + Registration: reg, 340 + Members: members, 341 + Repos: repoByMember, 342 + IsOwner: true, 493 343 }) 494 344 } 495 345 496 - func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 497 - user := k.OAuth.GetUser(r) 498 - l := k.Logger.With("handler", "addMember") 346 + // list members of domain, requires auth and requires owner status 347 + func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 348 + l := k.Logger.With("handler", "members") 499 349 500 350 domain := chi.URLParam(r, "domain") 501 351 if domain == "" { 502 - l.Error("empty domain") 503 - http.Error(w, "Not found", http.StatusNotFound) 352 + http.Error(w, "malformed url", http.StatusBadRequest) 504 353 return 505 354 } 506 355 l = l.With("domain", domain) 507 - l = l.With("user", user.Did) 508 356 509 - registrations, err := db.GetRegistrations( 510 - k.Db, 511 - db.FilterEq("did", user.Did), 512 - db.FilterEq("domain", domain), 513 - db.FilterIsNot("registered", "null"), 514 - ) 357 + // list all members for this domain 358 + memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 515 359 if err != nil { 516 - l.Error("failed to get registration", "err", err) 360 + w.Write([]byte("failed to fetch member list")) 361 + return 362 + } 363 + 364 + w.Write([]byte(strings.Join(memberDids, "\n"))) 365 + } 366 + 367 + // add member to domain, requires auth and requires invite access 368 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 369 + l := k.Logger.With("handler", "members") 370 + 371 + domain := chi.URLParam(r, "domain") 372 + if domain == "" { 373 + http.Error(w, "malformed url", http.StatusBadRequest) 517 374 return 518 375 } 519 - if len(registrations) != 1 { 520 - l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 376 + l = l.With("domain", domain) 377 + 378 + reg, err := db.RegistrationByDomain(k.Db, domain) 379 + if err != nil { 380 + l.Error("failed to get registration by domain", "err", err) 381 + http.Error(w, "malformed url", http.StatusBadRequest) 521 382 return 522 383 } 523 - registration := registrations[0] 524 384 525 - noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 385 + noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 386 + l = l.With("notice-id", noticeId) 526 387 defaultErr := "Failed to add member. Try again later." 527 388 fail := func() { 528 389 k.Pages.Notice(w, noticeId, defaultErr) 529 390 } 530 391 531 - member := r.FormValue("member") 532 - if member == "" { 533 - l.Error("empty member") 534 - k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 392 + subjectIdentifier := r.FormValue("subject") 393 + if subjectIdentifier == "" { 394 + http.Error(w, "malformed form", http.StatusBadRequest) 535 395 return 536 396 } 537 - l = l.With("member", member) 397 + l = l.With("subjectIdentifier", subjectIdentifier) 538 398 539 - memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 399 + subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 540 400 if err != nil { 541 - l.Error("failed to resolve member identity to handle", "err", err) 401 + l.Error("failed to resolve identity", "err", err) 542 402 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 543 403 return 544 404 } 545 - if memberId.Handle.IsInvalidHandle() { 546 - l.Error("failed to resolve member identity to handle") 547 - k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 548 - return 549 - } 405 + l = l.With("subjectDid", subjectIdentity.DID) 406 + 407 + l.Info("adding member to knot") 550 408 551 - // write to pds 409 + // announce this relation into the firehose, store into owners' pds 552 410 client, err := k.OAuth.AuthorizedClient(r) 553 411 if err != nil { 554 - l.Error("failed to authorize client", "err", err) 412 + l.Error("failed to create client", "err", err) 555 413 fail() 556 414 return 557 415 } 558 416 559 - rkey := tid.TID() 560 - 561 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 417 + currentUser := k.OAuth.GetUser(r) 418 + createdAt := time.Now().Format(time.RFC3339) 419 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 562 420 Collection: tangled.KnotMemberNSID, 563 - Repo: user.Did, 564 - Rkey: rkey, 421 + Repo: currentUser.Did, 422 + Rkey: tid.TID(), 565 423 Record: &lexutil.LexiconTypeDecoder{ 566 424 Val: &tangled.KnotMember{ 567 - CreatedAt: time.Now().Format(time.RFC3339), 425 + Subject: subjectIdentity.DID.String(), 568 426 Domain: domain, 569 - Subject: memberId.DID.String(), 570 - }, 571 - }, 427 + CreatedAt: createdAt, 428 + }}, 572 429 }) 430 + // invalid record 573 431 if err != nil { 574 - l.Error("failed to add record to PDS", "err", err) 575 - k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 432 + l.Error("failed to write to PDS", "err", err) 433 + fail() 576 434 return 577 435 } 436 + l = l.With("at-uri", resp.Uri) 437 + l.Info("wrote record to PDS") 578 438 579 - err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 439 + secret, err := db.GetRegistrationKey(k.Db, domain) 580 440 if err != nil { 581 - l.Error("failed to add member to ACLs", "err", err) 441 + l.Error("failed to get registration key", "err", err) 582 442 fail() 583 443 return 584 444 } 585 445 586 - err = k.Enforcer.E.SavePolicy() 446 + ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 587 447 if err != nil { 588 - l.Error("failed to save ACL policy", "err", err) 448 + l.Error("failed to create client", "err", err) 589 449 fail() 590 450 return 591 451 } 592 452 593 - // success 594 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 595 - } 596 - 597 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 598 - user := k.OAuth.GetUser(r) 599 - l := k.Logger.With("handler", "removeMember") 600 - 601 - noticeId := "operation-error" 602 - defaultErr := "Failed to remove member. Try again later." 603 - fail := func() { 604 - k.Pages.Notice(w, noticeId, defaultErr) 605 - } 606 - 607 - domain := chi.URLParam(r, "domain") 608 - if domain == "" { 609 - l.Error("empty domain") 610 - fail() 611 - return 612 - } 613 - l = l.With("domain", domain) 614 - l = l.With("user", user.Did) 615 - 616 - registrations, err := db.GetRegistrations( 617 - k.Db, 618 - db.FilterEq("did", user.Did), 619 - db.FilterEq("domain", domain), 620 - db.FilterIsNot("registered", "null"), 621 - ) 453 + ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 622 454 if err != nil { 623 - l.Error("failed to get registration", "err", err) 624 - return 625 - } 626 - if len(registrations) != 1 { 627 - l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 455 + l.Error("failed to reach knotserver", "err", err) 456 + k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 628 457 return 629 458 } 630 459 631 - member := r.FormValue("member") 632 - if member == "" { 633 - l.Error("empty member") 634 - k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 635 - return 636 - } 637 - l = l.With("member", member) 638 - 639 - memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 640 - if err != nil { 641 - l.Error("failed to resolve member identity to handle", "err", err) 642 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 - return 644 - } 645 - if memberId.Handle.IsInvalidHandle() { 646 - l.Error("failed to resolve member identity to handle") 647 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 648 - return 649 - } 650 - 651 - // remove from enforcer 652 - err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 653 - if err != nil { 654 - l.Error("failed to update ACLs", "err", err) 655 - fail() 656 - return 657 - } 658 - 659 - client, err := k.OAuth.AuthorizedClient(r) 660 - if err != nil { 661 - l.Error("failed to authorize client", "err", err) 662 - fail() 460 + if ksResp.StatusCode != http.StatusNoContent { 461 + l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 462 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 663 463 return 664 464 } 665 465 666 - // TODO: We need to track the rkey for knot members to delete the record 667 - // For now, just remove from ACLs 668 - _ = client 669 - 670 - // commit everything 671 - err = k.Enforcer.E.SavePolicy() 466 + err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 672 467 if err != nil { 673 - l.Error("failed to save ACLs", "err", err) 468 + l.Error("failed to add member to enforcer", "err", err) 674 469 fail() 675 470 return 676 471 } 677 472 678 - // ok 679 - k.Pages.HxRefresh(w) 473 + // success 474 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 680 475 } 681 476 682 - func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 - user := k.OAuth.GetUser(r) 684 - l := k.Logger.With("handler", "removeMember") 685 - l = l.With("did", user.Did) 686 - l = l.With("handle", user.Handle) 687 - 688 - registrations, err := db.GetRegistrations( 689 - k.Db, 690 - db.FilterEq("did", user.Did), 691 - db.FilterEq("read_only", 1), 692 - ) 693 - if err != nil { 694 - l.Error("non-fatal: failed to get registrations") 695 - return 696 - } 697 - 698 - if registrations == nil { 699 - return 700 - } 701 - 702 - k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 - Registrations: registrations, 704 - }) 477 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 705 478 }
+3 -3
appview/middleware/middleware.go
··· 217 217 if err != nil { 218 218 // invalid did or handle 219 219 log.Println("failed to resolve repo") 220 - mw.pages.ErrorKnot404(w) 220 + mw.pages.Error404(w) 221 221 return 222 222 } 223 223 ··· 234 234 f, err := mw.repoResolver.Resolve(r) 235 235 if err != nil { 236 236 log.Println("failed to fully resolve repo", err) 237 - mw.pages.ErrorKnot404(w) 237 + http.Error(w, "invalid repo url", http.StatusNotFound) 238 238 return 239 239 } 240 240 ··· 283 283 f, err := mw.repoResolver.Resolve(r) 284 284 if err != nil { 285 285 log.Println("failed to fully resolve repo", err) 286 - mw.pages.ErrorKnot404(w) 286 + http.Error(w, "invalid repo url", http.StatusNotFound) 287 287 return 288 288 } 289 289
+82 -98
appview/oauth/handler/handler.go
··· 8 8 "log" 9 9 "net/http" 10 10 "net/url" 11 - "slices" 12 11 "strings" 13 12 "time" 14 13 ··· 26 25 "tangled.sh/tangled.sh/core/appview/oauth/client" 27 26 "tangled.sh/tangled.sh/core/appview/pages" 28 27 "tangled.sh/tangled.sh/core/idresolver" 28 + "tangled.sh/tangled.sh/core/knotclient" 29 29 "tangled.sh/tangled.sh/core/rbac" 30 30 "tangled.sh/tangled.sh/core/tid" 31 31 ) ··· 353 353 return pubKey, nil 354 354 } 355 355 356 - var ( 357 - tangledHandle = "tangled.sh" 358 - tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 - defaultSpindle = "spindle.tangled.sh" 360 - defaultKnot = "knot1.tangled.sh" 361 - ) 362 - 363 356 func (o *OAuthHandler) addToDefaultSpindle(did string) { 364 357 // use the tangled.sh app password to get an accessJwt 365 358 // and create an sh.tangled.spindle.member record with that 359 + 360 + defaultSpindle := "spindle.tangled.sh" 361 + appPassword := o.config.Core.AppPassword 362 + 366 363 spindleMembers, err := db.GetSpindleMembers( 367 364 o.db, 368 365 db.FilterEq("instance", "spindle.tangled.sh"), ··· 378 375 return 379 376 } 380 377 381 - log.Printf("adding %s to default spindle", did) 382 - session, err := o.createAppPasswordSession() 383 - if err != nil { 384 - log.Printf("failed to create session: %s", err) 385 - return 386 - } 378 + // TODO: hardcoded tangled handle and did for now 379 + tangledHandle := "tangled.sh" 380 + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 387 381 388 - record := tangled.SpindleMember{ 389 - LexiconTypeID: "sh.tangled.spindle.member", 390 - Subject: did, 391 - Instance: defaultSpindle, 392 - CreatedAt: time.Now().Format(time.RFC3339), 393 - } 394 - 395 - if err := session.putRecord(record); err != nil { 396 - log.Printf("failed to add member to default knot: %s", err) 382 + if appPassword == "" { 383 + log.Println("no app password configured, skipping spindle member addition") 397 384 return 398 385 } 399 386 400 - log.Printf("successfully added %s to default spindle", did) 401 - } 402 - 403 - func (o *OAuthHandler) addToDefaultKnot(did string) { 404 - // use the tangled.sh app password to get an accessJwt 405 - // and create an sh.tangled.spindle.member record with that 387 + log.Printf("adding %s to default spindle", did) 406 388 407 - allKnots, err := o.enforcer.GetKnotsForUser(did) 389 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 408 390 if err != nil { 409 - log.Printf("failed to get knot members for did %s: %v", did, err) 391 + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 410 392 return 411 393 } 412 394 413 - if slices.Contains(allKnots, defaultKnot) { 414 - log.Printf("did %s is already a member of the default knot", did) 415 - return 416 - } 417 - 418 - log.Printf("adding %s to default knot", did) 419 - session, err := o.createAppPasswordSession() 420 - if err != nil { 421 - log.Printf("failed to create session: %s", err) 422 - return 423 - } 424 - 425 - record := tangled.KnotMember{ 426 - LexiconTypeID: "sh.tangled.knot.member", 427 - Subject: did, 428 - Domain: defaultKnot, 429 - CreatedAt: time.Now().Format(time.RFC3339), 430 - } 431 - 432 - if err := session.putRecord(record); err != nil { 433 - log.Printf("failed to add member to default knot: %s", err) 434 - return 435 - } 436 - 437 - log.Printf("successfully added %s to default Knot", did) 438 - } 439 - 440 - // create a session using apppasswords 441 - type session struct { 442 - AccessJwt string `json:"accessJwt"` 443 - PdsEndpoint string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession() (*session, error) { 447 - appPassword := o.config.Core.AppPassword 448 - if appPassword == "" { 449 - return nil, fmt.Errorf("no app password configured, skipping member addition") 450 - } 451 - 452 - resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 453 - if err != nil { 454 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 455 - } 456 - 457 395 pdsEndpoint := resolved.PDSEndpoint() 458 396 if pdsEndpoint == "" { 459 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 397 + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 398 + return 460 399 } 461 400 462 401 sessionPayload := map[string]string{ ··· 465 404 } 466 405 sessionBytes, err := json.Marshal(sessionPayload) 467 406 if err != nil { 468 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 407 + log.Printf("failed to marshal session payload: %v", err) 408 + return 469 409 } 470 410 471 411 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 472 412 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 473 413 if err != nil { 474 - return nil, fmt.Errorf("failed to create session request: %v", err) 414 + log.Printf("failed to create session request: %v", err) 415 + return 475 416 } 476 417 sessionReq.Header.Set("Content-Type", "application/json") 477 418 478 419 client := &http.Client{Timeout: 30 * time.Second} 479 420 sessionResp, err := client.Do(sessionReq) 480 421 if err != nil { 481 - return nil, fmt.Errorf("failed to create session: %v", err) 422 + log.Printf("failed to create session: %v", err) 423 + return 482 424 } 483 425 defer sessionResp.Body.Close() 484 426 485 427 if sessionResp.StatusCode != http.StatusOK { 486 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 428 + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 429 + return 487 430 } 488 431 489 - var session session 432 + var session struct { 433 + AccessJwt string `json:"accessJwt"` 434 + } 490 435 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 491 - return nil, fmt.Errorf("failed to decode session response: %v", err) 436 + log.Printf("failed to decode session response: %v", err) 437 + return 492 438 } 493 439 494 - session.PdsEndpoint = pdsEndpoint 495 - 496 - return &session, nil 497 - } 440 + record := tangled.SpindleMember{ 441 + LexiconTypeID: "sh.tangled.spindle.member", 442 + Subject: did, 443 + Instance: defaultSpindle, 444 + CreatedAt: time.Now().Format(time.RFC3339), 445 + } 498 446 499 - func (s *session) putRecord(record any) error { 500 447 recordBytes, err := json.Marshal(record) 501 448 if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 449 + log.Printf("failed to marshal spindle member record: %v", err) 450 + return 503 451 } 504 452 505 - payload := map[string]any{ 453 + payload := map[string]interface{}{ 506 454 "repo": tangledDid, 507 - "collection": tangled.KnotMemberNSID, 455 + "collection": tangled.SpindleMemberNSID, 508 456 "rkey": tid.TID(), 509 457 "record": json.RawMessage(recordBytes), 510 458 } 511 459 512 460 payloadBytes, err := json.Marshal(payload) 513 461 if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 462 + log.Printf("failed to marshal request payload: %v", err) 463 + return 515 464 } 516 465 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 466 + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 467 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 468 if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 469 + log.Printf("failed to create HTTP request: %v", err) 470 + return 521 471 } 522 472 523 473 req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 474 + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 525 475 526 - client := &http.Client{Timeout: 30 * time.Second} 527 476 resp, err := client.Do(req) 528 477 if err != nil { 529 - return fmt.Errorf("failed to add user to default Knot: %w", err) 478 + log.Printf("failed to add user to default spindle: %v", err) 479 + return 530 480 } 531 481 defer resp.Body.Close() 532 482 533 483 if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode) 484 + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 485 + return 535 486 } 536 487 537 - return nil 488 + log.Printf("successfully added %s to default spindle", did) 489 + } 490 + 491 + func (o *OAuthHandler) addToDefaultKnot(did string) { 492 + defaultKnot := "knot1.tangled.sh" 493 + 494 + log.Printf("adding %s to default knot", did) 495 + err := o.enforcer.AddKnotMember(defaultKnot, did) 496 + if err != nil { 497 + log.Println("failed to add user to knot1.tangled.sh: ", err) 498 + return 499 + } 500 + err = o.enforcer.E.SavePolicy() 501 + if err != nil { 502 + log.Println("failed to add user to knot1.tangled.sh: ", err) 503 + return 504 + } 505 + 506 + secret, err := db.GetRegistrationKey(o.db, defaultKnot) 507 + if err != nil { 508 + log.Println("failed to get registration key for knot1.tangled.sh") 509 + return 510 + } 511 + signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 512 + resp, err := signedClient.AddMember(did) 513 + if err != nil { 514 + log.Println("failed to add user to knot1.tangled.sh: ", err) 515 + return 516 + } 517 + 518 + if resp.StatusCode != http.StatusNoContent { 519 + log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 520 + return 521 + } 538 522 }
-3
appview/oauth/oauth.go
··· 286 286 AccessJwt: resp.Token, 287 287 }, 288 288 Host: opts.Host(), 289 - Client: &http.Client{ 290 - Timeout: time.Second * 5, 291 - }, 292 289 }, nil 293 290 } 294 291
-13
appview/pages/funcmap.go
··· 21 21 "github.com/go-enry/go-enry/v2" 22 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 - "tangled.sh/tangled.sh/core/crypto" 25 24 ) 26 25 27 26 func (p *Pages) funcMap() template.FuncMap { ··· 277 276 }, 278 277 "layoutCenter": func() string { 279 278 return "col-span-1 md:col-span-8 lg:col-span-6" 280 - }, 281 - 282 - "normalizeForHtmlId": func(s string) string { 283 - // TODO: extend this to handle other cases? 284 - return strings.ReplaceAll(s, ":", "_") 285 - }, 286 - "sshFingerprint": func(pubKey string) string { 287 - fp, err := crypto.SSHFingerprint(pubKey) 288 - if err != nil { 289 - return "error" 290 - } 291 - return fp 292 279 }, 293 280 } 294 281 }
+35 -81
appview/pages/pages.go
··· 306 306 return p.execute("timeline/timeline", w, params) 307 307 } 308 308 309 - type UserProfileSettingsParams struct { 310 - LoggedInUser *oauth.User 311 - Tabs []map[string]any 312 - Tab string 313 - } 314 - 315 - func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 316 - return p.execute("user/settings/profile", w, params) 317 - } 318 - 319 - type UserKeysSettingsParams struct { 309 + type SettingsParams struct { 320 310 LoggedInUser *oauth.User 321 311 PubKeys []db.PublicKey 322 - Tabs []map[string]any 323 - Tab string 324 - } 325 - 326 - func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 327 - return p.execute("user/settings/keys", w, params) 328 - } 329 - 330 - type UserEmailsSettingsParams struct { 331 - LoggedInUser *oauth.User 332 312 Emails []db.Email 333 - Tabs []map[string]any 334 - Tab string 335 - } 336 - 337 - func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 338 - return p.execute("user/settings/emails", w, params) 339 313 } 340 314 341 - type KnotBannerParams struct { 342 - Registrations []db.Registration 343 - } 344 - 345 - func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 - return p.executePlain("knots/fragments/banner", w, params) 315 + func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 316 + return p.execute("settings", w, params) 347 317 } 348 318 349 319 type KnotsParams struct { ··· 368 338 } 369 339 370 340 type KnotListingParams struct { 371 - *db.Registration 341 + db.Registration 372 342 } 373 343 374 344 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 375 345 return p.executePlain("knots/fragments/knotListing", w, params) 346 + } 347 + 348 + type KnotListingFullParams struct { 349 + Registrations []db.Registration 350 + } 351 + 352 + func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 353 + return p.executePlain("knots/fragments/knotListingFull", w, params) 354 + } 355 + 356 + type KnotSecretParams struct { 357 + Secret string 358 + } 359 + 360 + func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 361 + return p.executePlain("knots/fragments/secret", w, params) 376 362 } 377 363 378 364 type SpindlesParams struct { ··· 422 408 return p.execute("repo/fork", w, params) 423 409 } 424 410 425 - type ProfileHomePageParams struct { 411 + type ProfilePageParams struct { 426 412 LoggedInUser *oauth.User 427 413 Repos []db.Repo 428 414 CollaboratingRepos []db.Repo ··· 432 418 } 433 419 434 420 type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount int 421 + UserDid string 422 + UserHandle string 423 + FollowStatus db.FollowStatus 424 + Followers int 425 + Following int 440 426 441 427 Profile *db.Profile 442 428 } 443 429 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 430 + func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 445 431 return p.execute("user/profile", w, params) 446 432 } 447 433 ··· 453 439 454 440 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 441 return p.execute("user/repos", w, params) 456 - } 457 - 458 - type FollowCard struct { 459 - UserDid string 460 - FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 463 - Profile *db.Profile 464 - } 465 - 466 - type FollowersPageParams struct { 467 - LoggedInUser *oauth.User 468 - Followers []FollowCard 469 - Card ProfileCard 470 - } 471 - 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 474 - } 475 - 476 - type FollowingPageParams struct { 477 - LoggedInUser *oauth.User 478 - Following []FollowCard 479 - Card ProfileCard 480 - } 481 - 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 484 442 } 485 443 486 444 type FollowFragmentParams struct { ··· 539 497 } 540 498 541 499 type RepoIndexParams struct { 542 - LoggedInUser *oauth.User 543 - RepoInfo repoinfo.RepoInfo 544 - Active string 545 - TagMap map[string][]string 546 - CommitsTrunc []*object.Commit 547 - TagsTrunc []*types.TagReference 548 - BranchesTrunc []types.Branch 549 - // ForkInfo *types.ForkInfo 500 + LoggedInUser *oauth.User 501 + RepoInfo repoinfo.RepoInfo 502 + Active string 503 + TagMap map[string][]string 504 + CommitsTrunc []*object.Commit 505 + TagsTrunc []*types.TagReference 506 + BranchesTrunc []types.Branch 507 + ForkInfo *types.ForkInfo 550 508 HTMLReadme template.HTML 551 509 Raw bool 552 510 EmailToDidOrHandle map[string]string ··· 1312 1270 1313 1271 func (p *Pages) Error404(w io.Writer) error { 1314 1272 return p.execute("errors/404", w, nil) 1315 - } 1316 - 1317 - func (p *Pages) ErrorKnot404(w io.Writer) error { 1318 - return p.execute("errors/knot404", w, nil) 1319 1273 } 1320 1274 1321 1275 func (p *Pages) Error503(w io.Writer) error {
+4 -24
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 - {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 404 &mdash; page not found 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 - </p> 19 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 21 - {{ i "arrow-left" "w-4 h-4" }} 22 - go back 23 - </a> 24 - </div> 25 - </div> 26 - </div> 27 - </div> 4 + <h1>404 &mdash; nothing like that here!</h1> 5 + <p> 6 + It seems we couldn't find what you were looking for. Sorry about that! 7 + </p> 28 8 {{ end }}
+3 -36
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 500 &mdash; internal server error 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 26 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 - {{ i "refresh-cw" "w-4 h-4" }} 29 - try again 30 - </button> 31 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 - {{ i "home" "w-4 h-4" }} 33 - back to home 34 - </a> 35 - </div> 36 - </div> 37 - </div> 38 - </div> 39 - {{ end }} 4 + <h1>500 &mdash; something broke!</h1> 5 + <p>We're working on getting service back up. Hang tight!</p> 6 + {{ end }}
+5 -28
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 - {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 503 &mdash; service unavailable 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 - </p> 19 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 - {{ i "refresh-cw" "w-4 h-4" }} 22 - try again 23 - </button> 24 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 25 - {{ i "arrow-left" "w-4 h-4" }} 26 - back to timeline 27 - </a> 28 - </div> 29 - </div> 30 - </div> 31 - </div> 4 + <h1>503 &mdash; unable to reach knot</h1> 5 + <p> 6 + We were unable to reach the knot hosting this repository. Try again 7 + later. 8 + </p> 32 9 {{ end }}
-28
appview/pages/templates/errors/knot404.html
··· 1 - {{ define "title" }}404 &middot; tangled{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 - {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 404 &mdash; repository not found 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 - </p> 19 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 21 - {{ i "arrow-left" "w-4 h-4" }} 22 - back to timeline 23 - </a> 24 - </div> 25 - </div> 26 - </div> 27 - </div> 28 - {{ end }}
+28 -93
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 - <div id="right-side" class="flex gap-2"> 8 - {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 - {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 - {{ if .Registration.IsRegistered }} 11 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 - {{ if $isOwner }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <div id="left-side" class="flex gap-2 items-center"> 7 + <h1 class="text-xl font-bold dark:text-white"> 8 + {{ .Registration.Domain }} 9 + </h1> 10 + <span class="text-gray-500 text-base"> 11 + {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 + </span> 13 + </div> 14 + <div id="right-side" class="flex gap-2"> 15 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 + {{ if .Registration.Registered }} 17 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 13 18 {{ template "knots/fragments/addMemberModal" .Registration }} 19 + {{ else }} 20 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 14 21 {{ end }} 15 - {{ else if .Registration.IsReadOnly }} 16 - <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 - {{ i "shield-alert" "w-4 h-4" }} read-only 18 - </span> 19 - {{ if $isOwner }} 20 - {{ block "retryButton" .Registration }} {{ end }} 21 - {{ end }} 22 - {{ else }} 23 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 - {{ if $isOwner }} 25 - {{ block "retryButton" .Registration }} {{ end }} 26 - {{ end }} 27 - {{ end }} 28 - 29 - {{ if $isOwner }} 30 - {{ block "deleteButton" .Registration }} {{ end }} 31 - {{ end }} 22 + </div> 32 23 </div> 24 + <div id="operation-error" class="dark:text-red-400"></div> 33 25 </div> 34 - <div id="operation-error" class="dark:text-red-400"></div> 35 - </div> 36 26 37 - {{ if .Members }} 38 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 - <div class="flex flex-col gap-2"> 40 - {{ block "member" . }} {{ end }} 41 - </div> 42 - </section> 27 + {{ if .Members }} 28 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 + <div class="flex flex-col gap-2"> 30 + {{ block "knotMember" . }} {{ end }} 31 + </div> 32 + </section> 33 + {{ end }} 43 34 {{ end }} 44 - {{ end }} 45 - 46 35 47 - {{ define "member" }} 36 + {{ define "knotMember" }} 48 37 {{ range .Members }} 49 38 <div> 50 39 <div class="flex justify-between items-center"> ··· 52 41 {{ template "user/fragments/picHandleLink" . }} 53 42 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 54 43 </div> 55 - {{ if ne $.LoggedInUser.Did . }} 56 - {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 - {{ end }} 58 44 </div> 59 45 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 60 46 {{ $repos := index $.Repos . }} ··· 67 53 </div> 68 54 {{ else }} 69 55 <div class="text-gray-500 dark:text-gray-400"> 70 - No repositories configured yet. 56 + No repositories created yet. 71 57 </div> 72 58 {{ end }} 73 59 </div> 74 60 </div> 75 61 {{ end }} 76 62 {{ end }} 77 - 78 - {{ define "deleteButton" }} 79 - <button 80 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 - title="Delete knot" 82 - hx-delete="/knots/{{ .Domain }}" 83 - hx-swap="outerHTML" 84 - hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 - hx-headers='{"shouldRedirect": "true"}' 86 - > 87 - {{ i "trash-2" "w-5 h-5" }} 88 - <span class="hidden md:inline">delete</span> 89 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 - </button> 91 - {{ end }} 92 - 93 - 94 - {{ define "retryButton" }} 95 - <button 96 - class="btn gap-2 group" 97 - title="Retry knot verification" 98 - hx-post="/knots/{{ .Domain }}/retry" 99 - hx-swap="none" 100 - hx-headers='{"shouldRefresh": "true"}' 101 - > 102 - {{ i "rotate-ccw" "w-5 h-5" }} 103 - <span class="hidden md:inline">retry</span> 104 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 - </button> 106 - {{ end }} 107 - 108 - 109 - {{ define "removeMemberButton" }} 110 - {{ $root := index . 0 }} 111 - {{ $member := index . 1 }} 112 - {{ $memberHandle := resolve $member }} 113 - <button 114 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 - title="Remove member" 116 - hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 - hx-swap="none" 118 - hx-vals='{"member": "{{$member}}" }' 119 - hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 - > 121 - {{ i "user-minus" "w-4 h-4" }} 122 - remove 123 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 - </button> 125 - {{ end }} 126 - 127 -
+7 -6
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 1 {{ define "knots/fragments/addMemberModal" }} 2 2 <button 3 3 class="btn gap-2 group" 4 - title="Add member to this knot" 4 + title="Add member to this spindle" 5 5 popovertarget="add-member-{{ .Id }}" 6 6 popovertargetaction="toggle" 7 7 > ··· 20 20 21 21 {{ define "addKnotMemberPopover" }} 22 22 <form 23 - hx-post="/knots/{{ .Domain }}/add" 23 + hx-put="/knots/{{ .Domain }}/member" 24 24 hx-indicator="#spinner" 25 25 hx-swap="none" 26 26 class="flex flex-col gap-2" ··· 28 28 <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 29 ADD MEMBER 30 30 </label> 31 - <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 32 32 <input 33 33 type="text" 34 34 id="member-did-{{ .Id }}" 35 - name="member" 35 + name="subject" 36 36 required 37 37 placeholder="@foo.bsky.social" 38 38 /> 39 39 <div class="flex gap-2 pt-2"> 40 - <button 40 + <button 41 41 type="button" 42 42 popovertarget="add-member-{{ .Id }}" 43 43 popovertargetaction="hide" ··· 54 54 </div> 55 55 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 56 </form> 57 - {{ end }} 57 + {{ end }} 58 +
-9
appview/pages/templates/knots/fragments/banner.html
··· 1 - {{ define "knots/fragments/banner" }} 2 - <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 - A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 - that you administer is presently read-only. Consider upgrading this knot to 5 - continue creating repositories on it. 6 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 - </div> 8 - {{ end }} 9 -
+25 -57
appview/pages/templates/knots/fragments/knotListing.html
··· 1 1 {{ define "knots/fragments/knotListing" }} 2 - <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "knotLeftSide" . }} {{ end }} 4 - {{ block "knotRightSide" . }} {{ end }} 2 + <div 3 + id="knot-{{.Id}}" 4 + hx-swap-oob="true" 5 + class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 + {{ block "listLeftSide" . }} {{ end }} 7 + {{ block "listRightSide" . }} {{ end }} 5 8 </div> 6 9 {{ end }} 7 10 8 - {{ define "knotLeftSide" }} 9 - {{ if .Registered }} 10 - <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ define "listLeftSide" }} 12 + <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 13 {{ i "hard-drive" "w-4 h-4" }} 12 - <span class="hover:underline"> 14 + {{ if .Registered }} 15 + <a href="/knots/{{ .Domain }}"> 16 + {{ .Domain }} 17 + </a> 18 + {{ else }} 13 19 {{ .Domain }} 14 - </span> 15 - <span class="text-gray-500"> 16 - {{ template "repo/fragments/shortTimeAgo" .Created }} 17 - </span> 18 - </a> 19 - {{ else }} 20 - <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 21 - {{ i "hard-drive" "w-4 h-4" }} 22 - {{ .Domain }} 20 + {{ end }} 23 21 <span class="text-gray-500"> 24 22 {{ template "repo/fragments/shortTimeAgo" .Created }} 25 23 </span> 26 24 </div> 27 - {{ end }} 28 25 {{ end }} 29 26 30 - {{ define "knotRightSide" }} 27 + {{ define "listRightSide" }} 31 28 <div id="right-side" class="flex gap-2"> 32 29 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .IsRegistered }} 34 - <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 - {{ i "shield-check" "w-4 h-4" }} verified 36 - </span> 30 + {{ if .Registered }} 31 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 37 32 {{ template "knots/fragments/addMemberModal" . }} 38 - {{ block "knotDeleteButton" . }} {{ end }} 39 - {{ else if .IsReadOnly }} 40 - <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 - {{ i "shield-alert" "w-4 h-4" }} read-only 42 - </span> 43 - {{ block "knotRetryButton" . }} {{ end }} 44 - {{ block "knotDeleteButton" . }} {{ end }} 45 33 {{ else }} 46 - <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 - {{ i "shield-off" "w-4 h-4" }} unverified 48 - </span> 49 - {{ block "knotRetryButton" . }} {{ end }} 50 - {{ block "knotDeleteButton" . }} {{ end }} 34 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 35 + {{ block "initializeButton" . }} {{ end }} 51 36 {{ end }} 52 37 </div> 53 38 {{ end }} 54 39 55 - {{ define "knotDeleteButton" }} 40 + {{ define "initializeButton" }} 56 41 <button 57 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 - title="Delete knot" 59 - hx-delete="/knots/{{ .Domain }}" 60 - hx-swap="outerHTML" 61 - hx-target="#knot-{{.Id}}" 62 - hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 42 + class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 43 + hx-post="/knots/{{ .Domain }}/init" 44 + hx-swap="none" 63 45 > 64 - {{ i "trash-2" "w-5 h-5" }} 65 - <span class="hidden md:inline">delete</span> 46 + {{ i "square-play" "w-5 h-5" }} 47 + <span class="hidden md:inline">initialize</span> 66 48 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 49 </button> 68 50 {{ end }} 69 51 70 - 71 - {{ define "knotRetryButton" }} 72 - <button 73 - class="btn gap-2 group" 74 - title="Retry knot verification" 75 - hx-post="/knots/{{ .Domain }}/retry" 76 - hx-swap="none" 77 - hx-target="#knot-{{.Id}}" 78 - > 79 - {{ i "rotate-ccw" "w-5 h-5" }} 80 - <span class="hidden md:inline">retry</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </button> 83 - {{ end }}
+18
appview/pages/templates/knots/fragments/knotListingFull.html
··· 1 + {{ define "knots/fragments/knotListingFull" }} 2 + <section 3 + id="knot-listing-full" 4 + hx-swap-oob="true" 5 + class="rounded w-full flex flex-col gap-2"> 6 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 7 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 8 + {{ range $knot := .Registrations }} 9 + {{ template "knots/fragments/knotListing" . }} 10 + {{ else }} 11 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 12 + no knots registered yet 13 + </div> 14 + {{ end }} 15 + </div> 16 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 17 + </section> 18 + {{ end }}
+10
appview/pages/templates/knots/fragments/secret.html
··· 1 + {{ define "knots/fragments/secret" }} 2 + <div 3 + id="secret" 4 + hx-swap-oob="true" 5 + class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl"> 6 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2> 7 + <p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p> 8 + <span class="font-mono overflow-x">{{ .Secret }}</span> 9 + </div> 10 + {{ end }}
+8 -23
appview/pages/templates/knots/index.html
··· 8 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 9 <div class="flex flex-col gap-6"> 10 10 {{ block "about" . }} {{ end }} 11 - {{ block "list" . }} {{ end }} 11 + {{ template "knots/fragments/knotListingFull" . }} 12 12 {{ block "register" . }} {{ end }} 13 13 </div> 14 14 </section> ··· 27 27 </section> 28 28 {{ end }} 29 29 30 - {{ define "list" }} 31 - <section class="rounded w-full flex flex-col gap-2"> 32 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 33 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 34 - {{ range $registration := .Registrations }} 35 - {{ template "knots/fragments/knotListing" . }} 36 - {{ else }} 37 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 38 - no knots registered yet 39 - </div> 40 - {{ end }} 41 - </div> 42 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 43 - </section> 44 - {{ end }} 45 - 46 30 {{ define "register" }} 47 - <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 31 + <section class="rounded max-w-2xl flex flex-col gap-2"> 48 32 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 49 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 33 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 50 34 <form 51 - hx-post="/knots/register" 52 - class="max-w-2xl mb-2 space-y-4" 35 + hx-post="/knots/key" 36 + class="space-y-4" 53 37 hx-indicator="#register-button" 54 38 hx-swap="none" 55 39 > ··· 69 53 > 70 54 <span class="inline-flex items-center gap-2"> 71 55 {{ i "plus" "w-4 h-4" }} 72 - register 56 + generate 73 57 </span> 74 58 <span class="pl-2 hidden group-[.htmx-request]:inline"> 75 59 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 77 61 </button> 78 62 </div> 79 63 80 - <div id="register-error" class="error dark:text-red-400"></div> 64 + <div id="registration-error" class="error dark:text-red-400"></div> 81 65 </form> 82 66 67 + <div id="secret"></div> 83 68 </section> 84 69 {{ end }}
-7
appview/pages/templates/layouts/topbar.html
··· 21 21 </div> 22 22 </div> 23 23 </nav> 24 - {{ if .LoggedInUser }} 25 - <div id="upgrade-banner" 26 - hx-get="/knots/upgradeBanner" 27 - hx-trigger="load" 28 - hx-swap="innerHTML"> 29 - </div> 30 - {{ end }} 31 24 {{ end }} 32 25 33 26 {{ define "newButton" }}
+2 -8
appview/pages/templates/repo/fork.html
··· 5 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 9 9 <fieldset class="space-y-3"> 10 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 11 <div class="space-y-2"> ··· 30 30 </fieldset> 31 31 32 32 <div class="space-y-2"> 33 - <button type="submit" class="btn-create flex items-center gap-2"> 34 - {{ i "git-fork" "w-4 h-4" }} 35 - fork repo 36 - <span id="spinner" class="group"> 37 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 - </span> 39 - </button> 33 + <button type="submit" class="btn">fork repo</button> 40 34 <div id="repo" class="error"></div> 41 35 </div> 42 36 </form>
+47 -22
appview/pages/templates/repo/index.html
··· 84 84 </optgroup> 85 85 </select> 86 86 <div class="flex items-center gap-2"> 87 + {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 88 + {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 89 + {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 90 + {{ $disabled := "" }} 91 + {{ $title := "" }} 92 + {{ if eq .ForkInfo.Status 0 }} 93 + {{ $disabled = "disabled" }} 94 + {{ $title = "This branch is not behind the upstream" }} 95 + {{ else if eq .ForkInfo.Status 2 }} 96 + {{ $disabled = "disabled" }} 97 + {{ $title = "This branch has conflicts that must be resolved" }} 98 + {{ else if eq .ForkInfo.Status 3 }} 99 + {{ $disabled = "disabled" }} 100 + {{ $title = "This branch does not exist on the upstream" }} 101 + {{ end }} 102 + 103 + <button 104 + id="syncBtn" 105 + {{ $disabled }} 106 + {{ if $title }}title="{{ $title }}"{{ end }} 107 + class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 108 + hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 109 + hx-trigger="click" 110 + hx-swap="none" 111 + > 112 + {{ if $disabled }} 113 + {{ i "refresh-cw-off" "w-4 h-4" }} 114 + {{ else }} 115 + {{ i "refresh-cw" "w-4 h-4" }} 116 + {{ end }} 117 + <span>sync</span> 118 + </button> 119 + {{ end }} 87 120 <a 88 121 href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 89 122 class="btn flex items-center gap-2 no-underline hover:no-underline" ··· 323 356 324 357 {{ define "repoAfter" }} 325 358 {{- if or .HTMLReadme .Readme -}} 326 - <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 327 - {{- if .ReadmeFileName -}} 328 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 329 - {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 330 - <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 331 - </div> 332 - {{- end -}} 333 - <section 334 - class="p-6 overflow-auto {{ if not .Raw }} 335 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 336 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 337 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 338 - {{ end }}" 339 - > 340 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 341 - {{- .Readme -}} 342 - </pre> 343 - {{- else -}} 344 - {{ .HTMLReadme }} 345 - {{- end -}}</article> 346 - </section> 347 - </div> 359 + <section 360 + class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 361 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 362 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 363 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 364 + {{ end }}" 365 + > 366 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 367 + {{- .Readme -}} 368 + </pre> 369 + {{- else -}} 370 + {{ .HTMLReadme }} 371 + {{- end -}}</article> 372 + </section> 348 373 {{- end -}} 349 374 {{ end }}
+1 -1
appview/pages/templates/repo/new.html
··· 63 63 <button type="submit" class="btn-create flex items-center gap-2"> 64 64 {{ i "book-plus" "w-4 h-4" }} 65 65 create repo 66 - <span id="spinner" class="group"> 66 + <span id="create-pull-spinner" class="group"> 67 67 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 68 </span> 69 69 </button>
+1 -3
appview/pages/templates/repo/settings/general.html
··· 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 10 {{ template "deleteRepo" . }} 11 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 11 </div> 13 12 </section> 14 13 {{ end }} ··· 23 22 unless you specify a different branch. 24 23 </p> 25 24 </div> 26 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 25 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 27 26 <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 28 27 <option value="" disabled selected > 29 28 Choose a default branch ··· 55 54 <button 56 55 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 57 56 type="button" 58 - hx-swap="none" 59 57 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 60 58 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 61 59 {{ i "trash-2" "size-4" }}
+192
appview/pages/templates/settings.html
··· 1 + {{ define "title" }}settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="flex flex-col"> 8 + {{ block "profile" . }} {{ end }} 9 + {{ block "keys" . }} {{ end }} 10 + {{ block "emails" . }} {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "profile" }} 15 + <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 + <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 + {{ if .LoggedInUser.Handle }} 19 + <dt class="font-bold">handle</dt> 20 + <dd>@{{ .LoggedInUser.Handle }}</dd> 21 + {{ end }} 22 + <dt class="font-bold">did</dt> 23 + <dd>{{ .LoggedInUser.Did }}</dd> 24 + <dt class="font-bold">pds</dt> 25 + <dd>{{ .LoggedInUser.Pds }}</dd> 26 + </dl> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "keys" }} 31 + <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 + <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 + <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 + {{ range $index, $key := .PubKeys }} 36 + <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 + <div class="flex flex-col gap-1"> 38 + <div class="inline-flex items-center gap-4"> 39 + {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 + <p class="font-bold dark:text-white">{{ .Name }}</p> 41 + </div> 42 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 + <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 + <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 + </div> 46 + </div> 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete key" 50 + hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 + hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 + > 53 + {{ i "trash-2" "w-5 h-5" }} 54 + <span class="hidden md:inline">delete</span> 55 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 + </button> 57 + </div> 58 + {{ end }} 59 + </div> 60 + <form 61 + hx-put="/settings/keys" 62 + hx-indicator="#add-sshkey-spinner" 63 + hx-swap="none" 64 + class="max-w-2xl mb-8 space-y-4" 65 + > 66 + <input 67 + type="text" 68 + id="name" 69 + name="name" 70 + placeholder="key name" 71 + required 72 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 + 74 + <input 75 + id="key" 76 + name="key" 77 + placeholder="ssh-rsa AAAAAA..." 78 + required 79 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 + 81 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 + <span>add key</span> 83 + <span id="add-sshkey-spinner" class="group"> 84 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </span> 86 + </button> 87 + 88 + <div id="settings-keys" class="error dark:text-red-400"></div> 89 + </form> 90 + </section> 91 + {{ end }} 92 + 93 + {{ define "emails" }} 94 + <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 + <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 + <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 + {{ range $index, $email := .Emails }} 99 + <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 + <div class="flex flex-col gap-2"> 101 + <div class="inline-flex items-center gap-4"> 102 + {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 + <p class="font-bold dark:text-white">{{ .Address }}</p> 104 + <div class="inline-flex items-center gap-1"> 105 + {{ if .Verified }} 106 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 107 + {{ else }} 108 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 109 + {{ end }} 110 + {{ if .Primary }} 111 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 112 + {{ end }} 113 + </div> 114 + </div> 115 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 + </div> 117 + <div class="flex gap-2 items-center"> 118 + {{ if not .Verified }} 119 + <button 120 + class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 + hx-post="/settings/emails/verify/resend" 122 + hx-swap="none" 123 + href="#" 124 + hx-vals='{"email": "{{ .Address }}"}'> 125 + {{ i "rotate-cw" "w-5 h-5" }} 126 + <span class="hidden md:inline">resend</span> 127 + </button> 128 + {{ end }} 129 + {{ if and (not .Primary) .Verified }} 130 + <a 131 + class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 + hx-post="/settings/emails/primary" 133 + hx-swap="none" 134 + href="#" 135 + hx-vals='{"email": "{{ .Address }}"}'> 136 + set as primary 137 + </a> 138 + {{ end }} 139 + {{ if not .Primary }} 140 + <form 141 + hx-delete="/settings/emails" 142 + hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 + hx-indicator="#delete-email-{{ $index }}-spinner" 144 + > 145 + <input type="hidden" name="email" value="{{ .Address }}"> 146 + <button 147 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 + title="Delete email" 149 + type="submit" 150 + > 151 + {{ i "trash-2" "w-5 h-5" }} 152 + <span class="hidden md:inline">delete</span> 153 + <span id="delete-email-{{ $index }}-spinner" class="group"> 154 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 + </span> 156 + </button> 157 + </form> 158 + {{ end }} 159 + </div> 160 + </div> 161 + {{ end }} 162 + </div> 163 + <form 164 + hx-put="/settings/emails" 165 + hx-swap="none" 166 + class="max-w-2xl mb-8 space-y-4" 167 + hx-indicator="#add-email-spinner" 168 + > 169 + <input 170 + type="email" 171 + id="email" 172 + name="email" 173 + placeholder="your@email.com" 174 + required 175 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 + > 177 + 178 + <button 179 + class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 + type="submit" 181 + > 182 + <span>add email</span> 183 + <span id="add-email-spinner" class="group"> 184 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 + </span> 186 + </button> 187 + 188 + <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 + <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 + </form> 191 + </section> 192 + {{ end }}
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 16 class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 - {{ block "addSpindleMemberPopover" . }} {{ end }} 17 + {{ block "addMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 20 20 21 - {{ define "addSpindleMemberPopover" }} 21 + {{ define "addMemberPopover" }} 22 22 <form 23 23 hx-post="/spindles/{{ .Instance }}/add" 24 24 hx-indicator="#spinner"
+9 -11
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 1 {{ define "spindles/fragments/spindleListing" }} 2 2 <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - {{ block "spindleLeftSide" . }} {{ end }} 4 - {{ block "spindleRightSide" . }} {{ end }} 3 + {{ block "leftSide" . }} {{ end }} 4 + {{ block "rightSide" . }} {{ end }} 5 5 </div> 6 6 {{ end }} 7 7 8 - {{ define "spindleLeftSide" }} 8 + {{ define "leftSide" }} 9 9 {{ if .Verified }} 10 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 - <span class="hover:underline"> 13 - {{ .Instance }} 14 - </span> 12 + {{ .Instance }} 15 13 <span class="text-gray-500"> 16 14 {{ template "repo/fragments/shortTimeAgo" .Created }} 17 15 </span> ··· 27 25 {{ end }} 28 26 {{ end }} 29 27 30 - {{ define "spindleRightSide" }} 28 + {{ define "rightSide" }} 31 29 <div id="right-side" class="flex gap-2"> 32 30 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 31 {{ if .Verified }} ··· 35 33 {{ template "spindles/fragments/addMemberModal" . }} 36 34 {{ else }} 37 35 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 38 - {{ block "spindleRetryButton" . }} {{ end }} 36 + {{ block "retryButton" . }} {{ end }} 39 37 {{ end }} 40 - {{ block "spindleDeleteButton" . }} {{ end }} 38 + {{ block "deleteButton" . }} {{ end }} 41 39 </div> 42 40 {{ end }} 43 41 44 - {{ define "spindleDeleteButton" }} 42 + {{ define "deleteButton" }} 45 43 <button 46 44 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 47 45 title="Delete spindle" ··· 57 55 {{ end }} 58 56 59 57 60 - {{ define "spindleRetryButton" }} 58 + {{ define "retryButton" }} 61 59 <button 62 60 class="btn gap-2 group" 63 61 title="Retry spindle verification"
+3 -3
appview/pages/templates/timeline/timeline.html
··· 171 171 {{ end }} 172 172 {{ end }} 173 173 {{ with $stat }} 174 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 175 175 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 176 + <span id="followers">{{ .Followers }} followers</span> 177 177 <span class="select-none after:content-['ยท']"></span> 178 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 178 + <span id="following">{{ .Following }} following</span> 179 179 </div> 180 180 {{ end }} 181 181 </div>
-30
appview/pages/templates/user/followers.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "followers" . }}{{ end }} 17 - </div> 18 - </div> 19 - {{ end }} 20 - 21 - {{ define "followers" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 23 - <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 24 - {{ range .Followers }} 25 - {{ template "user/fragments/followCard" . }} 26 - {{ else }} 27 - <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 28 - {{ end }} 29 - </div> 30 - {{ end }}
-30
appview/pages/templates/user/following.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "following" . }}{{ end }} 17 - </div> 18 - </div> 19 - {{ end }} 20 - 21 - {{ define "following" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 23 - <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 24 - {{ range .Following }} 25 - {{ template "user/fragments/followCard" . }} 26 - {{ else }} 27 - <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 28 - {{ end }} 29 - </div> 30 - {{ end }}
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 - <button id="{{ normalizeForHtmlId .UserDid }}" 2 + <button id="followBtn" 3 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 9 {{ end }} 10 10 11 11 hx-trigger="click" 12 - hx-target="#{{ normalizeForHtmlId .UserDid }}" 12 + hx-target="#followBtn" 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
-29
appview/pages/templates/user/fragments/followCard.html
··· 1 - {{ define "user/fragments/followCard" }} 2 - {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 - </div> 8 - 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['ยท']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 - </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 - {{ template "user/fragments/follow" . }} 25 - </div> 26 - {{ end }} 27 - </div> 28 - </div> 29 - {{ end }}
+14 -17
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 4 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 9 8 </div> 10 9 <div class="col-span-2"> 11 10 <div class="flex items-center flex-row flex-nowrap gap-2"> 12 - <p title="{{ $userIdent }}" 11 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 14 - {{ $userIdent }} 13 + {{ didOrHandle .UserDid .UserHandle }} 15 14 </p> 16 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 17 16 </div> 18 17 19 18 <div class="md:hidden"> 20 - {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 19 + {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 21 20 </div> 22 21 </div> 23 22 <div class="col-span-3 md:col-span-full"> ··· 30 29 {{ end }} 31 30 32 31 <div class="hidden md:block"> 33 - {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 32 + {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 34 33 </div> 35 34 36 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 43 42 {{ if .IncludeBluesky }} 44 43 <div class="flex items-center gap-2"> 45 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 46 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 45 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 47 46 </div> 48 47 {{ end }} 49 48 {{ range $link := .Links }} ··· 89 88 {{ end }} 90 89 91 90 {{ define "followerFollowing" }} 92 - {{ $root := index . 0 }} 93 - {{ $userIdent := index . 1 }} 94 - {{ with $root }} 95 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 98 - <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 100 - </div> 101 - {{ end }} 91 + {{ $followers := index . 0 }} 92 + {{ $following := index . 1 }} 93 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers">{{ $followers }} followers</span> 96 + <span class="select-none after:content-['ยท']"></span> 97 + <span id="following">{{ $following }} following</span> 98 + </div> 102 99 {{ end }} 103 100
+1 -1
appview/pages/templates/user/repos.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
-94
appview/pages/templates/user/settings/emails.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "emailSettings" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "emailSettings" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Commits authored using emails listed here will be associated with your Tangled profile. 25 - </p> 26 - </div> 27 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 - {{ template "addEmailButton" . }} 29 - </div> 30 - </div> 31 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 - {{ range .Emails }} 33 - {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 - {{ else }} 35 - <div class="flex items-center justify-center p-2 text-gray-500"> 36 - no emails added yet 37 - </div> 38 - {{ end }} 39 - </div> 40 - {{ end }} 41 - 42 - {{ define "addEmailButton" }} 43 - <button 44 - class="btn flex items-center gap-2" 45 - popovertarget="add-email-modal" 46 - popovertargetaction="toggle"> 47 - {{ i "plus" "size-4" }} 48 - add email 49 - </button> 50 - <div 51 - id="add-email-modal" 52 - popover 53 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 - {{ template "addEmailModal" . }} 55 - </div> 56 - {{ end}} 57 - 58 - {{ define "addEmailModal" }} 59 - <form 60 - hx-put="/settings/emails" 61 - hx-indicator="#spinner" 62 - hx-swap="none" 63 - class="flex flex-col gap-2" 64 - > 65 - <p class="uppercase p-0">ADD EMAIL</p> 66 - <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 - <input 68 - type="email" 69 - id="email-address" 70 - name="email" 71 - required 72 - placeholder="your@email.com" 73 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 - /> 75 - <div class="flex gap-2 pt-2"> 76 - <button 77 - type="button" 78 - popovertarget="add-email-modal" 79 - popovertargetaction="hide" 80 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 - > 82 - {{ i "x" "size-4" }} cancel 83 - </button> 84 - <button type="submit" class="btn w-1/2 flex items-center"> 85 - <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 - <span id="spinner" class="group"> 87 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 - </span> 89 - </button> 90 - </div> 91 - <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 - <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 - </form> 94 - {{ end }}
-62
appview/pages/templates/user/settings/fragments/emailListing.html
··· 1 - {{ define "user/settings/fragments/emailListing" }} 2 - {{ $root := index . 0 }} 3 - {{ $email := index . 1 }} 4 - <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 - <div class="flex items-center gap-2"> 7 - {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 - <span class="font-bold"> 9 - {{ $email.Address }} 10 - </span> 11 - <div class="inline-flex items-center gap-1"> 12 - {{ if $email.Verified }} 13 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 - {{ else }} 15 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 - {{ end }} 17 - {{ if $email.Primary }} 18 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 - {{ end }} 20 - </div> 21 - </div> 22 - <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 - <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 - </div> 25 - </div> 26 - <div class="flex gap-2 items-center"> 27 - {{ if not $email.Verified }} 28 - <button 29 - class="btn flex gap-2 text-sm px-2 py-1" 30 - hx-post="/settings/emails/verify/resend" 31 - hx-swap="none" 32 - hx-vals='{"email": "{{ $email.Address }}"}'> 33 - {{ i "rotate-cw" "w-4 h-4" }} 34 - <span class="hidden md:inline">resend</span> 35 - </button> 36 - {{ end }} 37 - {{ if and (not $email.Primary) $email.Verified }} 38 - <button 39 - class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 - hx-post="/settings/emails/primary" 41 - hx-swap="none" 42 - hx-vals='{"email": "{{ $email.Address }}"}'> 43 - set as primary 44 - </button> 45 - {{ end }} 46 - {{ if not $email.Primary }} 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete email" 50 - hx-delete="/settings/emails" 51 - hx-swap="none" 52 - hx-vals='{"email": "{{ $email.Address }}"}' 53 - hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 - > 55 - {{ i "trash-2" "w-5 h-5" }} 56 - <span class="hidden md:inline">delete</span> 57 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 - </button> 59 - {{ end }} 60 - </div> 61 - </div> 62 - {{ end }}
-31
appview/pages/templates/user/settings/fragments/keyListing.html
··· 1 - {{ define "user/settings/fragments/keyListing" }} 2 - {{ $root := index . 0 }} 3 - {{ $key := index . 1 }} 4 - <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 - <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 - <div class="flex items-center gap-2"> 7 - <span>{{ i "key" "w-4" "h-4" }}</span> 8 - <span class="font-bold"> 9 - {{ $key.Name }} 10 - </span> 11 - </div> 12 - <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 - {{ sshFingerprint $key.Key }} 14 - </span> 15 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 - <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 - </div> 18 - </div> 19 - <button 20 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 - title="Delete key" 22 - hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 - hx-swap="none" 24 - hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 - > 26 - {{ i "trash-2" "w-5 h-5" }} 27 - <span class="hidden md:inline">delete</span> 28 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 - </button> 30 - </div> 31 - {{ end }}
-16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 - {{ define "user/settings/fragments/sidebar" }} 2 - {{ $active := .Tab }} 3 - {{ $tabs := .Tabs }} 4 - <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 - {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 - {{ range $tabs }} 8 - <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 - <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 - {{ i .Icon "size-4" }} 11 - {{ .Name }} 12 - </div> 13 - </a> 14 - {{ end }} 15 - </div> 16 - {{ end }}
-101
appview/pages/templates/user/settings/keys.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "sshKeysSettings" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "sshKeysSettings" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - SSH public keys added here will be broadcasted to knots that you are a member of, 25 - allowing you to push to repositories there. 26 - </p> 27 - </div> 28 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 - {{ template "addKeyButton" . }} 30 - </div> 31 - </div> 32 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 - {{ range .PubKeys }} 34 - {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 - {{ else }} 36 - <div class="flex items-center justify-center p-2 text-gray-500"> 37 - no keys added yet 38 - </div> 39 - {{ end }} 40 - </div> 41 - {{ end }} 42 - 43 - {{ define "addKeyButton" }} 44 - <button 45 - class="btn flex items-center gap-2" 46 - popovertarget="add-key-modal" 47 - popovertargetaction="toggle"> 48 - {{ i "plus" "size-4" }} 49 - add key 50 - </button> 51 - <div 52 - id="add-key-modal" 53 - popover 54 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 - {{ template "addKeyModal" . }} 56 - </div> 57 - {{ end}} 58 - 59 - {{ define "addKeyModal" }} 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#spinner" 63 - hx-swap="none" 64 - class="flex flex-col gap-2" 65 - > 66 - <p class="uppercase p-0">ADD SSH KEY</p> 67 - <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 - <input 69 - type="text" 70 - id="key-name" 71 - name="name" 72 - required 73 - placeholder="key name" 74 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 - /> 76 - <textarea 77 - type="text" 78 - id="key-value" 79 - name="key" 80 - required 81 - placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 - <div class="flex gap-2 pt-2"> 84 - <button 85 - type="button" 86 - popovertarget="add-key-modal" 87 - popovertargetaction="hide" 88 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 - > 90 - {{ i "x" "size-4" }} cancel 91 - </button> 92 - <button type="submit" class="btn w-1/2 flex items-center"> 93 - <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 - <span id="spinner" class="group"> 95 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 - </span> 97 - </button> 98 - </div> 99 - <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 - </form> 101 - {{ end }}
-64
appview/pages/templates/user/settings/profile.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "profileInfo" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "profileInfo" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Your account information from your AT Protocol identity. 25 - </p> 26 - </div> 27 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 - </div> 29 - </div> 30 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 - <div class="flex items-center justify-between p-4"> 32 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 - <span>Handle</span> 35 - </div> 36 - {{ if .LoggedInUser.Handle }} 37 - <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 39 - </span> 40 - {{ end }} 41 - </div> 42 - </div> 43 - <div class="flex items-center justify-between p-4"> 44 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 46 - <span>Decentralized Identifier (DID)</span> 47 - </div> 48 - <span class="font-mono font-bold"> 49 - {{ .LoggedInUser.Did }} 50 - </span> 51 - </div> 52 - </div> 53 - <div class="flex items-center justify-between p-4"> 54 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 56 - <span>Personal Data Server (PDS)</span> 57 - </div> 58 - <span class="font-bold"> 59 - {{ .LoggedInUser.Pds }} 60 - </span> 61 - </div> 62 - </div> 63 - </div> 64 - {{ end }}
+92 -113
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 8 + "io" 7 9 "log" 8 10 "net/http" 9 11 "sort" ··· 19 21 "tangled.sh/tangled.sh/core/appview/pages" 20 22 "tangled.sh/tangled.sh/core/appview/pages/markup" 21 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 24 "tangled.sh/tangled.sh/core/idresolver" 24 25 "tangled.sh/tangled.sh/core/knotclient" 25 26 "tangled.sh/tangled.sh/core/patchutil" ··· 29 30 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 32 lexutil "github.com/bluesky-social/indigo/lex/util" 32 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 33 33 "github.com/go-chi/chi/v5" 34 34 "github.com/google/uuid" 35 35 ) ··· 96 96 return 97 97 } 98 98 99 - mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 99 + mergeCheckResponse := s.mergeCheck(f, pull, stack) 100 100 resubmitResult := pages.Unknown 101 101 if user.Did == pull.OwnerDid { 102 102 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 151 151 } 152 152 } 153 153 154 - mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(f, pull, stack) 155 155 resubmitResult := pages.Unknown 156 156 if user != nil && user.Did == pull.OwnerDid { 157 157 resubmitResult = s.resubmitCheck(f, pull, stack) ··· 215 215 }) 216 216 } 217 217 218 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 218 + func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 219 if pull.State == db.PullMerged { 220 220 return types.MergeCheckResponse{} 221 221 } 222 222 223 - scheme := "https" 224 - if s.config.Core.Dev { 225 - scheme = "http" 223 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 224 + if err != nil { 225 + log.Printf("failed to get registration key: %v", err) 226 + return types.MergeCheckResponse{ 227 + Error: "failed to check merge status: this knot is unregistered", 228 + } 226 229 } 227 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 228 230 229 - xrpcc := indigoxrpc.Client{ 230 - Host: host, 231 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 232 + if err != nil { 233 + log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 234 + return types.MergeCheckResponse{ 235 + Error: "failed to check merge status", 236 + } 231 237 } 232 238 233 239 patch := pull.LatestPatch() ··· 240 246 patch = mergeable.CombinedPatch() 241 247 } 242 248 243 - resp, xe := tangled.RepoMergeCheck( 244 - r.Context(), 245 - &xrpcc, 246 - &tangled.RepoMergeCheck_Input{ 247 - Did: f.OwnerDid(), 248 - Name: f.Name, 249 - Branch: pull.TargetBranch, 250 - Patch: patch, 251 - }, 252 - ) 253 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 - log.Println("failed to check for mergeability", "err", err) 249 + resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch) 250 + if err != nil { 251 + log.Println("failed to check for mergeability:", err) 255 252 return types.MergeCheckResponse{ 256 - Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 253 + Error: "failed to check merge status", 257 254 } 258 255 } 259 - 260 - // convert xrpc response to internal types 261 - conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 - for i, conflict := range resp.Conflicts { 263 - conflicts[i] = types.ConflictInfo{ 264 - Filename: conflict.Filename, 265 - Reason: conflict.Reason, 256 + switch resp.StatusCode { 257 + case 404: 258 + return types.MergeCheckResponse{ 259 + Error: "failed to check merge status: this knot does not support PRs", 266 260 } 267 - } 268 - 269 - result := types.MergeCheckResponse{ 270 - IsConflicted: resp.Is_conflicted, 271 - Conflicts: conflicts, 261 + case 400: 262 + return types.MergeCheckResponse{ 263 + Error: "failed to check merge status: does this knot support PRs?", 264 + } 272 265 } 273 266 274 - if resp.Message != nil { 275 - result.Message = *resp.Message 267 + respBody, err := io.ReadAll(resp.Body) 268 + if err != nil { 269 + log.Println("failed to read merge check response body") 270 + return types.MergeCheckResponse{ 271 + Error: "failed to check merge status: knot is not speaking the right language", 272 + } 276 273 } 274 + defer resp.Body.Close() 277 275 278 - if resp.Error != nil { 279 - result.Error = *resp.Error 276 + var mergeCheckResponse types.MergeCheckResponse 277 + err = json.Unmarshal(respBody, &mergeCheckResponse) 278 + if err != nil { 279 + log.Println("failed to unmarshal merge check response", err) 280 + return types.MergeCheckResponse{ 281 + Error: "failed to check merge status: knot is not speaking the right language", 282 + } 280 283 } 281 284 282 - return result 285 + return mergeCheckResponse 283 286 } 284 287 285 288 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 864 867 return 865 868 } 866 869 867 - client, err := s.oauth.ServiceClient( 868 - r, 869 - oauth.WithService(fork.Knot), 870 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 871 - oauth.WithDev(s.config.Core.Dev), 872 - ) 870 + secret, err := db.GetRegistrationKey(s.db, fork.Knot) 873 871 if err != nil { 874 - log.Printf("failed to connect to knot server: %v", err) 872 + log.Println("failed to fetch registration key:", err) 873 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 874 + return 875 + } 876 + 877 + sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 878 + if err != nil { 879 + log.Println("failed to create signed client:", err) 875 880 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 876 881 return 877 882 } ··· 883 888 return 884 889 } 885 890 886 - resp, err := tangled.RepoHiddenRef( 887 - r.Context(), 888 - client, 889 - &tangled.RepoHiddenRef_Input{ 890 - ForkRef: sourceBranch, 891 - RemoteRef: targetBranch, 892 - Repo: fork.RepoAt().String(), 893 - }, 894 - ) 895 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 896 - s.pages.Notice(w, "pull", err.Error()) 891 + resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 892 + if err != nil { 893 + log.Println("failed to create hidden ref:", err, resp.StatusCode) 894 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 897 895 return 898 896 } 899 897 900 - if !resp.Success { 901 - errorMsg := "Failed to create pull request" 902 - if resp.Error != nil { 903 - errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 904 - } 905 - s.pages.Notice(w, "pull", errorMsg) 898 + switch resp.StatusCode { 899 + case 404: 900 + case 400: 901 + s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 906 902 return 907 903 } 908 904 ··· 1468 1464 return 1469 1465 } 1470 1466 1471 - // update the hidden tracking branch to latest 1472 - client, err := s.oauth.ServiceClient( 1473 - r, 1474 - oauth.WithService(forkRepo.Knot), 1475 - oauth.WithLxm(tangled.RepoHiddenRefNSID), 1476 - oauth.WithDev(s.config.Core.Dev), 1477 - ) 1467 + secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1478 1468 if err != nil { 1479 - log.Printf("failed to connect to knot server: %v", err) 1469 + log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1470 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1480 1471 return 1481 1472 } 1482 1473 1483 - resp, err := tangled.RepoHiddenRef( 1484 - r.Context(), 1485 - client, 1486 - &tangled.RepoHiddenRef_Input{ 1487 - ForkRef: pull.PullSource.Branch, 1488 - RemoteRef: pull.TargetBranch, 1489 - Repo: forkRepo.RepoAt().String(), 1490 - }, 1491 - ) 1492 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 1493 - s.pages.Notice(w, "resubmit-error", err.Error()) 1474 + // update the hidden tracking branch to latest 1475 + signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1476 + if err != nil { 1477 + log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1478 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1494 1479 return 1495 1480 } 1496 - if !resp.Success { 1497 - log.Println("Failed to update tracking ref.", "err", resp.Error) 1498 - s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1481 + 1482 + resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1483 + if err != nil || resp.StatusCode != http.StatusNoContent { 1484 + log.Printf("failed to update tracking branch: %s", err) 1485 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1499 1486 return 1500 1487 } 1501 1488 ··· 1921 1908 1922 1909 patch := pullsToMerge.CombinedPatch() 1923 1910 1911 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 1912 + if err != nil { 1913 + log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1914 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1915 + return 1916 + } 1917 + 1924 1918 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1925 1919 if err != nil { 1926 1920 log.Printf("resolving identity: %s", err) ··· 1933 1927 log.Printf("failed to get primary email: %s", err) 1934 1928 } 1935 1929 1936 - authorName := ident.Handle.String() 1937 - mergeInput := &tangled.RepoMerge_Input{ 1938 - Did: f.OwnerDid(), 1939 - Name: f.Name, 1940 - Branch: pull.TargetBranch, 1941 - Patch: patch, 1942 - CommitMessage: &pull.Title, 1943 - AuthorName: &authorName, 1944 - } 1945 - 1946 - if pull.Body != "" { 1947 - mergeInput.CommitBody = &pull.Body 1948 - } 1949 - 1950 - if email.Address != "" { 1951 - mergeInput.AuthorEmail = &email.Address 1930 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1931 + if err != nil { 1932 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1933 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1934 + return 1952 1935 } 1953 1936 1954 - client, err := s.oauth.ServiceClient( 1955 - r, 1956 - oauth.WithService(f.Knot), 1957 - oauth.WithLxm(tangled.RepoMergeNSID), 1958 - oauth.WithDev(s.config.Core.Dev), 1959 - ) 1937 + // Merge the pull request 1938 + resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1960 1939 if err != nil { 1961 - log.Printf("failed to connect to knot server: %v", err) 1940 + log.Printf("failed to merge pull request: %s", err) 1962 1941 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1963 1942 return 1964 1943 } 1965 1944 1966 - err = tangled.RepoMerge(r.Context(), client, mergeInput) 1967 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 1968 - s.pages.Notice(w, "pull-merge-error", err.Error()) 1945 + if resp.StatusCode != http.StatusOK { 1946 + log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1947 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1969 1948 return 1970 1949 } 1971 1950
+100 -10
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "encoding/json" 5 + "fmt" 4 6 "log" 5 7 "net/http" 6 8 "slices" ··· 9 11 10 12 "tangled.sh/tangled.sh/core/appview/commitverify" 11 13 "tangled.sh/tangled.sh/core/appview/db" 14 + "tangled.sh/tangled.sh/core/appview/oauth" 12 15 "tangled.sh/tangled.sh/core/appview/pages" 16 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 13 17 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 18 "tangled.sh/tangled.sh/core/knotclient" 15 19 "tangled.sh/tangled.sh/core/types" ··· 101 105 user := rp.oauth.GetUser(r) 102 106 repoInfo := f.RepoInfo(user) 103 107 108 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 109 + if err != nil { 110 + log.Printf("failed to get registration key for %s: %s", f.Knot, err) 111 + rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 112 + } 113 + 114 + signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 115 + if err != nil { 116 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 117 + return 118 + } 119 + 120 + var forkInfo *types.ForkInfo 121 + if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 122 + forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient) 123 + if err != nil { 124 + log.Printf("Failed to fetch fork information: %v", err) 125 + return 126 + } 127 + } 128 + 104 129 // TODO: a bit dirty 105 - languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 130 + languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "") 106 131 if err != nil { 107 132 log.Printf("failed to compute language percentages: %s", err) 108 133 // non-fatal ··· 119 144 } 120 145 121 146 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 122 - LoggedInUser: user, 123 - RepoInfo: repoInfo, 124 - TagMap: tagMap, 125 - RepoIndexResponse: *result, 126 - CommitsTrunc: commitsTrunc, 127 - TagsTrunc: tagsTrunc, 128 - // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 147 + LoggedInUser: user, 148 + RepoInfo: repoInfo, 149 + TagMap: tagMap, 150 + RepoIndexResponse: *result, 151 + CommitsTrunc: commitsTrunc, 152 + TagsTrunc: tagsTrunc, 153 + ForkInfo: forkInfo, 129 154 BranchesTrunc: branchesTrunc, 130 155 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 131 156 VerifiedCommits: vc, ··· 136 161 137 162 func (rp *Repo) getLanguageInfo( 138 163 f *reporesolver.ResolvedRepo, 139 - us *knotclient.UnsignedClient, 164 + signedClient *knotclient.SignedClient, 140 165 currentRef string, 141 166 isDefaultRef bool, 142 167 ) ([]types.RepoLanguageDetails, error) { ··· 149 174 150 175 if err != nil || langs == nil { 151 176 // non-fatal, fetch langs from ks 152 - ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 177 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 153 178 if err != nil { 154 179 return nil, err 155 180 } ··· 206 231 207 232 return languageStats, nil 208 233 } 234 + 235 + func getForkInfo( 236 + repoInfo repoinfo.RepoInfo, 237 + rp *Repo, 238 + f *reporesolver.ResolvedRepo, 239 + currentRef string, 240 + user *oauth.User, 241 + signedClient *knotclient.SignedClient, 242 + ) (*types.ForkInfo, error) { 243 + if user == nil { 244 + return nil, nil 245 + } 246 + 247 + forkInfo := types.ForkInfo{ 248 + IsFork: repoInfo.Source != nil, 249 + Status: types.UpToDate, 250 + } 251 + 252 + if !forkInfo.IsFork { 253 + forkInfo.IsFork = false 254 + return &forkInfo, nil 255 + } 256 + 257 + us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 258 + if err != nil { 259 + log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 260 + return nil, err 261 + } 262 + 263 + result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 264 + if err != nil { 265 + log.Println("failed to reach knotserver", err) 266 + return nil, err 267 + } 268 + 269 + if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 270 + return branch.Name == currentRef 271 + }) { 272 + forkInfo.Status = types.MissingBranch 273 + return &forkInfo, nil 274 + } 275 + 276 + newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef) 277 + if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 278 + log.Printf("failed to update tracking branch: %s", err) 279 + return nil, err 280 + } 281 + 282 + hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef) 283 + 284 + var status types.AncestorCheckResponse 285 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef) 286 + if err != nil { 287 + log.Printf("failed to check if fork is ahead/behind: %s", err) 288 + return nil, err 289 + } 290 + 291 + if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 292 + log.Printf("failed to decode fork status: %s", err) 293 + return nil, err 294 + } 295 + 296 + forkInfo.Status = status.Status 297 + return &forkInfo, nil 298 + }
+203 -221
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 20 "tangled.sh/tangled.sh/core/api/tangled" 23 21 "tangled.sh/tangled.sh/core/appview/commitverify" 24 22 "tangled.sh/tangled.sh/core/appview/config" ··· 28 26 "tangled.sh/tangled.sh/core/appview/pages" 29 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 30 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 29 "tangled.sh/tangled.sh/core/eventconsumer" 33 30 "tangled.sh/tangled.sh/core/idresolver" 34 31 "tangled.sh/tangled.sh/core/knotclient" ··· 36 33 "tangled.sh/tangled.sh/core/rbac" 37 34 "tangled.sh/tangled.sh/core/tid" 38 35 "tangled.sh/tangled.sh/core/types" 39 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 40 36 41 37 securejoin "github.com/cyphar/filepath-securejoin" 42 38 "github.com/go-chi/chi/v5" 43 39 "github.com/go-git/go-git/v5/plumbing" 44 40 41 + comatproto "github.com/bluesky-social/indigo/api/atproto" 45 42 "github.com/bluesky-social/indigo/atproto/syntax" 43 + lexutil "github.com/bluesky-social/indigo/lex/util" 46 44 ) 47 45 48 46 type Repo struct { ··· 56 54 enforcer *rbac.Enforcer 57 55 notifier notify.Notifier 58 56 logger *slog.Logger 59 - serviceAuth *serviceauth.ServiceAuth 60 57 } 61 58 62 59 func New( ··· 128 125 129 126 repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 127 if err != nil { 131 - rp.pages.Error503(w) 132 128 log.Println("failed to reach knotserver", err) 133 129 return 134 130 } 135 131 136 132 tagResult, err := us.Tags(f.OwnerDid(), f.Name) 137 133 if err != nil { 138 - rp.pages.Error503(w) 139 134 log.Println("failed to reach knotserver", err) 140 135 return 141 136 } ··· 151 146 152 147 branchResult, err := us.Branches(f.OwnerDid(), f.Name) 153 148 if err != nil { 154 - rp.pages.Error503(w) 155 149 log.Println("failed to reach knotserver", err) 156 150 return 157 151 } ··· 318 312 319 313 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 320 314 if err != nil { 321 - rp.pages.Error503(w) 322 315 log.Println("failed to reach knotserver", err) 323 316 return 324 317 } ··· 382 375 if !rp.config.Core.Dev { 383 376 protocol = "https" 384 377 } 385 - 386 - // if the tree path has a trailing slash, let's strip it 387 - // so we don't 404 388 - treePath = strings.TrimSuffix(treePath, "/") 389 - 390 378 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 391 379 if err != nil { 392 - rp.pages.Error503(w) 393 380 log.Println("failed to reach knotserver", err) 394 381 return 395 382 } 396 383 397 - // uhhh so knotserver returns a 500 if the entry isn't found in 398 - // the requested tree path, so let's stick to not-OK here. 399 - // we can fix this once we build out the xrpc apis for these operations. 400 - if resp.StatusCode != http.StatusOK { 401 - rp.pages.Error404(w) 402 - return 403 - } 404 - 405 384 body, err := io.ReadAll(resp.Body) 406 385 if err != nil { 407 386 log.Printf("Error reading response body: %v", err) ··· 459 438 460 439 result, err := us.Tags(f.OwnerDid(), f.Name) 461 440 if err != nil { 462 - rp.pages.Error503(w) 463 441 log.Println("failed to reach knotserver", err) 464 442 return 465 443 } ··· 517 495 518 496 result, err := us.Branches(f.OwnerDid(), f.Name) 519 497 if err != nil { 520 - rp.pages.Error503(w) 521 498 log.Println("failed to reach knotserver", err) 522 499 return 523 500 } ··· 547 524 } 548 525 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 549 526 if err != nil { 550 - rp.pages.Error503(w) 551 527 log.Println("failed to reach knotserver", err) 552 - return 553 - } 554 - 555 - if resp.StatusCode == http.StatusNotFound { 556 - rp.pages.Error404(w) 557 528 return 558 529 } 559 530 ··· 863 834 fail("Failed to write record to PDS.", err) 864 835 return 865 836 } 866 - 867 - aturi := resp.Uri 868 - l = l.With("at-uri", aturi) 837 + l = l.With("at-uri", resp.Uri) 869 838 l.Info("wrote record to PDS") 870 839 871 - tx, err := rp.db.BeginTx(r.Context(), nil) 840 + l.Info("adding to knot") 841 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 872 842 if err != nil { 873 - fail("Failed to add collaborator.", err) 843 + fail("Failed to add to knot.", err) 874 844 return 875 845 } 876 846 877 - rollback := func() { 878 - err1 := tx.Rollback() 879 - err2 := rp.enforcer.E.LoadPolicy() 880 - err3 := rollbackRecord(context.Background(), aturi, client) 847 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 848 + if err != nil { 849 + fail("Failed to add to knot.", err) 850 + return 851 + } 881 852 882 - // ignore txn complete errors, this is okay 883 - if errors.Is(err1, sql.ErrTxDone) { 884 - err1 = nil 885 - } 853 + ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String()) 854 + if err != nil { 855 + fail("Knot was unreachable.", err) 856 + return 857 + } 886 858 887 - if errs := errors.Join(err1, err2, err3); errs != nil { 888 - l.Error("failed to rollback changes", "errs", errs) 889 - return 859 + if ksResp.StatusCode != http.StatusNoContent { 860 + fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 861 + return 862 + } 863 + 864 + tx, err := rp.db.BeginTx(r.Context(), nil) 865 + if err != nil { 866 + fail("Failed to add collaborator.", err) 867 + return 868 + } 869 + defer func() { 870 + tx.Rollback() 871 + err = rp.enforcer.E.LoadPolicy() 872 + if err != nil { 873 + fail("Failed to add collaborator.", err) 890 874 } 891 - } 892 - defer rollback() 875 + }() 893 876 894 877 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 895 878 if err != nil { ··· 921 904 return 922 905 } 923 906 924 - // clear aturi to when everything is successful 925 - aturi = "" 926 - 927 907 rp.pages.HxRefresh(w) 928 908 } 929 909 930 910 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 931 911 user := rp.oauth.GetUser(r) 932 912 933 - noticeId := "operation-error" 934 913 f, err := rp.repoResolver.Resolve(r) 935 914 if err != nil { 936 915 log.Println("failed to get repo and knot", err) ··· 950 929 }) 951 930 if err != nil { 952 931 log.Printf("failed to delete record: %s", err) 953 - rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 932 + rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 954 933 return 955 934 } 956 935 log.Println("removed repo record ", f.RepoAt().String()) 957 936 958 - client, err := rp.oauth.ServiceClient( 959 - r, 960 - oauth.WithService(f.Knot), 961 - oauth.WithLxm(tangled.RepoDeleteNSID), 962 - oauth.WithDev(rp.config.Core.Dev), 963 - ) 937 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 964 938 if err != nil { 965 - log.Println("failed to connect to knot server:", err) 939 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 966 940 return 967 941 } 968 942 969 - err = tangled.RepoDelete( 970 - r.Context(), 971 - client, 972 - &tangled.RepoDelete_Input{ 973 - Did: f.OwnerDid(), 974 - Name: f.Name, 975 - Rkey: f.Rkey, 976 - }, 977 - ) 978 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 979 - rp.pages.Notice(w, noticeId, err.Error()) 943 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 944 + if err != nil { 945 + log.Println("failed to create client to ", f.Knot) 980 946 return 981 947 } 982 - log.Println("deleted repo from knot") 948 + 949 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name) 950 + if err != nil { 951 + log.Printf("failed to make request to %s: %s", f.Knot, err) 952 + return 953 + } 954 + 955 + if ksResp.StatusCode != http.StatusNoContent { 956 + log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 957 + } else { 958 + log.Println("removed repo from knot ", f.Knot) 959 + } 983 960 984 961 tx, err := rp.db.BeginTx(r.Context(), nil) 985 962 if err != nil { ··· 998 975 // remove collaborator RBAC 999 976 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1000 977 if err != nil { 1001 - rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 978 + rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1002 979 return 1003 980 } 1004 981 for _, c := range repoCollaborators { ··· 1010 987 // remove repo RBAC 1011 988 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 1012 989 if err != nil { 1013 - rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 990 + rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1014 991 return 1015 992 } 1016 993 1017 994 // remove repo from db 1018 995 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 1019 996 if err != nil { 1020 - rp.pages.Notice(w, noticeId, "Failed to update appview") 997 + rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1021 998 return 1022 999 } 1023 1000 log.Println("removed repo from db") ··· 1046 1023 return 1047 1024 } 1048 1025 1049 - noticeId := "operation-error" 1050 1026 branch := r.FormValue("branch") 1051 1027 if branch == "" { 1052 1028 http.Error(w, "malformed form", http.StatusBadRequest) 1053 1029 return 1054 1030 } 1055 1031 1056 - client, err := rp.oauth.ServiceClient( 1057 - r, 1058 - oauth.WithService(f.Knot), 1059 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1060 - oauth.WithDev(rp.config.Core.Dev), 1061 - ) 1032 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1062 1033 if err != nil { 1063 - log.Println("failed to connect to knot server:", err) 1064 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1034 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1065 1035 return 1066 1036 } 1067 1037 1068 - xe := tangled.RepoSetDefaultBranch( 1069 - r.Context(), 1070 - client, 1071 - &tangled.RepoSetDefaultBranch_Input{ 1072 - Repo: f.RepoAt().String(), 1073 - DefaultBranch: branch, 1074 - }, 1075 - ) 1076 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1077 - log.Println("xrpc failed", "err", xe) 1078 - rp.pages.Notice(w, noticeId, err.Error()) 1038 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1039 + if err != nil { 1040 + log.Println("failed to create client to ", f.Knot) 1079 1041 return 1080 1042 } 1081 1043 1082 - rp.pages.HxRefresh(w) 1044 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 1045 + if err != nil { 1046 + log.Printf("failed to make request to %s: %s", f.Knot, err) 1047 + return 1048 + } 1049 + 1050 + if ksResp.StatusCode != http.StatusNoContent { 1051 + rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1052 + return 1053 + } 1054 + 1055 + w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1083 1056 } 1084 1057 1085 1058 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1195 1168 case "pipelines": 1196 1169 rp.pipelineSettings(w, r) 1197 1170 } 1171 + 1172 + // user := rp.oauth.GetUser(r) 1173 + // repoCollaborators, err := f.Collaborators(r.Context()) 1174 + // if err != nil { 1175 + // log.Println("failed to get collaborators", err) 1176 + // } 1177 + 1178 + // isCollaboratorInviteAllowed := false 1179 + // if user != nil { 1180 + // ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1181 + // if err == nil && ok { 1182 + // isCollaboratorInviteAllowed = true 1183 + // } 1184 + // } 1185 + 1186 + // us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1187 + // if err != nil { 1188 + // log.Println("failed to create unsigned client", err) 1189 + // return 1190 + // } 1191 + 1192 + // result, err := us.Branches(f.OwnerDid(), f.Name) 1193 + // if err != nil { 1194 + // log.Println("failed to reach knotserver", err) 1195 + // return 1196 + // } 1197 + 1198 + // // all spindles that this user is a member of 1199 + // spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1200 + // if err != nil { 1201 + // log.Println("failed to fetch spindles", err) 1202 + // return 1203 + // } 1204 + 1205 + // var secrets []*tangled.RepoListSecrets_Secret 1206 + // if f.Spindle != "" { 1207 + // if spindleClient, err := rp.oauth.ServiceClient( 1208 + // r, 1209 + // oauth.WithService(f.Spindle), 1210 + // oauth.WithLxm(tangled.RepoListSecretsNSID), 1211 + // oauth.WithDev(rp.config.Core.Dev), 1212 + // ); err != nil { 1213 + // log.Println("failed to create spindle client", err) 1214 + // } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1215 + // log.Println("failed to fetch secrets", err) 1216 + // } else { 1217 + // secrets = resp.Secrets 1218 + // } 1219 + // } 1220 + 1221 + // rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 1222 + // LoggedInUser: user, 1223 + // RepoInfo: f.RepoInfo(user), 1224 + // Collaborators: repoCollaborators, 1225 + // IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1226 + // Branches: result.Branches, 1227 + // Spindles: spindles, 1228 + // CurrentSpindle: f.Spindle, 1229 + // Secrets: secrets, 1230 + // }) 1198 1231 } 1199 1232 1200 1233 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1209 1242 1210 1243 result, err := us.Branches(f.OwnerDid(), f.Name) 1211 1244 if err != nil { 1212 - rp.pages.Error503(w) 1213 1245 log.Println("failed to reach knotserver", err) 1214 1246 return 1215 1247 } ··· 1314 1346 1315 1347 switch r.Method { 1316 1348 case http.MethodPost: 1317 - client, err := rp.oauth.ServiceClient( 1318 - r, 1319 - oauth.WithService(f.Knot), 1320 - oauth.WithLxm(tangled.RepoForkSyncNSID), 1321 - oauth.WithDev(rp.config.Core.Dev), 1322 - ) 1349 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1323 1350 if err != nil { 1324 - rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1351 + rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1325 1352 return 1326 1353 } 1327 1354 1328 - repoInfo := f.RepoInfo(user) 1329 - if repoInfo.Source == nil { 1330 - rp.pages.Notice(w, "repo", "This repository is not a fork.") 1355 + client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1356 + if err != nil { 1357 + rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1331 1358 return 1332 1359 } 1333 1360 1334 - err = tangled.RepoForkSync( 1335 - r.Context(), 1336 - client, 1337 - &tangled.RepoForkSync_Input{ 1338 - Did: user.Did, 1339 - Name: f.Name, 1340 - Source: repoInfo.Source.RepoAt().String(), 1341 - Branch: ref, 1342 - }, 1343 - ) 1344 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 1345 - rp.pages.Notice(w, "repo", err.Error()) 1361 + var uri string 1362 + if rp.config.Core.Dev { 1363 + uri = "http" 1364 + } else { 1365 + uri = "https" 1366 + } 1367 + forkName := fmt.Sprintf("%s", f.Name) 1368 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1369 + 1370 + _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref) 1371 + if err != nil { 1372 + rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1346 1373 return 1347 1374 } 1348 1375 ··· 1375 1402 }) 1376 1403 1377 1404 case http.MethodPost: 1378 - l := rp.logger.With("handler", "ForkRepo") 1379 1405 1380 - targetKnot := r.FormValue("knot") 1381 - if targetKnot == "" { 1406 + knot := r.FormValue("knot") 1407 + if knot == "" { 1382 1408 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1383 1409 return 1384 1410 } 1385 - l = l.With("targetKnot", targetKnot) 1386 1411 1387 - ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1412 + ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1388 1413 if err != nil || !ok { 1389 1414 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1390 1415 return 1391 1416 } 1392 1417 1393 - // choose a name for a fork 1394 - forkName := f.Name 1418 + forkName := fmt.Sprintf("%s", f.Name) 1419 + 1395 1420 // this check is *only* to see if the forked repo name already exists 1396 1421 // in the user's account. 1397 1422 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) ··· 1407 1432 // repo with this name already exists, append random string 1408 1433 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1409 1434 } 1410 - l = l.With("forkName", forkName) 1435 + secret, err := db.GetRegistrationKey(rp.db, knot) 1436 + if err != nil { 1437 + rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1438 + return 1439 + } 1440 + 1441 + client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1442 + if err != nil { 1443 + rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1444 + return 1445 + } 1411 1446 1412 - uri := "https" 1447 + var uri string 1413 1448 if rp.config.Core.Dev { 1414 1449 uri = "http" 1450 + } else { 1451 + uri = "https" 1415 1452 } 1416 - 1417 1453 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1418 - l = l.With("cloneUrl", forkSourceUrl) 1419 - 1420 1454 sourceAt := f.RepoAt().String() 1421 1455 1422 - // create an atproto record for this fork 1423 1456 rkey := tid.TID() 1424 1457 repo := &db.Repo{ 1425 1458 Did: user.Did, 1426 1459 Name: forkName, 1427 - Knot: targetKnot, 1460 + Knot: knot, 1428 1461 Rkey: rkey, 1429 1462 Source: sourceAt, 1430 1463 } 1431 1464 1465 + tx, err := rp.db.BeginTx(r.Context(), nil) 1466 + if err != nil { 1467 + log.Println(err) 1468 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1469 + return 1470 + } 1471 + defer func() { 1472 + tx.Rollback() 1473 + err = rp.enforcer.E.LoadPolicy() 1474 + if err != nil { 1475 + log.Println("failed to rollback policies") 1476 + } 1477 + }() 1478 + 1479 + resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1480 + if err != nil { 1481 + rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1482 + return 1483 + } 1484 + 1485 + switch resp.StatusCode { 1486 + case http.StatusConflict: 1487 + rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1488 + return 1489 + case http.StatusInternalServerError: 1490 + rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1491 + case http.StatusNoContent: 1492 + // continue 1493 + } 1494 + 1432 1495 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1433 1496 if err != nil { 1434 - l.Error("failed to create xrpcclient", "err", err) 1435 - rp.pages.Notice(w, "repo", "Failed to fork repository.") 1497 + log.Println("failed to get authorized client", err) 1498 + rp.pages.Notice(w, "repo", "Failed to create repository.") 1436 1499 return 1437 1500 } 1438 1501 ··· 1451 1514 }}, 1452 1515 }) 1453 1516 if err != nil { 1454 - l.Error("failed to write to PDS", "err", err) 1517 + log.Printf("failed to create record: %s", err) 1455 1518 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1456 1519 return 1457 1520 } 1458 - 1459 - aturi := atresp.Uri 1460 - l = l.With("aturi", aturi) 1461 - l.Info("wrote to PDS") 1462 - 1463 - tx, err := rp.db.BeginTx(r.Context(), nil) 1464 - if err != nil { 1465 - l.Info("txn failed", "err", err) 1466 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1467 - return 1468 - } 1469 - 1470 - // The rollback function reverts a few things on failure: 1471 - // - the pending txn 1472 - // - the ACLs 1473 - // - the atproto record created 1474 - rollback := func() { 1475 - err1 := tx.Rollback() 1476 - err2 := rp.enforcer.E.LoadPolicy() 1477 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1478 - 1479 - // ignore txn complete errors, this is okay 1480 - if errors.Is(err1, sql.ErrTxDone) { 1481 - err1 = nil 1482 - } 1483 - 1484 - if errs := errors.Join(err1, err2, err3); errs != nil { 1485 - l.Error("failed to rollback changes", "errs", errs) 1486 - return 1487 - } 1488 - } 1489 - defer rollback() 1490 - 1491 - client, err := rp.oauth.ServiceClient( 1492 - r, 1493 - oauth.WithService(targetKnot), 1494 - oauth.WithLxm(tangled.RepoCreateNSID), 1495 - oauth.WithDev(rp.config.Core.Dev), 1496 - ) 1497 - if err != nil { 1498 - l.Error("could not create service client", "err", err) 1499 - rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1500 - return 1501 - } 1502 - 1503 - err = tangled.RepoCreate( 1504 - r.Context(), 1505 - client, 1506 - &tangled.RepoCreate_Input{ 1507 - Rkey: rkey, 1508 - Source: &forkSourceUrl, 1509 - }, 1510 - ) 1511 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 1512 - rp.pages.Notice(w, "repo", err.Error()) 1513 - return 1514 - } 1521 + log.Println("created repo record: ", atresp.Uri) 1515 1522 1516 1523 err = db.AddRepo(tx, repo) 1517 1524 if err != nil { ··· 1522 1529 1523 1530 // acls 1524 1531 p, _ := securejoin.SecureJoin(user.Did, forkName) 1525 - err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1532 + err = rp.enforcer.AddRepo(user.Did, knot, p) 1526 1533 if err != nil { 1527 1534 log.Println(err) 1528 1535 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1543 1550 return 1544 1551 } 1545 1552 1546 - // reset the ATURI because the transaction completed successfully 1547 - aturi = "" 1548 - 1549 - rp.notifier.NewRepo(r.Context(), repo) 1550 1553 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1551 - } 1552 - } 1553 - 1554 - // this is used to rollback changes made to the PDS 1555 - // 1556 - // it is a no-op if the provided ATURI is empty 1557 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1558 - if aturi == "" { 1559 - return nil 1554 + return 1560 1555 } 1561 - 1562 - parsed := syntax.ATURI(aturi) 1563 - 1564 - collection := parsed.Collection().String() 1565 - repo := parsed.Authority().String() 1566 - rkey := parsed.RecordKey().String() 1567 - 1568 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1569 - Collection: collection, 1570 - Repo: repo, 1571 - Rkey: rkey, 1572 - }) 1573 - return err 1574 1556 } 1575 1557 1576 1558 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
-164
appview/serververify/verify.go
··· 1 - package serververify 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 - 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - ) 15 - 16 - var ( 17 - FetchError = errors.New("failed to fetch owner") 18 - ) 19 - 20 - // fetchOwner fetches the owner DID from a server's /owner endpoint 21 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 - scheme := "https" 23 - if dev { 24 - scheme = "http" 25 - } 26 - 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 - } 41 - 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 - } 46 - 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 - } 54 - 55 - type OwnerMismatch struct { 56 - expected string 57 - observed string 58 - } 59 - 60 - func (e *OwnerMismatch) Error() string { 61 - return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 - } 63 - 64 - // RunVerification verifies that the server at the given domain has the expected owner 65 - func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 - observedOwner, err := fetchOwner(ctx, domain, dev) 67 - if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 - } 70 - 71 - if observedOwner != expectedOwner { 72 - return &OwnerMismatch{ 73 - expected: expectedOwner, 74 - observed: observedOwner, 75 - } 76 - } 77 - 78 - return nil 79 - } 80 - 81 - // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 82 - func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 - tx, err := d.Begin() 84 - if err != nil { 85 - return 0, fmt.Errorf("failed to create txn: %w", err) 86 - } 87 - defer func() { 88 - tx.Rollback() 89 - e.E.LoadPolicy() 90 - }() 91 - 92 - // mark this spindle as verified in the db 93 - rowId, err := db.VerifySpindle( 94 - tx, 95 - db.FilterEq("owner", owner), 96 - db.FilterEq("instance", instance), 97 - ) 98 - if err != nil { 99 - return 0, fmt.Errorf("failed to write to DB: %w", err) 100 - } 101 - 102 - err = e.AddSpindleOwner(instance, owner) 103 - if err != nil { 104 - return 0, fmt.Errorf("failed to update ACL: %w", err) 105 - } 106 - 107 - err = tx.Commit() 108 - if err != nil { 109 - return 0, fmt.Errorf("failed to commit txn: %w", err) 110 - } 111 - 112 - err = e.E.SavePolicy() 113 - if err != nil { 114 - return 0, fmt.Errorf("failed to update ACL: %w", err) 115 - } 116 - 117 - return rowId, nil 118 - } 119 - 120 - // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 121 - func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 122 - tx, err := d.BeginTx(context.Background(), nil) 123 - if err != nil { 124 - return fmt.Errorf("failed to start tx: %w", err) 125 - } 126 - defer func() { 127 - tx.Rollback() 128 - e.E.LoadPolicy() 129 - }() 130 - 131 - // mark as registered 132 - err = db.MarkRegistered( 133 - tx, 134 - db.FilterEq("did", owner), 135 - db.FilterEq("domain", domain), 136 - ) 137 - if err != nil { 138 - return fmt.Errorf("failed to register domain: %w", err) 139 - } 140 - 141 - // add basic acls for this domain 142 - err = e.AddKnot(domain) 143 - if err != nil { 144 - return fmt.Errorf("failed to add knot to enforcer: %w", err) 145 - } 146 - 147 - // add this did as owner of this domain 148 - err = e.AddKnotOwner(domain, owner) 149 - if err != nil { 150 - return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 151 - } 152 - 153 - err = tx.Commit() 154 - if err != nil { 155 - return fmt.Errorf("failed to commit changes: %w", err) 156 - } 157 - 158 - err = e.E.SavePolicy() 159 - if err != nil { 160 - return fmt.Errorf("failed to update ACLs: %w", err) 161 - } 162 - 163 - return nil 164 - }
+9 -44
appview/settings/settings.go
··· 33 33 Config *config.Config 34 34 } 35 35 36 - type tab = map[string]any 37 - 38 - var ( 39 - settingsTabs []tab = []tab{ 40 - {"Name": "profile", "Icon": "user"}, 41 - {"Name": "keys", "Icon": "key"}, 42 - {"Name": "emails", "Icon": "mail"}, 43 - } 44 - ) 45 - 46 36 func (s *Settings) Router() http.Handler { 47 37 r := chi.NewRouter() 48 38 49 39 r.Use(middleware.AuthMiddleware(s.OAuth)) 50 40 51 - // settings pages 52 - r.Get("/", s.profileSettings) 53 - r.Get("/profile", s.profileSettings) 41 + r.Get("/", s.settings) 54 42 55 43 r.Route("/keys", func(r chi.Router) { 56 - r.Get("/", s.keysSettings) 57 44 r.Put("/", s.keys) 58 45 r.Delete("/", s.keys) 59 46 }) 60 47 61 48 r.Route("/emails", func(r chi.Router) { 62 - r.Get("/", s.emailsSettings) 63 49 r.Put("/", s.emails) 64 50 r.Delete("/", s.emails) 65 51 r.Get("/verify", s.emailsVerify) ··· 70 56 return r 71 57 } 72 58 73 - func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 - user := s.OAuth.GetUser(r) 75 - 76 - s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 - LoggedInUser: user, 78 - Tabs: settingsTabs, 79 - Tab: "profile", 80 - }) 81 - } 82 - 83 - func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 59 + func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 84 60 user := s.OAuth.GetUser(r) 85 61 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 86 62 if err != nil { 87 63 log.Println(err) 88 64 } 89 65 90 - s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 - LoggedInUser: user, 92 - PubKeys: pubKeys, 93 - Tabs: settingsTabs, 94 - Tab: "keys", 95 - }) 96 - } 97 - 98 - func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 - user := s.OAuth.GetUser(r) 100 66 emails, err := db.GetAllEmails(s.Db, user.Did) 101 67 if err != nil { 102 68 log.Println(err) 103 69 } 104 70 105 - s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 71 + s.Pages.Settings(w, pages.SettingsParams{ 106 72 LoggedInUser: user, 73 + PubKeys: pubKeys, 107 74 Emails: emails, 108 - Tabs: settingsTabs, 109 - Tab: "emails", 110 75 }) 111 76 } 112 77 ··· 236 201 return 237 202 } 238 203 239 - s.Pages.HxLocation(w, "/settings/emails") 204 + s.Pages.HxLocation(w, "/settings") 240 205 return 241 206 } 242 207 } ··· 279 244 return 280 245 } 281 246 282 - http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 247 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 283 248 } 284 249 285 250 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 374 339 return 375 340 } 376 341 377 - s.Pages.HxLocation(w, "/settings/emails") 342 + s.Pages.HxLocation(w, "/settings") 378 343 } 379 344 380 345 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 445 410 return 446 411 } 447 412 448 - s.Pages.HxLocation(w, "/settings/keys") 413 + s.Pages.HxLocation(w, "/settings") 449 414 return 450 415 451 416 case http.MethodDelete: ··· 490 455 } 491 456 log.Println("deleted successfully") 492 457 493 - s.Pages.HxLocation(w, "/settings/keys") 458 + s.Pages.HxLocation(w, "/settings") 494 459 return 495 460 } 496 461 }
+8 -8
appview/spindles/spindles.go
··· 15 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 17 "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/serververify" 18 + verify "tangled.sh/tangled.sh/core/appview/spindleverify" 19 19 "tangled.sh/tangled.sh/core/idresolver" 20 20 "tangled.sh/tangled.sh/core/rbac" 21 21 "tangled.sh/tangled.sh/core/tid" ··· 227 227 } 228 228 229 229 // begin verification 230 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 230 + err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 231 231 if err != nil { 232 232 l.Error("verification failed", "err", err) 233 233 s.Pages.HxRefresh(w) 234 234 return 235 235 } 236 236 237 - _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 237 + _, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 238 238 if err != nil { 239 239 l.Error("failed to mark verified", "err", err) 240 240 s.Pages.HxRefresh(w) ··· 400 400 } 401 401 402 402 // begin verification 403 - err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 403 + err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 404 404 if err != nil { 405 405 l.Error("verification failed", "err", err) 406 406 407 - if errors.Is(err, serververify.FetchError) { 408 - s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 407 + if errors.Is(err, verify.FetchError) { 408 + s.Pages.Notice(w, noticeId, err.Error()) 409 409 return 410 410 } 411 411 412 - if e, ok := err.(*serververify.OwnerMismatch); ok { 412 + if e, ok := err.(*verify.OwnerMismatch); ok { 413 413 s.Pages.Notice(w, noticeId, e.Error()) 414 414 return 415 415 } ··· 418 418 return 419 419 } 420 420 421 - rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 421 + rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 422 422 if err != nil { 423 423 l.Error("failed to mark verified", "err", err) 424 424 s.Pages.Notice(w, noticeId, err.Error())
+118
appview/spindleverify/verify.go
··· 1 + package spindleverify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "tangled.sh/tangled.sh/core/appview/db" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + ) 15 + 16 + var ( 17 + FetchError = errors.New("failed to fetch owner") 18 + ) 19 + 20 + // TODO: move this to "spindleclient" or similar 21 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 22 + scheme := "https" 23 + if dev { 24 + scheme = "http" 25 + } 26 + 27 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 + req, err := http.NewRequest("GET", url, nil) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + client := &http.Client{ 34 + Timeout: 1 * time.Second, 35 + } 36 + 37 + resp, err := client.Do(req.WithContext(ctx)) 38 + if err != nil || resp.StatusCode != 200 { 39 + return "", fmt.Errorf("failed to fetch /owner") 40 + } 41 + 42 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 + if err != nil { 44 + return "", fmt.Errorf("failed to read /owner response: %w", err) 45 + } 46 + 47 + did := strings.TrimSpace(string(body)) 48 + if did == "" { 49 + return "", fmt.Errorf("empty DID in /owner response") 50 + } 51 + 52 + return did, nil 53 + } 54 + 55 + type OwnerMismatch struct { 56 + expected string 57 + observed string 58 + } 59 + 60 + func (e *OwnerMismatch) Error() string { 61 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 62 + } 63 + 64 + func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error { 65 + // begin verification 66 + observedOwner, err := fetchOwner(ctx, instance, dev) 67 + if err != nil { 68 + return fmt.Errorf("%w: %w", FetchError, err) 69 + } 70 + 71 + if observedOwner != expectedOwner { 72 + return &OwnerMismatch{ 73 + expected: expectedOwner, 74 + observed: observedOwner, 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // mark this spindle as verified in the DB and add this user as its owner 82 + func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 83 + tx, err := d.Begin() 84 + if err != nil { 85 + return 0, fmt.Errorf("failed to create txn: %w", err) 86 + } 87 + defer func() { 88 + tx.Rollback() 89 + e.E.LoadPolicy() 90 + }() 91 + 92 + // mark this spindle as verified in the db 93 + rowId, err := db.VerifySpindle( 94 + tx, 95 + db.FilterEq("owner", owner), 96 + db.FilterEq("instance", instance), 97 + ) 98 + if err != nil { 99 + return 0, fmt.Errorf("failed to write to DB: %w", err) 100 + } 101 + 102 + err = e.AddSpindleOwner(instance, owner) 103 + if err != nil { 104 + return 0, fmt.Errorf("failed to update ACL: %w", err) 105 + } 106 + 107 + err = tx.Commit() 108 + if err != nil { 109 + return 0, fmt.Errorf("failed to commit txn: %w", err) 110 + } 111 + 112 + err = e.E.SavePolicy() 113 + if err != nil { 114 + return 0, fmt.Errorf("failed to update ACL: %w", err) 115 + } 116 + 117 + return rowId, nil 118 + }
+2 -5
appview/state/knotstream.go
··· 24 24 ) 25 25 26 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetRegistrations( 28 - d, 29 - db.FilterIsNot("registered", "null"), 30 - ) 27 + knots, err := db.GetCompletedRegistrations(d) 31 28 if err != nil { 32 29 return nil, err 33 30 } 34 31 35 32 srcs := make(map[ec.Source]struct{}) 36 33 for _, k := range knots { 37 - s := ec.NewKnotSource(k.Domain) 34 + s := ec.NewKnotSource(k) 38 35 srcs[s] = struct{}{} 39 36 } 40 37
+62 -212
appview/state/profile.go
··· 17 17 "github.com/gorilla/feeds" 18 18 "tangled.sh/tangled.sh/core/api/tangled" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 ) 23 22 ··· 25 24 tabVal := r.URL.Query().Get("tab") 26 25 switch tabVal { 27 26 case "": 28 - s.profileHomePage(w, r) 27 + s.profilePage(w, r) 29 28 case "repos": 30 29 s.reposPage(w, r) 31 - case "followers": 32 - s.followersPage(w, r) 33 - case "following": 34 - s.followingPage(w, r) 35 30 } 36 31 } 37 32 38 - type ProfilePageParams struct { 39 - Id identity.Identity 40 - LoggedInUser *oauth.User 41 - Card pages.ProfileCard 42 - } 43 - 44 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 33 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 45 34 didOrHandle := chi.URLParam(r, "user") 46 35 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 36 + http.Error(w, "Bad request", http.StatusBadRequest) 37 + return 49 38 } 50 39 51 40 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 41 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 42 + s.pages.Error404(w) 43 + return 56 44 } 57 - did := ident.DID.String() 58 45 59 - profile, err := db.GetProfile(s.db, did) 46 + profile, err := db.GetProfile(s.db, ident.DID.String()) 60 47 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 48 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 64 49 } 65 50 66 - followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 - if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 69 - } 70 - 71 - loggedInUser := s.oauth.GetUser(r) 72 - followStatus := db.IsNotFollowing 73 - if loggedInUser != nil { 74 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 - } 76 - 77 - return &ProfilePageParams{ 78 - Id: ident, 79 - LoggedInUser: loggedInUser, 80 - Card: pages.ProfileCard{ 81 - UserDid: did, 82 - UserHandle: ident.Handle.String(), 83 - Profile: profile, 84 - FollowStatus: followStatus, 85 - FollowersCount: followStats.Followers, 86 - FollowingCount: followStats.Following, 87 - }, 88 - } 89 - } 90 - 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 94 - return 95 - } 96 - 97 - id := pageWithProfile.Id 98 51 repos, err := db.GetRepos( 99 52 s.db, 100 53 0, 101 - db.FilterEq("did", id.DID), 54 + db.FilterEq("did", ident.DID.String()), 102 55 ) 103 56 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 57 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 105 58 } 106 59 107 - profile := pageWithProfile.Card.Profile 108 60 // filter out ones that are pinned 109 61 pinnedRepos := []db.Repo{} 110 62 for i, r := range repos { ··· 119 71 } 120 72 } 121 73 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 74 + collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 123 75 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 76 + log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 125 77 } 126 78 127 79 pinnedCollaboratingRepos := []db.Repo{} ··· 132 84 } 133 85 } 134 86 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 87 + timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 136 88 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 89 + log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 138 90 } 139 91 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 92 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 93 + if err != nil { 94 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 143 95 } 144 - for _, byMonth := range timeline.ByMonth { 145 - for _, pe := range byMonth.PullEvents.Items { 146 - didsToResolve = append(didsToResolve, pe.Repo.Did) 147 - } 148 - for _, ie := range byMonth.IssueEvents.Items { 149 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 150 - } 151 - for _, re := range byMonth.RepoEvents { 152 - didsToResolve = append(didsToResolve, re.Repo.Did) 153 - if re.Source != nil { 154 - didsToResolve = append(didsToResolve, re.Source.Did) 155 - } 156 - } 96 + 97 + loggedInUser := s.oauth.GetUser(r) 98 + followStatus := db.IsNotFollowing 99 + if loggedInUser != nil { 100 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 157 101 } 158 102 159 103 now := time.Now() 160 104 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 105 punchcard, err := db.MakePunchcard( 162 106 s.db, 163 - db.FilterEq("did", id.DID), 107 + db.FilterEq("did", ident.DID.String()), 164 108 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 109 db.FilterLte("date", now.Format(time.DateOnly)), 166 110 ) 167 111 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 112 + log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 169 113 } 170 114 171 - s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 - LoggedInUser: pageWithProfile.LoggedInUser, 115 + s.pages.ProfilePage(w, pages.ProfilePageParams{ 116 + LoggedInUser: loggedInUser, 173 117 Repos: pinnedRepos, 174 118 CollaboratingRepos: pinnedCollaboratingRepos, 175 - Card: pageWithProfile.Card, 176 - Punchcard: punchcard, 177 - ProfileTimeline: timeline, 119 + Card: pages.ProfileCard{ 120 + UserDid: ident.DID.String(), 121 + UserHandle: ident.Handle.String(), 122 + Profile: profile, 123 + FollowStatus: followStatus, 124 + Followers: followers, 125 + Following: following, 126 + }, 127 + Punchcard: punchcard, 128 + ProfileTimeline: timeline, 178 129 }) 179 130 } 180 131 181 132 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 133 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 134 + if !ok { 135 + s.pages.Error404(w) 184 136 return 185 137 } 186 138 187 - id := pageWithProfile.Id 139 + profile, err := db.GetProfile(s.db, ident.DID.String()) 140 + if err != nil { 141 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 142 + } 143 + 188 144 repos, err := db.GetRepos( 189 145 s.db, 190 146 0, 191 - db.FilterEq("did", id.DID), 147 + db.FilterEq("did", ident.DID.String()), 192 148 ) 193 149 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 195 - } 196 - 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 199 - Repos: repos, 200 - Card: pageWithProfile.Card, 201 - }) 202 - } 203 - 204 - type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 208 - } 209 - 210 - func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 - pageWithProfile := s.profilePage(w, r) 212 - if pageWithProfile == nil { 213 - return FollowsPageParams{}, nil 150 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 214 151 } 215 152 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 218 - 219 - follows, err := fetchFollows(s.db, id.DID.String()) 220 - if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 153 + loggedInUser := s.oauth.GetUser(r) 154 + followStatus := db.IsNotFollowing 155 + if loggedInUser != nil { 156 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 223 157 } 224 158 225 - if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 231 - } 232 - 233 - followDids := make([]string, 0, len(follows)) 234 - for _, follow := range follows { 235 - followDids = append(followDids, extractDid(follow)) 236 - } 237 - 238 - profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 159 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 239 160 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 242 - } 243 - 244 - followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 245 - if err != nil { 246 - log.Printf("getting follow counts for %s: %s", followDids, err) 247 - } 248 - 249 - var loggedInUserFollowing map[string]struct{} 250 - if loggedInUser != nil { 251 - following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 - if err != nil { 253 - return FollowsPageParams{}, err 254 - } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 260 - } 161 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 261 162 } 262 163 263 - followCards := make([]pages.FollowCard, 0, len(follows)) 264 - for _, did := range followDids { 265 - followStats, exists := followStatsMap[did] 266 - if !exists { 267 - followStats = db.FollowStats{} 268 - } 269 - followStatus := db.IsNotFollowing 270 - if loggedInUserFollowing != nil { 271 - if _, exists := loggedInUserFollowing[did]; exists { 272 - followStatus = db.IsFollowing 273 - } else if loggedInUser.Did == did { 274 - followStatus = db.IsSelf 275 - } 276 - } 277 - var profile *db.Profile 278 - if p, exists := profiles[did]; exists { 279 - profile = p 280 - } else { 281 - profile = &db.Profile{} 282 - profile.Did = did 283 - } 284 - followCards = append(followCards, pages.FollowCard{ 285 - UserDid: did, 286 - FollowStatus: followStatus, 287 - FollowersCount: followStats.Followers, 288 - FollowingCount: followStats.Following, 289 - Profile: profile, 290 - }) 291 - } 292 - 293 - return FollowsPageParams{ 164 + s.pages.ReposPage(w, pages.ReposPageParams{ 294 165 LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 298 - } 299 - 300 - func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 - followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 - if err != nil { 303 - s.pages.Notice(w, "all-followers", "Failed to load followers") 304 - return 305 - } 306 - 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 309 - Followers: followPage.Follows, 310 - Card: followPage.Card, 311 - }) 312 - } 313 - 314 - func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 - followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 - if err != nil { 317 - s.pages.Notice(w, "all-following", "Failed to load following") 318 - return 319 - } 320 - 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 323 - Following: followPage.Follows, 324 - Card: followPage.Card, 166 + Repos: repos, 167 + Card: pages.ProfileCard{ 168 + UserDid: ident.DID.String(), 169 + UserHandle: ident.Handle.String(), 170 + Profile: profile, 171 + FollowStatus: followStatus, 172 + Followers: followers, 173 + Following: following, 174 + }, 325 175 }) 326 176 } 327 177
+3 -3
appview/state/router.go
··· 147 147 148 148 r.Mount("/settings", s.SettingsRouter()) 149 149 r.Mount("/strings", s.StringsRouter(mw)) 150 - r.Mount("/knots", s.KnotsRouter()) 150 + r.Mount("/knots", s.KnotsRouter(mw)) 151 151 r.Mount("/spindles", s.SpindlesRouter()) 152 152 r.Mount("/signup", s.SignupRouter()) 153 153 r.Mount("/", s.OAuthRouter()) ··· 195 195 return spindles.Router() 196 196 } 197 197 198 - func (s *State) KnotsRouter() http.Handler { 198 + func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 199 199 logger := log.New("knots") 200 200 201 201 knots := &knots.Knots{ ··· 209 209 Logger: logger, 210 210 } 211 211 212 - return knots.Router() 212 + return knots.Router(mw) 213 213 } 214 214 215 215 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+41 -95
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "database/sql" 6 - "errors" 7 5 "fmt" 8 6 "log" 9 7 "log/slog" ··· 12 10 "time" 13 11 14 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 13 lexutil "github.com/bluesky-social/indigo/lex/util" 17 14 securejoin "github.com/cyphar/filepath-securejoin" 18 15 "github.com/go-chi/chi/v5" ··· 28 25 "tangled.sh/tangled.sh/core/appview/pages" 29 26 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 28 "tangled.sh/tangled.sh/core/eventconsumer" 33 29 "tangled.sh/tangled.sh/core/idresolver" 34 30 "tangled.sh/tangled.sh/core/jetstream" 31 + "tangled.sh/tangled.sh/core/knotclient" 35 32 tlog "tangled.sh/tangled.sh/core/log" 36 33 "tangled.sh/tangled.sh/core/rbac" 37 34 "tangled.sh/tangled.sh/core/tid" 38 - // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 39 35 ) 40 36 41 37 type State struct { ··· 52 48 repoResolver *reporesolver.RepoResolver 53 49 knotstream *eventconsumer.Consumer 54 50 spindlestream *eventconsumer.Consumer 55 - logger *slog.Logger 56 51 } 57 52 58 53 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 99 94 tangled.SpindleMemberNSID, 100 95 tangled.SpindleNSID, 101 96 tangled.StringNSID, 97 + tangled.RepoIssueNSID, 98 + tangled.RepoIssueCommentNSID, 102 99 }, 103 100 nil, 104 101 slog.Default(), ··· 157 154 repoResolver, 158 155 knotstream, 159 156 spindlestream, 160 - slog.Default(), 161 157 } 162 158 163 159 return state, nil ··· 297 293 }) 298 294 299 295 case http.MethodPost: 300 - l := s.logger.With("handler", "NewRepo") 301 - 302 296 user := s.oauth.GetUser(r) 303 - l = l.With("did", user.Did) 304 - l = l.With("handle", user.Handle) 305 297 306 - // form validation 307 298 domain := r.FormValue("domain") 308 299 if domain == "" { 309 300 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 310 301 return 311 302 } 312 - l = l.With("knot", domain) 313 303 314 304 repoName := r.FormValue("name") 315 305 if repoName == "" { ··· 321 311 s.pages.Notice(w, "repo", err.Error()) 322 312 return 323 313 } 314 + 324 315 repoName = stripGitExt(repoName) 325 - l = l.With("repoName", repoName) 326 316 327 317 defaultBranch := r.FormValue("branch") 328 318 if defaultBranch == "" { 329 319 defaultBranch = "main" 330 320 } 331 - l = l.With("defaultBranch", defaultBranch) 332 321 333 322 description := r.FormValue("description") 334 323 335 - // ACL validation 336 324 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 337 325 if err != nil || !ok { 338 - l.Info("unauthorized") 339 326 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 340 327 return 341 328 } 342 329 343 - // Check for existing repos 344 330 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 345 331 if err == nil && existingRepo != nil { 346 - l.Info("repo exists") 347 - s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 332 + s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 333 + return 334 + } 335 + 336 + secret, err := db.GetRegistrationKey(s.db, domain) 337 + if err != nil { 338 + s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 348 339 return 349 340 } 350 341 351 - // create atproto record for this repo 342 + client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 343 + if err != nil { 344 + s.pages.Notice(w, "repo", "Failed to connect to knot server.") 345 + return 346 + } 347 + 352 348 rkey := tid.TID() 353 349 repo := &db.Repo{ 354 350 Did: user.Did, ··· 360 356 361 357 xrpcClient, err := s.oauth.AuthorizedClient(r) 362 358 if err != nil { 363 - l.Info("PDS write failed", "err", err) 364 359 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 365 360 return 366 361 } ··· 379 374 }}, 380 375 }) 381 376 if err != nil { 382 - l.Info("PDS write failed", "err", err) 377 + log.Printf("failed to create record: %s", err) 383 378 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 384 379 return 385 380 } 386 - 387 - aturi := atresp.Uri 388 - l = l.With("aturi", aturi) 389 - l.Info("wrote to PDS") 381 + log.Println("created repo record: ", atresp.Uri) 390 382 391 383 tx, err := s.db.BeginTx(r.Context(), nil) 392 384 if err != nil { 393 - l.Info("txn failed", "err", err) 385 + log.Println(err) 394 386 s.pages.Notice(w, "repo", "Failed to save repository information.") 395 387 return 396 388 } 397 - 398 - // The rollback function reverts a few things on failure: 399 - // - the pending txn 400 - // - the ACLs 401 - // - the atproto record created 402 - rollback := func() { 403 - err1 := tx.Rollback() 404 - err2 := s.enforcer.E.LoadPolicy() 405 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 406 - 407 - // ignore txn complete errors, this is okay 408 - if errors.Is(err1, sql.ErrTxDone) { 409 - err1 = nil 410 - } 411 - 412 - if errs := errors.Join(err1, err2, err3); errs != nil { 413 - l.Error("failed to rollback changes", "errs", errs) 414 - return 389 + defer func() { 390 + tx.Rollback() 391 + err = s.enforcer.E.LoadPolicy() 392 + if err != nil { 393 + log.Println("failed to rollback policies") 415 394 } 416 - } 417 - defer rollback() 395 + }() 418 396 419 - client, err := s.oauth.ServiceClient( 420 - r, 421 - oauth.WithService(domain), 422 - oauth.WithLxm(tangled.RepoCreateNSID), 423 - oauth.WithDev(s.config.Core.Dev), 424 - ) 397 + resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 425 398 if err != nil { 426 - l.Error("service auth failed", "err", err) 427 - s.pages.Notice(w, "repo", "Failed to reach PDS.") 399 + s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 428 400 return 429 401 } 430 402 431 - xe := tangled.RepoCreate( 432 - r.Context(), 433 - client, 434 - &tangled.RepoCreate_Input{ 435 - Rkey: rkey, 436 - }, 437 - ) 438 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 439 - l.Error("xrpc error", "xe", xe) 440 - s.pages.Notice(w, "repo", err.Error()) 403 + switch resp.StatusCode { 404 + case http.StatusConflict: 405 + s.pages.Notice(w, "repo", "A repository with that name already exists.") 441 406 return 407 + case http.StatusInternalServerError: 408 + s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 409 + case http.StatusNoContent: 410 + // continue 442 411 } 443 412 444 413 err = db.AddRepo(tx, repo) 445 414 if err != nil { 446 - l.Error("db write failed", "err", err) 415 + log.Println(err) 447 416 s.pages.Notice(w, "repo", "Failed to save repository information.") 448 417 return 449 418 } ··· 452 421 p, _ := securejoin.SecureJoin(user.Did, repoName) 453 422 err = s.enforcer.AddRepo(user.Did, domain, p) 454 423 if err != nil { 455 - l.Error("acl setup failed", "err", err) 424 + log.Println(err) 456 425 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 457 426 return 458 427 } 459 428 460 429 err = tx.Commit() 461 430 if err != nil { 462 - l.Error("txn commit failed", "err", err) 431 + log.Println("failed to commit changes", err) 463 432 http.Error(w, err.Error(), http.StatusInternalServerError) 464 433 return 465 434 } 466 435 467 436 err = s.enforcer.E.SavePolicy() 468 437 if err != nil { 469 - l.Error("acl save failed", "err", err) 438 + log.Println("failed to update ACLs", err) 470 439 http.Error(w, err.Error(), http.StatusInternalServerError) 471 440 return 472 441 } 473 442 474 - // reset the ATURI because the transaction completed successfully 475 - aturi = "" 476 - 477 443 s.notifier.NewRepo(r.Context(), repo) 478 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 479 - } 480 - } 481 444 482 - // this is used to rollback changes made to the PDS 483 - // 484 - // it is a no-op if the provided ATURI is empty 485 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 486 - if aturi == "" { 487 - return nil 445 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 446 + return 488 447 } 489 - 490 - parsed := syntax.ATURI(aturi) 491 - 492 - collection := parsed.Collection().String() 493 - repo := parsed.Authority().String() 494 - rkey := parsed.RecordKey().String() 495 - 496 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 497 - Collection: collection, 498 - Repo: repo, 499 - Rkey: rkey, 500 - }) 501 - return err 502 448 }
+7 -7
appview/strings/strings.go
··· 202 202 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 203 } 204 204 205 - followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 205 + followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 206 if err != nil { 207 207 l.Error("failed to get follow stats", "err", err) 208 208 } ··· 210 210 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 211 LoggedInUser: s.OAuth.GetUser(r), 212 212 Card: pages.ProfileCard{ 213 - UserDid: id.DID.String(), 214 - UserHandle: id.Handle.String(), 215 - Profile: profile, 216 - FollowStatus: followStatus, 217 - FollowersCount: followStats.Followers, 218 - FollowingCount: followStats.Following, 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + Followers: followers, 218 + Following: following, 219 219 }, 220 220 Strings: all, 221 221 })
-25
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 - "errors" 7 - "fmt" 8 6 "io" 9 - "net/http" 10 7 11 8 "github.com/bluesky-social/indigo/api/atproto" 12 9 "github.com/bluesky-social/indigo/xrpc" 13 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 10 oauth "tangled.sh/icyphox.sh/atproto-oauth" 15 11 ) 16 12 ··· 106 102 107 103 return &out, nil 108 104 } 109 - 110 - // produces a more manageable error 111 - func HandleXrpcErr(err error) error { 112 - if err == nil { 113 - return nil 114 - } 115 - 116 - var xrpcerr *indigoxrpc.Error 117 - if ok := errors.As(err, &xrpcerr); !ok { 118 - return fmt.Errorf("Recieved invalid XRPC error response.") 119 - } 120 - 121 - switch xrpcerr.StatusCode { 122 - case http.StatusNotFound: 123 - return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 - case http.StatusUnauthorized: 125 - return fmt.Errorf("Unauthorized XRPC request.") 126 - default: 127 - return fmt.Errorf("Failed to perform operation. Try again later.") 128 - } 129 - }
-1
cmd/gen.go
··· 24 24 tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 25 tangled.GitRefUpdate_Pair{}, 26 26 tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 27 tangled.KnotMember{}, 29 28 tangled.Pipeline{}, 30 29 tangled.Pipeline_CloneOpts{},
+19 -19
docs/hacking.md
··· 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, grab your DID from http://localhost:3000/settings. 59 - Then, set `TANGLED_VM_KNOT_OWNER` and 60 - `TANGLED_VM_SPINDLE_OWNER` to your DID. 58 + To begin, head to `http://localhost:3000/knots` in the browser 59 + and create a knot with hostname `localhost:6000`. This will 60 + generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 + don't lose it. 61 63 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 64 + You will also need to set the `$TANGLED_VM_SPINDLE_OWNER` 65 + variable to some value. If you don't want to [set up a 66 + spindle](#running-a-spindle), you can use any placeholder 67 + value. 64 68 65 69 You can now start a lightweight NixOS VM like so: 66 70 ··· 71 75 ``` 72 76 73 77 This starts a knot on port 6000, a spindle on port 6555 74 - with `ssh` exposed on port 2222. 75 - 76 - Once the services are running, head to 77 - http://localhost:3000/knots and hit verify (and similarly, 78 - http://localhost:3000/spindles to verify your spindle). It 79 - should verify the ownership of the services instantly if 80 - everything went smoothly. 81 - 82 - You can push repositories to this VM with this ssh config 83 - block on your main machine: 78 + with `ssh` exposed on port 2222. You can push repositories 79 + to this VM with this ssh config block on your main machine: 84 80 85 81 ```bash 86 82 Host nixos-shell ··· 99 95 100 96 ## running a spindle 101 97 102 - The above VM should already be running a spindle on 103 - `localhost:6555`. Head to http://localhost:3000/spindles and 104 - hit verify. You can then configure each repository to use 105 - this spindle and run CI jobs. 98 + You will need to find out your DID by entering your login handle into 99 + <https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID. 100 + 101 + The above VM should already be running a spindle on `localhost:6555`. 102 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 103 + and register a spindle with hostname `localhost:6555`. It should instantly 104 + be verified. You can then configure each repository to use this spindle 105 + and run CI jobs. 106 106 107 107 Of interest when debugging spindles: 108 108
+5 -7
docs/knot-hosting.md
··· 73 73 ``` 74 74 75 75 Create `/home/git/.knot.env` with the following, updating the values as 76 - necessary. The `KNOT_SERVER_OWNER` should be set to your 77 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 76 + necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 + [/knots](https://tangled.sh/knots) page on Tangled. 78 78 79 79 ``` 80 80 KNOT_REPO_SCAN_PATH=/home/git 81 81 KNOT_SERVER_HOSTNAME=knot.example.com 82 82 APPVIEW_ENDPOINT=https://tangled.sh 83 - KNOT_SERVER_OWNER=did:plc:foobar 83 + KNOT_SERVER_SECRET=secret 84 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 85 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 86 86 ``` ··· 128 128 Remember to use Let's Encrypt or similar to procure a certificate for your 129 129 knot domain. 130 130 131 - You should now have a running knot server! You can finalize 132 - your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 134 - a record on your PDS to announce the existence of the knot. 131 + You should now have a running knot server! You can finalize your registration by hitting the 132 + `initialize` button on the [/knots](https://tangled.sh/knots) page. 135 133 136 134 ### custom paths 137 135
-35
docs/migrations/knot-1.7.0.md
··· 1 - # Upgrading from v1.7.0 2 - 3 - After v1.7.0, knot secrets have been deprecated. You no 4 - longer need a secret from the appview to run a knot. All 5 - authorized commands to knots are managed via [Inter-Service 6 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 - Knots will be read-only until upgraded. 8 - 9 - Upgrading is quite easy, in essence: 10 - 11 - - `KNOT_SERVER_SECRET` is no more, you can remove this 12 - environment variable entirely 13 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 - your DID. You can find your DID in the 15 - [settings](https://tangled.sh/settings) page. 16 - - Restart your knot once you have replaced the environment 17 - variable 18 - - Head to the [knot dashboard](https://tangled.sh/knots) and 19 - hit the "retry" button to verify your knot. This simply 20 - writes a `sh.tangled.knot` record to your PDS. 21 - 22 - ## Nix 23 - 24 - If you use the nix module, simply bump the flake to the 25 - latest revision, and change your config block like so: 26 - 27 - ```diff 28 - services.tangled-knot = { 29 - enable = true; 30 - server = { 31 - - secretFile = /path/to/secret; 32 - + owner = "did:plc:foo"; 33 - }; 34 - }; 35 - ```
+1 -1
flake.nix
··· 252 252 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 253 253 cd "$rootDir" 254 254 255 - rm -f api/tangled/* 255 + rm api/tangled/* 256 256 lexgen --build-file lexicon-build-config.json lexicons 257 257 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 258 258 ${pkgs.gotools}/bin/goimports -w api/tangled/*
+336
knotclient/signer.go
··· 1 + package knotclient 2 + 3 + import ( 4 + "bytes" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "log" 12 + "net/http" 13 + "net/url" 14 + "time" 15 + 16 + "tangled.sh/tangled.sh/core/types" 17 + ) 18 + 19 + type SignerTransport struct { 20 + Secret string 21 + } 22 + 23 + func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 + timestamp := time.Now().Format(time.RFC3339) 25 + mac := hmac.New(sha256.New, []byte(s.Secret)) 26 + message := req.Method + req.URL.Path + timestamp 27 + mac.Write([]byte(message)) 28 + signature := hex.EncodeToString(mac.Sum(nil)) 29 + req.Header.Set("X-Signature", signature) 30 + req.Header.Set("X-Timestamp", timestamp) 31 + return http.DefaultTransport.RoundTrip(req) 32 + } 33 + 34 + type SignedClient struct { 35 + Secret string 36 + Url *url.URL 37 + client *http.Client 38 + } 39 + 40 + func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 + client := &http.Client{ 42 + Timeout: 5 * time.Second, 43 + Transport: SignerTransport{ 44 + Secret: secret, 45 + }, 46 + } 47 + 48 + scheme := "https" 49 + if dev { 50 + scheme = "http" 51 + } 52 + url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + signedClient := &SignedClient{ 58 + Secret: secret, 59 + client: client, 60 + Url: url, 61 + } 62 + 63 + return signedClient, nil 64 + } 65 + 66 + func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 + return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 + } 69 + 70 + func (s *SignedClient) Init(did string) (*http.Response, error) { 71 + const ( 72 + Method = "POST" 73 + Endpoint = "/init" 74 + ) 75 + 76 + body, _ := json.Marshal(map[string]any{ 77 + "did": did, 78 + }) 79 + 80 + req, err := s.newRequest(Method, Endpoint, body) 81 + if err != nil { 82 + return nil, err 83 + } 84 + 85 + return s.client.Do(req) 86 + } 87 + 88 + func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 + const ( 90 + Method = "PUT" 91 + Endpoint = "/repo/new" 92 + ) 93 + 94 + body, _ := json.Marshal(map[string]any{ 95 + "did": did, 96 + "name": repoName, 97 + "default_branch": defaultBranch, 98 + }) 99 + 100 + req, err := s.newRequest(Method, Endpoint, body) 101 + if err != nil { 102 + return nil, err 103 + } 104 + 105 + return s.client.Do(req) 106 + } 107 + 108 + func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 + const ( 110 + Method = "GET" 111 + ) 112 + endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 + 114 + req, err := s.newRequest(Method, endpoint, nil) 115 + if err != nil { 116 + return nil, err 117 + } 118 + 119 + resp, err := s.client.Do(req) 120 + if err != nil { 121 + return nil, err 122 + } 123 + 124 + var result types.RepoLanguageResponse 125 + if resp.StatusCode != http.StatusOK { 126 + log.Println("failed to calculate languages", resp.Status) 127 + return &types.RepoLanguageResponse{}, nil 128 + } 129 + 130 + body, err := io.ReadAll(resp.Body) 131 + if err != nil { 132 + return nil, err 133 + } 134 + 135 + err = json.Unmarshal(body, &result) 136 + if err != nil { 137 + return nil, err 138 + } 139 + 140 + return &result, nil 141 + } 142 + 143 + func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 + const ( 145 + Method = "GET" 146 + ) 147 + endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 + 149 + body, _ := json.Marshal(map[string]any{ 150 + "did": ownerDid, 151 + "source": source, 152 + "name": name, 153 + "hiddenref": hiddenRef, 154 + }) 155 + 156 + req, err := s.newRequest(Method, endpoint, body) 157 + if err != nil { 158 + return nil, err 159 + } 160 + 161 + return s.client.Do(req) 162 + } 163 + 164 + func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 + const ( 166 + Method = "POST" 167 + ) 168 + endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 + 170 + body, _ := json.Marshal(map[string]any{ 171 + "did": ownerDid, 172 + "source": source, 173 + "name": name, 174 + }) 175 + 176 + req, err := s.newRequest(Method, endpoint, body) 177 + if err != nil { 178 + return nil, err 179 + } 180 + 181 + return s.client.Do(req) 182 + } 183 + 184 + func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 + const ( 186 + Method = "POST" 187 + Endpoint = "/repo/fork" 188 + ) 189 + 190 + body, _ := json.Marshal(map[string]any{ 191 + "did": ownerDid, 192 + "source": source, 193 + "name": name, 194 + }) 195 + 196 + req, err := s.newRequest(Method, Endpoint, body) 197 + if err != nil { 198 + return nil, err 199 + } 200 + 201 + return s.client.Do(req) 202 + } 203 + 204 + func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 + const ( 206 + Method = "DELETE" 207 + Endpoint = "/repo" 208 + ) 209 + 210 + body, _ := json.Marshal(map[string]any{ 211 + "did": did, 212 + "name": repoName, 213 + }) 214 + 215 + req, err := s.newRequest(Method, Endpoint, body) 216 + if err != nil { 217 + return nil, err 218 + } 219 + 220 + return s.client.Do(req) 221 + } 222 + 223 + func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 + const ( 225 + Method = "PUT" 226 + Endpoint = "/member/add" 227 + ) 228 + 229 + body, _ := json.Marshal(map[string]any{ 230 + "did": did, 231 + }) 232 + 233 + req, err := s.newRequest(Method, Endpoint, body) 234 + if err != nil { 235 + return nil, err 236 + } 237 + 238 + return s.client.Do(req) 239 + } 240 + 241 + func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 + const ( 243 + Method = "PUT" 244 + ) 245 + endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 + 247 + body, _ := json.Marshal(map[string]any{ 248 + "branch": branch, 249 + }) 250 + 251 + req, err := s.newRequest(Method, endpoint, body) 252 + if err != nil { 253 + return nil, err 254 + } 255 + 256 + return s.client.Do(req) 257 + } 258 + 259 + func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 + const ( 261 + Method = "POST" 262 + ) 263 + endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 + 265 + body, _ := json.Marshal(map[string]any{ 266 + "did": memberDid, 267 + }) 268 + 269 + req, err := s.newRequest(Method, endpoint, body) 270 + if err != nil { 271 + return nil, err 272 + } 273 + 274 + return s.client.Do(req) 275 + } 276 + 277 + func (s *SignedClient) Merge( 278 + patch []byte, 279 + ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 + ) (*http.Response, error) { 281 + const ( 282 + Method = "POST" 283 + ) 284 + endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 + 286 + mr := types.MergeRequest{ 287 + Branch: branch, 288 + CommitMessage: commitMessage, 289 + CommitBody: commitBody, 290 + AuthorName: authorName, 291 + AuthorEmail: authorEmail, 292 + Patch: string(patch), 293 + } 294 + 295 + body, _ := json.Marshal(mr) 296 + 297 + req, err := s.newRequest(Method, endpoint, body) 298 + if err != nil { 299 + return nil, err 300 + } 301 + 302 + return s.client.Do(req) 303 + } 304 + 305 + func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 + const ( 307 + Method = "POST" 308 + ) 309 + endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 + 311 + body, _ := json.Marshal(map[string]any{ 312 + "patch": string(patch), 313 + "branch": branch, 314 + }) 315 + 316 + req, err := s.newRequest(Method, endpoint, body) 317 + if err != nil { 318 + return nil, err 319 + } 320 + 321 + return s.client.Do(req) 322 + } 323 + 324 + func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 + const ( 326 + Method = "POST" 327 + ) 328 + endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 + 330 + req, err := s.newRequest(Method, endpoint, nil) 331 + if err != nil { 332 + return nil, err 333 + } 334 + 335 + return s.client.Do(req) 336 + }
-35
knotclient/unsigned.go
··· 248 248 249 249 return &formatPatchResponse, nil 250 250 } 251 - 252 - func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 - const ( 254 - Method = "GET" 255 - ) 256 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 - 258 - req, err := s.newRequest(Method, endpoint, nil, nil) 259 - if err != nil { 260 - return nil, err 261 - } 262 - 263 - resp, err := s.client.Do(req) 264 - if err != nil { 265 - return nil, err 266 - } 267 - 268 - var result types.RepoLanguageResponse 269 - if resp.StatusCode != http.StatusOK { 270 - log.Println("failed to calculate languages", resp.Status) 271 - return &types.RepoLanguageResponse{}, nil 272 - } 273 - 274 - body, err := io.ReadAll(resp.Body) 275 - if err != nil { 276 - return nil, err 277 - } 278 - 279 - err = json.Unmarshal(body, &result) 280 - if err != nil { 281 - return nil, err 282 - } 283 - 284 - return &result, nil 285 - }
+1 -1
knotserver/config/config.go
··· 17 17 type Server struct { 18 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 + Secret string `env:"SECRET, required"` 20 21 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 22 Hostname string `env:"HOSTNAME, required"` 22 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 - Owner string `env:"OWNER, required"` 24 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 25 26 26 // This disables signature verification so use with caution.
+150 -1008
knotserver/handler.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "compress/gzip" 5 4 "context" 6 - "crypto/sha256" 7 - "encoding/json" 8 - "errors" 9 5 "fmt" 10 - "log" 6 + "log/slog" 11 7 "net/http" 12 - "net/url" 13 - "path/filepath" 14 - "strconv" 15 - "strings" 16 - "sync" 17 - "time" 8 + "runtime/debug" 18 9 19 - securejoin "github.com/cyphar/filepath-securejoin" 20 - "github.com/gliderlabs/ssh" 21 10 "github.com/go-chi/chi/v5" 22 - "github.com/go-git/go-git/v5/plumbing" 23 - "github.com/go-git/go-git/v5/plumbing/object" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 24 14 "tangled.sh/tangled.sh/core/knotserver/db" 25 - "tangled.sh/tangled.sh/core/knotserver/git" 26 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 + "tangled.sh/tangled.sh/core/notifier" 18 + "tangled.sh/tangled.sh/core/rbac" 27 19 ) 28 20 29 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 - } 32 - 33 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 - w.Header().Set("Content-Type", "application/json") 35 - 36 - capabilities := map[string]any{ 37 - "pull_requests": map[string]any{ 38 - "format_patch": true, 39 - "patch_submissions": true, 40 - "branch_submissions": true, 41 - "fork_submissions": true, 42 - }, 43 - "xrpc": true, 44 - } 45 - 46 - jsonData, err := json.Marshal(capabilities) 47 - if err != nil { 48 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 - return 50 - } 51 - 52 - w.Write(jsonData) 53 - } 54 - 55 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 - l := h.l.With("path", path, "handler", "RepoIndex") 58 - ref := chi.URLParam(r, "ref") 59 - ref, _ = url.PathUnescape(ref) 60 - 61 - gr, err := git.Open(path, ref) 62 - if err != nil { 63 - plain, err2 := git.PlainOpen(path) 64 - if err2 != nil { 65 - l.Error("opening repo", "error", err2.Error()) 66 - notFound(w) 67 - return 68 - } 69 - branches, _ := plain.Branches() 70 - 71 - log.Println(err) 72 - 73 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 - resp := types.RepoIndexResponse{ 75 - IsEmpty: true, 76 - Branches: branches, 77 - } 78 - writeJSON(w, resp) 79 - return 80 - } else { 81 - l.Error("opening repo", "error", err.Error()) 82 - notFound(w) 83 - return 84 - } 85 - } 86 - 87 - var ( 88 - commits []*object.Commit 89 - total int 90 - branches []types.Branch 91 - files []types.NiceTree 92 - tags []object.Tag 93 - ) 94 - 95 - var wg sync.WaitGroup 96 - errorsCh := make(chan error, 5) 97 - 98 - wg.Add(1) 99 - go func() { 100 - defer wg.Done() 101 - cs, err := gr.Commits(0, 60) 102 - if err != nil { 103 - errorsCh <- fmt.Errorf("commits: %w", err) 104 - return 105 - } 106 - commits = cs 107 - }() 108 - 109 - wg.Add(1) 110 - go func() { 111 - defer wg.Done() 112 - t, err := gr.TotalCommits() 113 - if err != nil { 114 - errorsCh <- fmt.Errorf("calculating total: %w", err) 115 - return 116 - } 117 - total = t 118 - }() 119 - 120 - wg.Add(1) 121 - go func() { 122 - defer wg.Done() 123 - bs, err := gr.Branches() 124 - if err != nil { 125 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 - return 127 - } 128 - branches = bs 129 - }() 130 - 131 - wg.Add(1) 132 - go func() { 133 - defer wg.Done() 134 - ts, err := gr.Tags() 135 - if err != nil { 136 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 - return 138 - } 139 - tags = ts 140 - }() 141 - 142 - wg.Add(1) 143 - go func() { 144 - defer wg.Done() 145 - fs, err := gr.FileTree(r.Context(), "") 146 - if err != nil { 147 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 - return 149 - } 150 - files = fs 151 - }() 21 + type Handle struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 152 29 153 - wg.Wait() 154 - close(errorsCh) 155 - 156 - // show any errors 157 - for err := range errorsCh { 158 - l.Error("loading repo", "error", err.Error()) 159 - writeError(w, err.Error(), http.StatusInternalServerError) 160 - return 161 - } 162 - 163 - rtags := []*types.TagReference{} 164 - for _, tag := range tags { 165 - var target *object.Tag 166 - if tag.Target != plumbing.ZeroHash { 167 - target = &tag 168 - } 169 - tr := types.TagReference{ 170 - Tag: target, 171 - } 172 - 173 - tr.Reference = types.Reference{ 174 - Name: tag.Name, 175 - Hash: tag.Hash.String(), 176 - } 177 - 178 - if tag.Message != "" { 179 - tr.Message = tag.Message 180 - } 181 - 182 - rtags = append(rtags, &tr) 183 - } 184 - 185 - var readmeContent string 186 - var readmeFile string 187 - for _, readme := range h.c.Repo.Readme { 188 - content, _ := gr.FileContent(readme) 189 - if len(content) > 0 { 190 - readmeContent = string(content) 191 - readmeFile = readme 192 - } 193 - } 194 - 195 - if ref == "" { 196 - mainBranch, err := gr.FindMainBranch() 197 - if err != nil { 198 - writeError(w, err.Error(), http.StatusInternalServerError) 199 - l.Error("finding main branch", "error", err.Error()) 200 - return 201 - } 202 - ref = mainBranch 203 - } 204 - 205 - resp := types.RepoIndexResponse{ 206 - IsEmpty: false, 207 - Ref: ref, 208 - Commits: commits, 209 - Description: getDescription(path), 210 - Readme: readmeContent, 211 - ReadmeFileName: readmeFile, 212 - Files: files, 213 - Branches: branches, 214 - Tags: rtags, 215 - TotalCommits: total, 216 - } 217 - 218 - writeJSON(w, resp) 30 + // init is a channel that is closed when the knot has been initailized 31 + // i.e. when the first user (knot owner) has been added. 32 + init chan struct{} 33 + knotInitialized bool 219 34 } 220 35 221 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 - treePath := chi.URLParam(r, "*") 223 - ref := chi.URLParam(r, "ref") 224 - ref, _ = url.PathUnescape(ref) 36 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 37 + r := chi.NewRouter() 225 38 226 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 - 228 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 - gr, err := git.Open(path, ref) 230 - if err != nil { 231 - notFound(w) 232 - return 39 + h := Handle{ 40 + c: c, 41 + db: db, 42 + e: e, 43 + l: l, 44 + jc: jc, 45 + n: n, 46 + resolver: idresolver.DefaultResolver(), 47 + init: make(chan struct{}), 233 48 } 234 49 235 - files, err := gr.FileTree(r.Context(), treePath) 50 + err := e.AddKnot(rbac.ThisServer) 236 51 if err != nil { 237 - writeError(w, err.Error(), http.StatusInternalServerError) 238 - l.Error("file tree", "error", err.Error()) 239 - return 240 - } 241 - 242 - resp := types.RepoTreeResponse{ 243 - Ref: ref, 244 - Parent: treePath, 245 - Description: getDescription(path), 246 - DotDot: filepath.Dir(treePath), 247 - Files: files, 52 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 248 53 } 249 54 250 - writeJSON(w, resp) 251 - } 252 - 253 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 - treePath := chi.URLParam(r, "*") 255 - ref := chi.URLParam(r, "ref") 256 - ref, _ = url.PathUnescape(ref) 257 - 258 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 - 260 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 - gr, err := git.Open(path, ref) 55 + // Check if the knot knows about any Dids; 56 + // if it does, it is already initialized and we can repopulate the 57 + // Jetstream subscriptions. 58 + dids, err := db.GetAllDids() 262 59 if err != nil { 263 - notFound(w) 264 - return 60 + return nil, fmt.Errorf("failed to get all Dids: %w", err) 265 61 } 266 62 267 - contents, err := gr.RawContent(treePath) 268 - if err != nil { 269 - writeError(w, err.Error(), http.StatusBadRequest) 270 - l.Error("file content", "error", err.Error()) 271 - return 272 - } 273 - 274 - mimeType := http.DetectContentType(contents) 275 - 276 - // exception for svg 277 - if filepath.Ext(treePath) == ".svg" { 278 - mimeType = "image/svg+xml" 279 - } 280 - 281 - contentHash := sha256.Sum256(contents) 282 - eTag := fmt.Sprintf("\"%x\"", contentHash) 283 - 284 - // allow image, video, and text/plain files to be served directly 285 - switch { 286 - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 - w.WriteHeader(http.StatusNotModified) 289 - return 63 + if len(dids) > 0 { 64 + h.knotInitialized = true 65 + close(h.init) 66 + for _, d := range dids { 67 + h.jc.AddDid(d) 290 68 } 291 - w.Header().Set("ETag", eTag) 292 - 293 - case strings.HasPrefix(mimeType, "text/plain"): 294 - w.Header().Set("Cache-Control", "public, no-cache") 295 - 296 - default: 297 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 - return 300 69 } 301 70 302 - w.Header().Set("Content-Type", mimeType) 303 - w.Write(contents) 304 - } 305 - 306 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 - treePath := chi.URLParam(r, "*") 308 - ref := chi.URLParam(r, "ref") 309 - ref, _ = url.PathUnescape(ref) 310 - 311 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 - 313 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 - gr, err := git.Open(path, ref) 71 + err = h.jc.StartJetstream(ctx, h.processMessages) 315 72 if err != nil { 316 - notFound(w) 317 - return 73 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 318 74 } 319 75 320 - var isBinaryFile bool = false 321 - contents, err := gr.FileContent(treePath) 322 - if errors.Is(err, git.ErrBinaryFile) { 323 - isBinaryFile = true 324 - } else if errors.Is(err, object.ErrFileNotFound) { 325 - notFound(w) 326 - return 327 - } else if err != nil { 328 - writeError(w, err.Error(), http.StatusInternalServerError) 329 - return 330 - } 76 + r.Get("/", h.Index) 77 + r.Get("/capabilities", h.Capabilities) 78 + r.Get("/version", h.Version) 79 + r.Route("/{did}", func(r chi.Router) { 80 + // Repo routes 81 + r.Route("/{name}", func(r chi.Router) { 82 + r.Route("/collaborator", func(r chi.Router) { 83 + r.Use(h.VerifySignature) 84 + r.Post("/add", h.AddRepoCollaborator) 85 + }) 331 86 332 - bytes := []byte(contents) 333 - // safe := string(sanitize(bytes)) 334 - sizeHint := len(bytes) 87 + r.Route("/languages", func(r chi.Router) { 88 + r.With(h.VerifySignature) 89 + r.Get("/", h.RepoLanguages) 90 + r.Get("/{ref}", h.RepoLanguages) 91 + }) 335 92 336 - resp := types.RepoBlobResponse{ 337 - Ref: ref, 338 - Contents: string(bytes), 339 - Path: treePath, 340 - IsBinary: isBinaryFile, 341 - SizeHint: uint64(sizeHint), 342 - } 343 - 344 - h.showFile(resp, w, l) 345 - } 346 - 347 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 - name := chi.URLParam(r, "name") 349 - file := chi.URLParam(r, "file") 350 - 351 - l := h.l.With("handler", "Archive", "name", name, "file", file) 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/info/refs", h.InfoRefs) 95 + r.Post("/git-upload-pack", h.UploadPack) 96 + r.Post("/git-receive-pack", h.ReceivePack) 97 + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 352 98 353 - // TODO: extend this to add more files compression (e.g.: xz) 354 - if !strings.HasSuffix(file, ".tar.gz") { 355 - notFound(w) 356 - return 357 - } 99 + r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 358 100 359 - ref := strings.TrimSuffix(file, ".tar.gz") 101 + r.Route("/merge", func(r chi.Router) { 102 + r.With(h.VerifySignature) 103 + r.Post("/", h.Merge) 104 + r.Post("/check", h.MergeCheck) 105 + }) 360 106 361 - unescapedRef, err := url.PathUnescape(ref) 362 - if err != nil { 363 - notFound(w) 364 - return 365 - } 107 + r.Route("/tree/{ref}", func(r chi.Router) { 108 + r.Get("/", h.RepoIndex) 109 + r.Get("/*", h.RepoTree) 110 + }) 366 111 367 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 112 + r.Route("/blob/{ref}", func(r chi.Router) { 113 + r.Get("/*", h.Blob) 114 + }) 368 115 369 - // This allows the browser to use a proper name for the file when 370 - // downloading 371 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 - setContentDisposition(w, filename) 373 - setGZipMIME(w) 116 + r.Route("/raw/{ref}", func(r chi.Router) { 117 + r.Get("/*", h.BlobRaw) 118 + }) 374 119 375 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 - gr, err := git.Open(path, unescapedRef) 377 - if err != nil { 378 - notFound(w) 379 - return 380 - } 381 - 382 - gw := gzip.NewWriter(w) 383 - defer gw.Close() 384 - 385 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 - err = gr.WriteTar(gw, prefix) 387 - if err != nil { 388 - // once we start writing to the body we can't report error anymore 389 - // so we are only left with printing the error. 390 - l.Error("writing tar file", "error", err.Error()) 391 - return 392 - } 393 - 394 - err = gw.Flush() 395 - if err != nil { 396 - // once we start writing to the body we can't report error anymore 397 - // so we are only left with printing the error. 398 - l.Error("flushing?", "error", err.Error()) 399 - return 400 - } 401 - } 402 - 403 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 - ref := chi.URLParam(r, "ref") 405 - ref, _ = url.PathUnescape(ref) 406 - 407 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 408 - 409 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 410 - 411 - gr, err := git.Open(path, ref) 412 - if err != nil { 413 - notFound(w) 414 - return 415 - } 416 - 417 - // Get page parameters 418 - page := 1 419 - pageSize := 30 420 - 421 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 - page = p 424 - } 425 - } 426 - 427 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 - pageSize = ps 430 - } 431 - } 432 - 433 - // convert to offset/limit 434 - offset := (page - 1) * pageSize 435 - limit := pageSize 436 - 437 - commits, err := gr.Commits(offset, limit) 438 - if err != nil { 439 - writeError(w, err.Error(), http.StatusInternalServerError) 440 - l.Error("fetching commits", "error", err.Error()) 441 - return 442 - } 443 - 444 - total := len(commits) 445 - 446 - resp := types.RepoLogResponse{ 447 - Commits: commits, 448 - Ref: ref, 449 - Description: getDescription(path), 450 - Log: true, 451 - Total: total, 452 - Page: page, 453 - PerPage: pageSize, 454 - } 455 - 456 - writeJSON(w, resp) 457 - } 458 - 459 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 - ref := chi.URLParam(r, "ref") 461 - ref, _ = url.PathUnescape(ref) 462 - 463 - l := h.l.With("handler", "Diff", "ref", ref) 464 - 465 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 - gr, err := git.Open(path, ref) 467 - if err != nil { 468 - notFound(w) 469 - return 470 - } 471 - 472 - diff, err := gr.Diff() 473 - if err != nil { 474 - writeError(w, err.Error(), http.StatusInternalServerError) 475 - l.Error("getting diff", "error", err.Error()) 476 - return 477 - } 478 - 479 - resp := types.RepoCommitResponse{ 480 - Ref: ref, 481 - Diff: diff, 482 - } 483 - 484 - writeJSON(w, resp) 485 - } 486 - 487 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 - l := h.l.With("handler", "Refs") 490 - 491 - gr, err := git.Open(path, "") 492 - if err != nil { 493 - notFound(w) 494 - return 495 - } 496 - 497 - tags, err := gr.Tags() 498 - if err != nil { 499 - // Non-fatal, we *should* have at least one branch to show. 500 - l.Warn("getting tags", "error", err.Error()) 501 - } 502 - 503 - rtags := []*types.TagReference{} 504 - for _, tag := range tags { 505 - var target *object.Tag 506 - if tag.Target != plumbing.ZeroHash { 507 - target = &tag 508 - } 509 - tr := types.TagReference{ 510 - Tag: target, 511 - } 512 - 513 - tr.Reference = types.Reference{ 514 - Name: tag.Name, 515 - Hash: tag.Hash.String(), 516 - } 517 - 518 - if tag.Message != "" { 519 - tr.Message = tag.Message 520 - } 120 + r.Get("/log/{ref}", h.Log) 121 + r.Get("/archive/{file}", h.Archive) 122 + r.Get("/commit/{ref}", h.Diff) 123 + r.Get("/tags", h.Tags) 124 + r.Route("/branches", func(r chi.Router) { 125 + r.Get("/", h.Branches) 126 + r.Get("/{branch}", h.Branch) 127 + r.Route("/default", func(r chi.Router) { 128 + r.Get("/", h.DefaultBranch) 129 + r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 130 + }) 131 + }) 132 + }) 133 + }) 521 134 522 - rtags = append(rtags, &tr) 523 - } 135 + // xrpc apis 136 + r.Mount("/xrpc", h.XrpcRouter()) 524 137 525 - resp := types.RepoTagsResponse{ 526 - Tags: rtags, 527 - } 138 + // Create a new repository. 139 + r.Route("/repo", func(r chi.Router) { 140 + r.Use(h.VerifySignature) 141 + r.Put("/new", h.NewRepo) 142 + r.Delete("/", h.RemoveRepo) 143 + r.Route("/fork", func(r chi.Router) { 144 + r.Post("/", h.RepoFork) 145 + r.Post("/sync/*", h.RepoForkSync) 146 + r.Get("/sync/*", h.RepoForkAheadBehind) 147 + }) 148 + }) 528 149 529 - writeJSON(w, resp) 530 - } 150 + r.Route("/member", func(r chi.Router) { 151 + r.Use(h.VerifySignature) 152 + r.Put("/add", h.AddMember) 153 + }) 531 154 532 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 155 + // Socket that streams git oplogs 156 + r.Get("/events", h.Events) 534 157 535 - gr, err := git.PlainOpen(path) 536 - if err != nil { 537 - notFound(w) 538 - return 539 - } 158 + // Initialize the knot with an owner and public key. 159 + r.With(h.VerifySignature).Post("/init", h.Init) 540 160 541 - branches, _ := gr.Branches() 161 + // Health check. Used for two-way verification with appview. 162 + r.With(h.VerifySignature).Get("/health", h.Health) 542 163 543 - resp := types.RepoBranchesResponse{ 544 - Branches: branches, 545 - } 164 + // All public keys on the knot. 165 + r.Get("/keys", h.Keys) 546 166 547 - writeJSON(w, resp) 167 + return r, nil 548 168 } 549 169 550 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 - branchName := chi.URLParam(r, "branch") 553 - branchName, _ = url.PathUnescape(branchName) 554 - 555 - l := h.l.With("handler", "Branch") 556 - 557 - gr, err := git.PlainOpen(path) 558 - if err != nil { 559 - notFound(w) 560 - return 561 - } 170 + func (h *Handle) XrpcRouter() http.Handler { 171 + logger := tlog.New("knots") 562 172 563 - ref, err := gr.Branch(branchName) 564 - if err != nil { 565 - l.Error("getting branch", "error", err.Error()) 566 - writeError(w, err.Error(), http.StatusInternalServerError) 567 - return 173 + xrpc := &xrpc.Xrpc{ 174 + Config: h.c, 175 + Db: h.db, 176 + Ingester: h.jc, 177 + Enforcer: h.e, 178 + Logger: logger, 179 + Notifier: h.n, 180 + Resolver: h.resolver, 568 181 } 569 - 570 - commit, err := gr.Commit(ref.Hash()) 571 - if err != nil { 572 - l.Error("getting commit object", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 - } 576 - 577 - defaultBranch, err := gr.FindMainBranch() 578 - isDefault := false 579 - if err != nil { 580 - l.Error("getting default branch", "error", err.Error()) 581 - // do not quit though 582 - } else if defaultBranch == branchName { 583 - isDefault = true 584 - } 585 - 586 - resp := types.RepoBranchResponse{ 587 - Branch: types.Branch{ 588 - Reference: types.Reference{ 589 - Name: ref.Name().Short(), 590 - Hash: ref.Hash().String(), 591 - }, 592 - Commit: commit, 593 - IsDefault: isDefault, 594 - }, 595 - } 596 - 597 - writeJSON(w, resp) 182 + return xrpc.Router() 598 183 } 599 184 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 185 + // version is set during build time. 186 + var version string 619 187 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 188 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 189 + if version == "" { 190 + info, ok := debug.ReadBuildInfo() 191 + if !ok { 192 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 624 193 return 625 194 } 626 195 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 196 + var modVer string 197 + for _, mod := range info.Deps { 198 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 199 + version = mod.Version 200 + break 201 + } 630 202 } 631 203 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 204 + if modVer == "" { 205 + version = "unknown" 636 206 } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 207 } 641 - } 642 208 643 - // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 - // l := h.l.With("handler", "RepoForkSync") 645 - // 646 - // data := struct { 647 - // Did string `json:"did"` 648 - // Source string `json:"source"` 649 - // Name string `json:"name,omitempty"` 650 - // HiddenRef string `json:"hiddenref"` 651 - // }{} 652 - // 653 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 - // writeError(w, "invalid request body", http.StatusBadRequest) 655 - // return 656 - // } 657 - // 658 - // did := data.Did 659 - // source := data.Source 660 - // 661 - // if did == "" || source == "" { 662 - // l.Error("invalid request body, empty did or name") 663 - // w.WriteHeader(http.StatusBadRequest) 664 - // return 665 - // } 666 - // 667 - // var name string 668 - // if data.Name != "" { 669 - // name = data.Name 670 - // } else { 671 - // name = filepath.Base(source) 672 - // } 673 - // 674 - // branch := chi.URLParam(r, "branch") 675 - // branch, _ = url.PathUnescape(branch) 676 - // 677 - // relativeRepoPath := filepath.Join(did, name) 678 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 - // 680 - // gr, err := git.PlainOpen(repoPath) 681 - // if err != nil { 682 - // log.Println(err) 683 - // notFound(w) 684 - // return 685 - // } 686 - // 687 - // forkCommit, err := gr.ResolveRevision(branch) 688 - // if err != nil { 689 - // l.Error("error resolving ref revision", "msg", err.Error()) 690 - // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 - // return 692 - // } 693 - // 694 - // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 - // if err != nil { 696 - // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 - // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 - // return 699 - // } 700 - // 701 - // status := types.UpToDate 702 - // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 - // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 - // if err != nil { 705 - // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 - // return 707 - // } 708 - // 709 - // if isAncestor { 710 - // status = types.FastForwardable 711 - // } else { 712 - // status = types.Conflict 713 - // } 714 - // } 715 - // 716 - // w.Header().Set("Content-Type", "application/json") 717 - // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 - // } 719 - 720 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 - ref := chi.URLParam(r, "ref") 723 - ref, _ = url.PathUnescape(ref) 724 - 725 - l := h.l.With("handler", "RepoLanguages") 726 - 727 - gr, err := git.Open(repoPath, ref) 728 - if err != nil { 729 - l.Error("opening repo", "error", err.Error()) 730 - notFound(w) 731 - return 732 - } 733 - 734 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 - defer cancel() 736 - 737 - sizes, err := gr.AnalyzeLanguages(ctx) 738 - if err != nil { 739 - l.Error("failed to analyze languages", "error", err.Error()) 740 - writeError(w, err.Error(), http.StatusNoContent) 741 - return 742 - } 743 - 744 - resp := types.RepoLanguageResponse{Languages: sizes} 745 - 746 - writeJSON(w, resp) 747 - } 748 - 749 - // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 - // l := h.l.With("handler", "RepoForkSync") 751 - // 752 - // data := struct { 753 - // Did string `json:"did"` 754 - // Source string `json:"source"` 755 - // Name string `json:"name,omitempty"` 756 - // }{} 757 - // 758 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 - // writeError(w, "invalid request body", http.StatusBadRequest) 760 - // return 761 - // } 762 - // 763 - // did := data.Did 764 - // source := data.Source 765 - // 766 - // if did == "" || source == "" { 767 - // l.Error("invalid request body, empty did or name") 768 - // w.WriteHeader(http.StatusBadRequest) 769 - // return 770 - // } 771 - // 772 - // var name string 773 - // if data.Name != "" { 774 - // name = data.Name 775 - // } else { 776 - // name = filepath.Base(source) 777 - // } 778 - // 779 - // branch := chi.URLParam(r, "branch") 780 - // branch, _ = url.PathUnescape(branch) 781 - // 782 - // relativeRepoPath := filepath.Join(did, name) 783 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 - // 785 - // gr, err := git.Open(repoPath, branch) 786 - // if err != nil { 787 - // log.Println(err) 788 - // notFound(w) 789 - // return 790 - // } 791 - // 792 - // err = gr.Sync() 793 - // if err != nil { 794 - // l.Error("error syncing repo fork", "error", err.Error()) 795 - // writeError(w, err.Error(), http.StatusInternalServerError) 796 - // return 797 - // } 798 - // 799 - // w.WriteHeader(http.StatusNoContent) 800 - // } 801 - 802 - // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 - // l := h.l.With("handler", "RepoFork") 804 - // 805 - // data := struct { 806 - // Did string `json:"did"` 807 - // Source string `json:"source"` 808 - // Name string `json:"name,omitempty"` 809 - // }{} 810 - // 811 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 - // writeError(w, "invalid request body", http.StatusBadRequest) 813 - // return 814 - // } 815 - // 816 - // did := data.Did 817 - // source := data.Source 818 - // 819 - // if did == "" || source == "" { 820 - // l.Error("invalid request body, empty did or name") 821 - // w.WriteHeader(http.StatusBadRequest) 822 - // return 823 - // } 824 - // 825 - // var name string 826 - // if data.Name != "" { 827 - // name = data.Name 828 - // } else { 829 - // name = filepath.Base(source) 830 - // } 831 - // 832 - // relativeRepoPath := filepath.Join(did, name) 833 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 - // 835 - // err := git.Fork(repoPath, source) 836 - // if err != nil { 837 - // l.Error("forking repo", "error", err.Error()) 838 - // writeError(w, err.Error(), http.StatusInternalServerError) 839 - // return 840 - // } 841 - // 842 - // // add perms for this user to access the repo 843 - // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 - // if err != nil { 845 - // l.Error("adding repo permissions", "error", err.Error()) 846 - // writeError(w, err.Error(), http.StatusInternalServerError) 847 - // return 848 - // } 849 - // 850 - // hook.SetupRepo( 851 - // hook.Config( 852 - // hook.WithScanPath(h.c.Repo.ScanPath), 853 - // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 - // ), 855 - // repoPath, 856 - // ) 857 - // 858 - // w.WriteHeader(http.StatusNoContent) 859 - // } 860 - 861 - // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 - // l := h.l.With("handler", "RemoveRepo") 863 - // 864 - // data := struct { 865 - // Did string `json:"did"` 866 - // Name string `json:"name"` 867 - // }{} 868 - // 869 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 - // writeError(w, "invalid request body", http.StatusBadRequest) 871 - // return 872 - // } 873 - // 874 - // did := data.Did 875 - // name := data.Name 876 - // 877 - // if did == "" || name == "" { 878 - // l.Error("invalid request body, empty did or name") 879 - // w.WriteHeader(http.StatusBadRequest) 880 - // return 881 - // } 882 - // 883 - // relativeRepoPath := filepath.Join(did, name) 884 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 - // err := os.RemoveAll(repoPath) 886 - // if err != nil { 887 - // l.Error("removing repo", "error", err.Error()) 888 - // writeError(w, err.Error(), http.StatusInternalServerError) 889 - // return 890 - // } 891 - // 892 - // w.WriteHeader(http.StatusNoContent) 893 - // 894 - // } 895 - 896 - // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 - // 899 - // data := types.MergeRequest{} 900 - // 901 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 - // writeError(w, err.Error(), http.StatusBadRequest) 903 - // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 - // return 905 - // } 906 - // 907 - // mo := &git.MergeOptions{ 908 - // AuthorName: data.AuthorName, 909 - // AuthorEmail: data.AuthorEmail, 910 - // CommitBody: data.CommitBody, 911 - // CommitMessage: data.CommitMessage, 912 - // } 913 - // 914 - // patch := data.Patch 915 - // branch := data.Branch 916 - // gr, err := git.Open(path, branch) 917 - // if err != nil { 918 - // notFound(w) 919 - // return 920 - // } 921 - // 922 - // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 - // 924 - // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 - // var mergeErr *git.ErrMerge 926 - // if errors.As(err, &mergeErr) { 927 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 - // for i, conflict := range mergeErr.Conflicts { 929 - // conflicts[i] = types.ConflictInfo{ 930 - // Filename: conflict.Filename, 931 - // Reason: conflict.Reason, 932 - // } 933 - // } 934 - // response := types.MergeCheckResponse{ 935 - // IsConflicted: true, 936 - // Conflicts: conflicts, 937 - // Message: mergeErr.Message, 938 - // } 939 - // writeConflict(w, response) 940 - // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 - // } else { 942 - // writeError(w, err.Error(), http.StatusBadRequest) 943 - // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 - // } 945 - // return 946 - // } 947 - // 948 - // w.WriteHeader(http.StatusOK) 949 - // } 950 - 951 - // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 - // 954 - // var data struct { 955 - // Patch string `json:"patch"` 956 - // Branch string `json:"branch"` 957 - // } 958 - // 959 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 - // writeError(w, err.Error(), http.StatusBadRequest) 961 - // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 - // return 963 - // } 964 - // 965 - // patch := data.Patch 966 - // branch := data.Branch 967 - // gr, err := git.Open(path, branch) 968 - // if err != nil { 969 - // notFound(w) 970 - // return 971 - // } 972 - // 973 - // err = gr.MergeCheck([]byte(patch), branch) 974 - // if err == nil { 975 - // response := types.MergeCheckResponse{ 976 - // IsConflicted: false, 977 - // } 978 - // writeJSON(w, response) 979 - // return 980 - // } 981 - // 982 - // var mergeErr *git.ErrMerge 983 - // if errors.As(err, &mergeErr) { 984 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 - // for i, conflict := range mergeErr.Conflicts { 986 - // conflicts[i] = types.ConflictInfo{ 987 - // Filename: conflict.Filename, 988 - // Reason: conflict.Reason, 989 - // } 990 - // } 991 - // response := types.MergeCheckResponse{ 992 - // IsConflicted: true, 993 - // Conflicts: conflicts, 994 - // Message: mergeErr.Message, 995 - // } 996 - // writeConflict(w, response) 997 - // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 - // return 999 - // } 1000 - // writeError(w, err.Error(), http.StatusInternalServerError) 1001 - // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 - // } 1003 - 1004 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 - rev1 := chi.URLParam(r, "rev1") 1006 - rev1, _ = url.PathUnescape(rev1) 1007 - 1008 - rev2 := chi.URLParam(r, "rev2") 1009 - rev2, _ = url.PathUnescape(rev2) 1010 - 1011 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 - 1013 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 - gr, err := git.PlainOpen(path) 1015 - if err != nil { 1016 - notFound(w) 1017 - return 1018 - } 1019 - 1020 - commit1, err := gr.ResolveRevision(rev1) 1021 - if err != nil { 1022 - l.Error("error resolving revision 1", "msg", err.Error()) 1023 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 - return 1025 - } 1026 - 1027 - commit2, err := gr.ResolveRevision(rev2) 1028 - if err != nil { 1029 - l.Error("error resolving revision 2", "msg", err.Error()) 1030 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 - return 1032 - } 1033 - 1034 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 - if err != nil { 1036 - l.Error("error comparing revisions", "msg", err.Error()) 1037 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 - return 1039 - } 1040 - 1041 - writeJSON(w, types.RepoFormatPatchResponse{ 1042 - Rev1: commit1.Hash.String(), 1043 - Rev2: commit2.Hash.String(), 1044 - FormatPatch: formatPatch, 1045 - Patch: rawPatch, 1046 - }) 1047 - } 1048 - 1049 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 - l := h.l.With("handler", "DefaultBranch") 1051 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 - 1053 - gr, err := git.Open(path, "") 1054 - if err != nil { 1055 - notFound(w) 1056 - return 1057 - } 1058 - 1059 - branch, err := gr.FindMainBranch() 1060 - if err != nil { 1061 - writeError(w, err.Error(), http.StatusInternalServerError) 1062 - l.Error("getting default branch", "error", err.Error()) 1063 - return 1064 - } 1065 - 1066 - writeJSON(w, types.RepoDefaultBranchResponse{ 1067 - Branch: branch, 1068 - }) 209 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 + fmt.Fprintf(w, "knotserver/%s", version) 1069 211 }
+10
knotserver/http_util.go
··· 20 20 func notFound(w http.ResponseWriter) { 21 21 writeError(w, "not found", http.StatusNotFound) 22 22 } 23 + 24 + func writeMsg(w http.ResponseWriter, msg string) { 25 + writeJSON(w, map[string]string{"msg": msg}) 26 + } 27 + 28 + func writeConflict(w http.ResponseWriter, data interface{}) { 29 + w.Header().Set("Content-Type", "application/json") 30 + w.WriteHeader(http.StatusConflict) 31 + json.NewEncoder(w).Encode(data) 32 + }
+35 -20
knotserver/ingester.go
··· 8 8 "net/http" 9 9 "net/url" 10 10 "path/filepath" 11 + "slices" 11 12 "strings" 12 13 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 73 74 } 74 75 l.Info("added member from firehose", "member", record.Subject) 75 76 76 - if err := h.db.AddDid(record.Subject); err != nil { 77 + if err := h.db.AddDid(did); err != nil { 77 78 l.Error("failed to add did", "error", err) 78 79 return fmt.Errorf("failed to add did: %w", err) 79 80 } 80 - h.jc.AddDid(record.Subject) 81 + h.jc.AddDid(did) 81 82 82 - if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 83 + if err := h.fetchAndAddKeys(ctx, did); err != nil { 83 84 return fmt.Errorf("failed to fetch and add keys: %w", err) 84 85 } 85 86 ··· 102 103 l = l.With("target_branch", record.TargetBranch) 103 104 104 105 if record.Source == nil { 105 - return fmt.Errorf("ignoring pull record: not a branch-based pull request") 106 + reason := "not a branch-based pull request" 107 + l.Info("ignoring pull record", "reason", reason) 108 + return fmt.Errorf("ignoring pull record: %s", reason) 106 109 } 107 110 108 111 if record.Source.Repo != nil { 109 - return fmt.Errorf("ignoring pull record: fork based pull") 112 + reason := "fork based pull" 113 + l.Info("ignoring pull record", "reason", reason) 114 + return fmt.Errorf("ignoring pull record: %s", reason) 115 + } 116 + 117 + allDids, err := h.db.GetAllDids() 118 + if err != nil { 119 + return err 120 + } 121 + 122 + // presently: we only process PRs from collaborators for pipelines 123 + if !slices.Contains(allDids, did) { 124 + reason := "not a known did" 125 + l.Info("rejecting pull record", "reason", reason) 126 + return fmt.Errorf("rejected pull record: %s, %s", reason, did) 110 127 } 111 128 112 129 repoAt, err := syntax.ParseATURI(record.TargetRepo) 113 130 if err != nil { 114 - return fmt.Errorf("failed to parse ATURI: %w", err) 131 + return err 115 132 } 116 133 117 134 // resolve this aturi to extract the repo record ··· 127 144 128 145 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 129 146 if err != nil { 130 - return fmt.Errorf("failed to resolver repo: %w", err) 147 + return err 131 148 } 132 149 133 150 repo := resp.Value.Val.(*tangled.Repo) 134 151 135 152 if repo.Knot != h.c.Server.Hostname { 136 - return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 153 + reason := "not this knot" 154 + l.Info("rejecting pull record", "reason", reason) 155 + return fmt.Errorf("rejected pull record: %s", reason) 137 156 } 138 157 139 158 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 140 159 if err != nil { 141 - return fmt.Errorf("failed to construct relative repo path: %w", err) 160 + return err 142 161 } 143 162 144 163 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 145 164 if err != nil { 146 - return fmt.Errorf("failed to construct absolute repo path: %w", err) 165 + return err 147 166 } 148 167 149 168 gr, err := git.Open(repoPath, record.Source.Branch) 150 169 if err != nil { 151 - return fmt.Errorf("failed to open git repository: %w", err) 170 + return err 152 171 } 153 172 154 173 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 155 174 if err != nil { 156 - return fmt.Errorf("failed to open workflow directory: %w", err) 175 + return err 157 176 } 158 177 159 178 var pipeline workflow.RawPipeline ··· 196 215 cp := compiler.Compile(compiler.Parse(pipeline)) 197 216 eventJson, err := json.Marshal(cp) 198 217 if err != nil { 199 - return fmt.Errorf("failed to marshal pipeline event: %w", err) 218 + return err 200 219 } 201 220 202 221 // do not run empty pipelines ··· 255 274 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 256 275 257 276 // check perms for this user 258 - ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 259 - if err != nil { 260 - return fmt.Errorf("failed to check permissions: %w", err) 261 - } 262 - if !ok { 263 - return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 277 + if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil { 278 + return fmt.Errorf("insufficient permissions: %w", err) 264 279 } 265 280 266 281 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 302 317 return fmt.Errorf("error reading response body: %w", err) 303 318 } 304 319 305 - for key := range strings.SplitSeq(string(plaintext), "\n") { 320 + for _, key := range strings.Split(string(plaintext), "\n") { 306 321 if key == "" { 307 322 continue 308 323 }
+2
knotserver/internal.go
··· 47 47 } 48 48 49 49 w.WriteHeader(http.StatusNoContent) 50 + return 50 51 } 51 52 52 53 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 62 63 data = append(data, j) 63 64 } 64 65 writeJSON(w, data) 66 + return 65 67 } 66 68 67 69 type PushOptions struct {
+53
knotserver/middleware.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "net/http" 8 + "time" 9 + ) 10 + 11 + func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 + if h.c.Server.Dev { 13 + return next 14 + } 15 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + signature := r.Header.Get("X-Signature") 17 + if signature == "" || !h.verifyHMAC(signature, r) { 18 + writeError(w, "signature verification failed", http.StatusForbidden) 19 + return 20 + } 21 + next.ServeHTTP(w, r) 22 + }) 23 + } 24 + 25 + func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 + secret := h.c.Server.Secret 27 + timestamp := r.Header.Get("X-Timestamp") 28 + if timestamp == "" { 29 + return false 30 + } 31 + 32 + // Verify that the timestamp is not older than a minute 33 + reqTime, err := time.Parse(time.RFC3339, timestamp) 34 + if err != nil { 35 + return false 36 + } 37 + if time.Since(reqTime) > time.Minute { 38 + return false 39 + } 40 + 41 + message := r.Method + r.URL.Path + timestamp 42 + 43 + mac := hmac.New(sha256.New, []byte(secret)) 44 + mac.Write([]byte(message)) 45 + expectedMAC := mac.Sum(nil) 46 + 47 + signatureBytes, err := hex.DecodeString(signature) 48 + if err != nil { 49 + return false 50 + } 51 + 52 + return hmac.Equal(signatureBytes, expectedMAC) 53 + }
+1292 -138
knotserver/routes.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 + "compress/gzip" 4 5 "context" 6 + "crypto/hmac" 7 + "crypto/sha256" 8 + "encoding/hex" 9 + "encoding/json" 10 + "errors" 5 11 "fmt" 6 - "log/slog" 12 + "log" 7 13 "net/http" 8 - "runtime/debug" 14 + "net/url" 15 + "os" 16 + "path/filepath" 17 + "strconv" 18 + "strings" 19 + "sync" 20 + "time" 9 21 22 + securejoin "github.com/cyphar/filepath-securejoin" 23 + "github.com/gliderlabs/ssh" 10 24 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 25 + gogit "github.com/go-git/go-git/v5" 26 + "github.com/go-git/go-git/v5/plumbing" 27 + "github.com/go-git/go-git/v5/plumbing/object" 28 + "tangled.sh/tangled.sh/core/hook" 14 29 "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 30 + "tangled.sh/tangled.sh/core/knotserver/git" 31 + "tangled.sh/tangled.sh/core/patchutil" 18 32 "tangled.sh/tangled.sh/core/rbac" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 33 + "tangled.sh/tangled.sh/core/types" 20 34 ) 21 35 22 - type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 - resolver *idresolver.Resolver 36 + func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 37 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 30 38 } 31 39 32 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 - r := chi.NewRouter() 40 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 + w.Header().Set("Content-Type", "application/json") 34 42 35 - h := Handle{ 36 - c: c, 37 - db: db, 38 - e: e, 39 - l: l, 40 - jc: jc, 41 - n: n, 42 - resolver: idresolver.DefaultResolver(), 43 + capabilities := map[string]any{ 44 + "pull_requests": map[string]any{ 45 + "format_patch": true, 46 + "patch_submissions": true, 47 + "branch_submissions": true, 48 + "fork_submissions": true, 49 + }, 50 + } 51 + 52 + jsonData, err := json.Marshal(capabilities) 53 + if err != nil { 54 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 + return 43 56 } 44 57 45 - err := e.AddKnot(rbac.ThisServer) 58 + w.Write(jsonData) 59 + } 60 + 61 + func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 62 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 63 + l := h.l.With("path", path, "handler", "RepoIndex") 64 + ref := chi.URLParam(r, "ref") 65 + ref, _ = url.PathUnescape(ref) 66 + 67 + gr, err := git.Open(path, ref) 46 68 if err != nil { 47 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 69 + plain, err2 := git.PlainOpen(path) 70 + if err2 != nil { 71 + l.Error("opening repo", "error", err2.Error()) 72 + notFound(w) 73 + return 74 + } 75 + branches, _ := plain.Branches() 76 + 77 + log.Println(err) 78 + 79 + if errors.Is(err, plumbing.ErrReferenceNotFound) { 80 + resp := types.RepoIndexResponse{ 81 + IsEmpty: true, 82 + Branches: branches, 83 + } 84 + writeJSON(w, resp) 85 + return 86 + } else { 87 + l.Error("opening repo", "error", err.Error()) 88 + notFound(w) 89 + return 90 + } 91 + } 92 + 93 + var ( 94 + commits []*object.Commit 95 + total int 96 + branches []types.Branch 97 + files []types.NiceTree 98 + tags []object.Tag 99 + ) 100 + 101 + var wg sync.WaitGroup 102 + errorsCh := make(chan error, 5) 103 + 104 + wg.Add(1) 105 + go func() { 106 + defer wg.Done() 107 + cs, err := gr.Commits(0, 60) 108 + if err != nil { 109 + errorsCh <- fmt.Errorf("commits: %w", err) 110 + return 111 + } 112 + commits = cs 113 + }() 114 + 115 + wg.Add(1) 116 + go func() { 117 + defer wg.Done() 118 + t, err := gr.TotalCommits() 119 + if err != nil { 120 + errorsCh <- fmt.Errorf("calculating total: %w", err) 121 + return 122 + } 123 + total = t 124 + }() 125 + 126 + wg.Add(1) 127 + go func() { 128 + defer wg.Done() 129 + bs, err := gr.Branches() 130 + if err != nil { 131 + errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 + return 133 + } 134 + branches = bs 135 + }() 136 + 137 + wg.Add(1) 138 + go func() { 139 + defer wg.Done() 140 + ts, err := gr.Tags() 141 + if err != nil { 142 + errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 + return 144 + } 145 + tags = ts 146 + }() 147 + 148 + wg.Add(1) 149 + go func() { 150 + defer wg.Done() 151 + fs, err := gr.FileTree(r.Context(), "") 152 + if err != nil { 153 + errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 + return 155 + } 156 + files = fs 157 + }() 158 + 159 + wg.Wait() 160 + close(errorsCh) 161 + 162 + // show any errors 163 + for err := range errorsCh { 164 + l.Error("loading repo", "error", err.Error()) 165 + writeError(w, err.Error(), http.StatusInternalServerError) 166 + return 167 + } 168 + 169 + rtags := []*types.TagReference{} 170 + for _, tag := range tags { 171 + var target *object.Tag 172 + if tag.Target != plumbing.ZeroHash { 173 + target = &tag 174 + } 175 + tr := types.TagReference{ 176 + Tag: target, 177 + } 178 + 179 + tr.Reference = types.Reference{ 180 + Name: tag.Name, 181 + Hash: tag.Hash.String(), 182 + } 183 + 184 + if tag.Message != "" { 185 + tr.Message = tag.Message 186 + } 187 + 188 + rtags = append(rtags, &tr) 189 + } 190 + 191 + var readmeContent string 192 + var readmeFile string 193 + for _, readme := range h.c.Repo.Readme { 194 + content, _ := gr.FileContent(readme) 195 + if len(content) > 0 { 196 + readmeContent = string(content) 197 + readmeFile = readme 198 + } 199 + } 200 + 201 + if ref == "" { 202 + mainBranch, err := gr.FindMainBranch() 203 + if err != nil { 204 + writeError(w, err.Error(), http.StatusInternalServerError) 205 + l.Error("finding main branch", "error", err.Error()) 206 + return 207 + } 208 + ref = mainBranch 209 + } 210 + 211 + resp := types.RepoIndexResponse{ 212 + IsEmpty: false, 213 + Ref: ref, 214 + Commits: commits, 215 + Description: getDescription(path), 216 + Readme: readmeContent, 217 + ReadmeFileName: readmeFile, 218 + Files: files, 219 + Branches: branches, 220 + Tags: rtags, 221 + TotalCommits: total, 222 + } 223 + 224 + writeJSON(w, resp) 225 + return 226 + } 227 + 228 + func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 229 + treePath := chi.URLParam(r, "*") 230 + ref := chi.URLParam(r, "ref") 231 + ref, _ = url.PathUnescape(ref) 232 + 233 + l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 234 + 235 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 236 + gr, err := git.Open(path, ref) 237 + if err != nil { 238 + notFound(w) 239 + return 240 + } 241 + 242 + files, err := gr.FileTree(r.Context(), treePath) 243 + if err != nil { 244 + writeError(w, err.Error(), http.StatusInternalServerError) 245 + l.Error("file tree", "error", err.Error()) 246 + return 247 + } 248 + 249 + resp := types.RepoTreeResponse{ 250 + Ref: ref, 251 + Parent: treePath, 252 + Description: getDescription(path), 253 + DotDot: filepath.Dir(treePath), 254 + Files: files, 255 + } 256 + 257 + writeJSON(w, resp) 258 + return 259 + } 260 + 261 + func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 262 + treePath := chi.URLParam(r, "*") 263 + ref := chi.URLParam(r, "ref") 264 + ref, _ = url.PathUnescape(ref) 265 + 266 + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 267 + 268 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 269 + gr, err := git.Open(path, ref) 270 + if err != nil { 271 + notFound(w) 272 + return 273 + } 274 + 275 + contents, err := gr.RawContent(treePath) 276 + if err != nil { 277 + writeError(w, err.Error(), http.StatusBadRequest) 278 + l.Error("file content", "error", err.Error()) 279 + return 280 + } 281 + 282 + mimeType := http.DetectContentType(contents) 283 + 284 + // exception for svg 285 + if filepath.Ext(treePath) == ".svg" { 286 + mimeType = "image/svg+xml" 287 + } 288 + 289 + contentHash := sha256.Sum256(contents) 290 + eTag := fmt.Sprintf("\"%x\"", contentHash) 291 + 292 + // allow image, video, and text/plain files to be served directly 293 + switch { 294 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 295 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 296 + w.WriteHeader(http.StatusNotModified) 297 + return 298 + } 299 + w.Header().Set("ETag", eTag) 300 + 301 + case strings.HasPrefix(mimeType, "text/plain"): 302 + w.Header().Set("Cache-Control", "public, no-cache") 303 + 304 + default: 305 + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 306 + writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 307 + return 308 + } 309 + 310 + w.Header().Set("Content-Type", mimeType) 311 + w.Write(contents) 312 + } 313 + 314 + func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 315 + treePath := chi.URLParam(r, "*") 316 + ref := chi.URLParam(r, "ref") 317 + ref, _ = url.PathUnescape(ref) 318 + 319 + l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 320 + 321 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 322 + gr, err := git.Open(path, ref) 323 + if err != nil { 324 + notFound(w) 325 + return 326 + } 327 + 328 + var isBinaryFile bool = false 329 + contents, err := gr.FileContent(treePath) 330 + if errors.Is(err, git.ErrBinaryFile) { 331 + isBinaryFile = true 332 + } else if errors.Is(err, object.ErrFileNotFound) { 333 + notFound(w) 334 + return 335 + } else if err != nil { 336 + writeError(w, err.Error(), http.StatusInternalServerError) 337 + return 338 + } 339 + 340 + bytes := []byte(contents) 341 + // safe := string(sanitize(bytes)) 342 + sizeHint := len(bytes) 343 + 344 + resp := types.RepoBlobResponse{ 345 + Ref: ref, 346 + Contents: string(bytes), 347 + Path: treePath, 348 + IsBinary: isBinaryFile, 349 + SizeHint: uint64(sizeHint), 48 350 } 49 351 50 - // configure owner 51 - if err = h.configureOwner(); err != nil { 52 - return nil, err 352 + h.showFile(resp, w, l) 353 + } 354 + 355 + func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 356 + name := chi.URLParam(r, "name") 357 + file := chi.URLParam(r, "file") 358 + 359 + l := h.l.With("handler", "Archive", "name", name, "file", file) 360 + 361 + // TODO: extend this to add more files compression (e.g.: xz) 362 + if !strings.HasSuffix(file, ".tar.gz") { 363 + notFound(w) 364 + return 53 365 } 54 - h.l.Info("owner set", "did", h.c.Server.Owner) 55 - h.jc.AddDid(h.c.Server.Owner) 366 + 367 + ref := strings.TrimSuffix(file, ".tar.gz") 56 368 57 - // configure known-dids in jetstream consumer 58 - dids, err := h.db.GetAllDids() 369 + unescapedRef, err := url.PathUnescape(ref) 59 370 if err != nil { 60 - return nil, fmt.Errorf("failed to get all dids: %w", err) 371 + notFound(w) 372 + return 61 373 } 62 - for _, d := range dids { 63 - jc.AddDid(d) 374 + 375 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 376 + 377 + // This allows the browser to use a proper name for the file when 378 + // downloading 379 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 380 + setContentDisposition(w, filename) 381 + setGZipMIME(w) 382 + 383 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 384 + gr, err := git.Open(path, unescapedRef) 385 + if err != nil { 386 + notFound(w) 387 + return 64 388 } 65 389 66 - err = h.jc.StartJetstream(ctx, h.processMessages) 390 + gw := gzip.NewWriter(w) 391 + defer gw.Close() 392 + 393 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 394 + err = gr.WriteTar(gw, prefix) 67 395 if err != nil { 68 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 396 + // once we start writing to the body we can't report error anymore 397 + // so we are only left with printing the error. 398 + l.Error("writing tar file", "error", err.Error()) 399 + return 400 + } 401 + 402 + err = gw.Flush() 403 + if err != nil { 404 + // once we start writing to the body we can't report error anymore 405 + // so we are only left with printing the error. 406 + l.Error("flushing?", "error", err.Error()) 407 + return 69 408 } 409 + } 410 + 411 + func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 412 + ref := chi.URLParam(r, "ref") 413 + ref, _ = url.PathUnescape(ref) 70 414 71 - r.Get("/", h.Index) 72 - r.Get("/capabilities", h.Capabilities) 73 - r.Get("/version", h.Version) 74 - r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 - w.Write([]byte(h.c.Server.Owner)) 76 - }) 77 - r.Route("/{did}", func(r chi.Router) { 78 - // Repo routes 79 - r.Route("/{name}", func(r chi.Router) { 415 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 80 416 81 - r.Route("/languages", func(r chi.Router) { 82 - r.Get("/", h.RepoLanguages) 83 - r.Get("/{ref}", h.RepoLanguages) 84 - }) 417 + l := h.l.With("handler", "Log", "ref", ref, "path", path) 85 418 86 - r.Get("/", h.RepoIndex) 87 - r.Get("/info/refs", h.InfoRefs) 88 - r.Post("/git-upload-pack", h.UploadPack) 89 - r.Post("/git-receive-pack", h.ReceivePack) 90 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 419 + gr, err := git.Open(path, ref) 420 + if err != nil { 421 + notFound(w) 422 + return 423 + } 91 424 92 - r.Route("/tree/{ref}", func(r chi.Router) { 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/*", h.RepoTree) 95 - }) 425 + // Get page parameters 426 + page := 1 427 + pageSize := 30 96 428 97 - r.Route("/blob/{ref}", func(r chi.Router) { 98 - r.Get("/*", h.Blob) 99 - }) 429 + if pageParam := r.URL.Query().Get("page"); pageParam != "" { 430 + if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 431 + page = p 432 + } 433 + } 100 434 101 - r.Route("/raw/{ref}", func(r chi.Router) { 102 - r.Get("/*", h.BlobRaw) 103 - }) 435 + if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 436 + if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 437 + pageSize = ps 438 + } 439 + } 104 440 105 - r.Get("/log/{ref}", h.Log) 106 - r.Get("/archive/{file}", h.Archive) 107 - r.Get("/commit/{ref}", h.Diff) 108 - r.Get("/tags", h.Tags) 109 - r.Route("/branches", func(r chi.Router) { 110 - r.Get("/", h.Branches) 111 - r.Get("/{branch}", h.Branch) 112 - r.Get("/default", h.DefaultBranch) 113 - }) 114 - }) 115 - }) 441 + // convert to offset/limit 442 + offset := (page - 1) * pageSize 443 + limit := pageSize 116 444 117 - // xrpc apis 118 - r.Mount("/xrpc", h.XrpcRouter()) 445 + commits, err := gr.Commits(offset, limit) 446 + if err != nil { 447 + writeError(w, err.Error(), http.StatusInternalServerError) 448 + l.Error("fetching commits", "error", err.Error()) 449 + return 450 + } 119 451 120 - // Socket that streams git oplogs 121 - r.Get("/events", h.Events) 452 + total := len(commits) 122 453 123 - // All public keys on the knot. 124 - r.Get("/keys", h.Keys) 454 + resp := types.RepoLogResponse{ 455 + Commits: commits, 456 + Ref: ref, 457 + Description: getDescription(path), 458 + Log: true, 459 + Total: total, 460 + Page: page, 461 + PerPage: pageSize, 462 + } 125 463 126 - return r, nil 464 + writeJSON(w, resp) 465 + return 127 466 } 128 467 129 - func (h *Handle) XrpcRouter() http.Handler { 130 - logger := tlog.New("knots") 468 + func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 469 + ref := chi.URLParam(r, "ref") 470 + ref, _ = url.PathUnescape(ref) 131 471 132 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 472 + l := h.l.With("handler", "Diff", "ref", ref) 133 473 134 - xrpc := &xrpc.Xrpc{ 135 - Config: h.c, 136 - Db: h.db, 137 - Ingester: h.jc, 138 - Enforcer: h.e, 139 - Logger: logger, 140 - Notifier: h.n, 141 - Resolver: h.resolver, 142 - ServiceAuth: serviceAuth, 474 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 475 + gr, err := git.Open(path, ref) 476 + if err != nil { 477 + notFound(w) 478 + return 479 + } 480 + 481 + diff, err := gr.Diff() 482 + if err != nil { 483 + writeError(w, err.Error(), http.StatusInternalServerError) 484 + l.Error("getting diff", "error", err.Error()) 485 + return 486 + } 487 + 488 + resp := types.RepoCommitResponse{ 489 + Ref: ref, 490 + Diff: diff, 143 491 } 144 - return xrpc.Router() 492 + 493 + writeJSON(w, resp) 494 + return 145 495 } 146 496 147 - // version is set during build time. 148 - var version string 497 + func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 498 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 499 + l := h.l.With("handler", "Refs") 500 + 501 + gr, err := git.Open(path, "") 502 + if err != nil { 503 + notFound(w) 504 + return 505 + } 506 + 507 + tags, err := gr.Tags() 508 + if err != nil { 509 + // Non-fatal, we *should* have at least one branch to show. 510 + l.Warn("getting tags", "error", err.Error()) 511 + } 149 512 150 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 - if version == "" { 152 - info, ok := debug.ReadBuildInfo() 153 - if !ok { 154 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 155 - return 513 + rtags := []*types.TagReference{} 514 + for _, tag := range tags { 515 + var target *object.Tag 516 + if tag.Target != plumbing.ZeroHash { 517 + target = &tag 518 + } 519 + tr := types.TagReference{ 520 + Tag: target, 156 521 } 157 522 158 - var modVer string 159 - for _, mod := range info.Deps { 160 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 - version = mod.Version 162 - break 163 - } 523 + tr.Reference = types.Reference{ 524 + Name: tag.Name, 525 + Hash: tag.Hash.String(), 164 526 } 165 527 166 - if modVer == "" { 167 - version = "unknown" 528 + if tag.Message != "" { 529 + tr.Message = tag.Message 168 530 } 531 + 532 + rtags = append(rtags, &tr) 169 533 } 170 534 171 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 - fmt.Fprintf(w, "knotserver/%s", version) 535 + resp := types.RepoTagsResponse{ 536 + Tags: rtags, 537 + } 538 + 539 + writeJSON(w, resp) 540 + return 173 541 } 174 542 175 - func (h *Handle) configureOwner() error { 176 - cfgOwner := h.c.Server.Owner 543 + func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 544 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 177 545 178 - rbacDomain := "thisserver" 546 + gr, err := git.PlainOpen(path) 547 + if err != nil { 548 + notFound(w) 549 + return 550 + } 179 551 180 - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 552 + branches, _ := gr.Branches() 553 + 554 + resp := types.RepoBranchesResponse{ 555 + Branches: branches, 556 + } 557 + 558 + writeJSON(w, resp) 559 + return 560 + } 561 + 562 + func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 563 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 564 + branchName := chi.URLParam(r, "branch") 565 + branchName, _ = url.PathUnescape(branchName) 566 + 567 + l := h.l.With("handler", "Branch") 568 + 569 + gr, err := git.PlainOpen(path) 181 570 if err != nil { 182 - return err 571 + notFound(w) 572 + return 573 + } 574 + 575 + ref, err := gr.Branch(branchName) 576 + if err != nil { 577 + l.Error("getting branch", "error", err.Error()) 578 + writeError(w, err.Error(), http.StatusInternalServerError) 579 + return 580 + } 581 + 582 + commit, err := gr.Commit(ref.Hash()) 583 + if err != nil { 584 + l.Error("getting commit object", "error", err.Error()) 585 + writeError(w, err.Error(), http.StatusInternalServerError) 586 + return 587 + } 588 + 589 + defaultBranch, err := gr.FindMainBranch() 590 + isDefault := false 591 + if err != nil { 592 + l.Error("getting default branch", "error", err.Error()) 593 + // do not quit though 594 + } else if defaultBranch == branchName { 595 + isDefault = true 183 596 } 184 597 185 - switch len(existing) { 186 - case 0: 187 - // no owner configured, continue 188 - case 1: 189 - // find existing owner 190 - existingOwner := existing[0] 598 + resp := types.RepoBranchResponse{ 599 + Branch: types.Branch{ 600 + Reference: types.Reference{ 601 + Name: ref.Name().Short(), 602 + Hash: ref.Hash().String(), 603 + }, 604 + Commit: commit, 605 + IsDefault: isDefault, 606 + }, 607 + } 608 + 609 + writeJSON(w, resp) 610 + return 611 + } 191 612 192 - // no ownership change, this is okay 193 - if existingOwner == h.c.Server.Owner { 194 - break 613 + func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 614 + l := h.l.With("handler", "Keys") 615 + 616 + switch r.Method { 617 + case http.MethodGet: 618 + keys, err := h.db.GetAllPublicKeys() 619 + if err != nil { 620 + writeError(w, err.Error(), http.StatusInternalServerError) 621 + l.Error("getting public keys", "error", err.Error()) 622 + return 195 623 } 196 624 197 - // remove existing owner 198 - err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 625 + data := make([]map[string]any, 0) 626 + for _, key := range keys { 627 + j := key.JSON() 628 + data = append(data, j) 629 + } 630 + writeJSON(w, data) 631 + return 632 + 633 + case http.MethodPut: 634 + pk := db.PublicKey{} 635 + if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 636 + writeError(w, "invalid request body", http.StatusBadRequest) 637 + return 638 + } 639 + 640 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 199 641 if err != nil { 200 - return nil 642 + writeError(w, "invalid pubkey", http.StatusBadRequest) 201 643 } 202 - default: 203 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 644 + 645 + if err := h.db.AddPublicKey(pk); err != nil { 646 + writeError(w, err.Error(), http.StatusInternalServerError) 647 + l.Error("adding public key", "error", err.Error()) 648 + return 649 + } 650 + 651 + w.WriteHeader(http.StatusNoContent) 652 + return 653 + } 654 + } 655 + 656 + func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 657 + l := h.l.With("handler", "NewRepo") 658 + 659 + data := struct { 660 + Did string `json:"did"` 661 + Name string `json:"name"` 662 + DefaultBranch string `json:"default_branch,omitempty"` 663 + }{} 664 + 665 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 666 + writeError(w, "invalid request body", http.StatusBadRequest) 667 + return 204 668 } 205 669 206 - return h.e.AddKnotOwner(rbacDomain, cfgOwner) 670 + if data.DefaultBranch == "" { 671 + data.DefaultBranch = h.c.Repo.MainBranch 672 + } 673 + 674 + did := data.Did 675 + name := data.Name 676 + defaultBranch := data.DefaultBranch 677 + 678 + if err := validateRepoName(name); err != nil { 679 + l.Error("creating repo", "error", err.Error()) 680 + writeError(w, err.Error(), http.StatusBadRequest) 681 + return 682 + } 683 + 684 + relativeRepoPath := filepath.Join(did, name) 685 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 686 + err := git.InitBare(repoPath, defaultBranch) 687 + if err != nil { 688 + l.Error("initializing bare repo", "error", err.Error()) 689 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 690 + writeError(w, "That repo already exists!", http.StatusConflict) 691 + return 692 + } else { 693 + writeError(w, err.Error(), http.StatusInternalServerError) 694 + return 695 + } 696 + } 697 + 698 + // add perms for this user to access the repo 699 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 700 + if err != nil { 701 + l.Error("adding repo permissions", "error", err.Error()) 702 + writeError(w, err.Error(), http.StatusInternalServerError) 703 + return 704 + } 705 + 706 + hook.SetupRepo( 707 + hook.Config( 708 + hook.WithScanPath(h.c.Repo.ScanPath), 709 + hook.WithInternalApi(h.c.Server.InternalListenAddr), 710 + ), 711 + repoPath, 712 + ) 713 + 714 + w.WriteHeader(http.StatusNoContent) 715 + } 716 + 717 + func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 718 + l := h.l.With("handler", "RepoForkAheadBehind") 719 + 720 + data := struct { 721 + Did string `json:"did"` 722 + Source string `json:"source"` 723 + Name string `json:"name,omitempty"` 724 + HiddenRef string `json:"hiddenref"` 725 + }{} 726 + 727 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 728 + writeError(w, "invalid request body", http.StatusBadRequest) 729 + return 730 + } 731 + 732 + did := data.Did 733 + source := data.Source 734 + 735 + if did == "" || source == "" { 736 + l.Error("invalid request body, empty did or name") 737 + w.WriteHeader(http.StatusBadRequest) 738 + return 739 + } 740 + 741 + var name string 742 + if data.Name != "" { 743 + name = data.Name 744 + } else { 745 + name = filepath.Base(source) 746 + } 747 + 748 + branch := chi.URLParam(r, "branch") 749 + branch, _ = url.PathUnescape(branch) 750 + 751 + relativeRepoPath := filepath.Join(did, name) 752 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 753 + 754 + gr, err := git.PlainOpen(repoPath) 755 + if err != nil { 756 + log.Println(err) 757 + notFound(w) 758 + return 759 + } 760 + 761 + forkCommit, err := gr.ResolveRevision(branch) 762 + if err != nil { 763 + l.Error("error resolving ref revision", "msg", err.Error()) 764 + writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 765 + return 766 + } 767 + 768 + sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 769 + if err != nil { 770 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 771 + writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 772 + return 773 + } 774 + 775 + status := types.UpToDate 776 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 777 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 778 + if err != nil { 779 + log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 780 + return 781 + } 782 + 783 + if isAncestor { 784 + status = types.FastForwardable 785 + } else { 786 + status = types.Conflict 787 + } 788 + } 789 + 790 + w.Header().Set("Content-Type", "application/json") 791 + json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 792 + } 793 + 794 + func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 795 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 796 + ref := chi.URLParam(r, "ref") 797 + ref, _ = url.PathUnescape(ref) 798 + 799 + l := h.l.With("handler", "RepoLanguages") 800 + 801 + gr, err := git.Open(repoPath, ref) 802 + if err != nil { 803 + l.Error("opening repo", "error", err.Error()) 804 + notFound(w) 805 + return 806 + } 807 + 808 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 809 + defer cancel() 810 + 811 + sizes, err := gr.AnalyzeLanguages(ctx) 812 + if err != nil { 813 + l.Error("failed to analyze languages", "error", err.Error()) 814 + writeError(w, err.Error(), http.StatusNoContent) 815 + return 816 + } 817 + 818 + resp := types.RepoLanguageResponse{Languages: sizes} 819 + 820 + writeJSON(w, resp) 821 + } 822 + 823 + func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 824 + l := h.l.With("handler", "RepoForkSync") 825 + 826 + data := struct { 827 + Did string `json:"did"` 828 + Source string `json:"source"` 829 + Name string `json:"name,omitempty"` 830 + }{} 831 + 832 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 833 + writeError(w, "invalid request body", http.StatusBadRequest) 834 + return 835 + } 836 + 837 + did := data.Did 838 + source := data.Source 839 + 840 + if did == "" || source == "" { 841 + l.Error("invalid request body, empty did or name") 842 + w.WriteHeader(http.StatusBadRequest) 843 + return 844 + } 845 + 846 + var name string 847 + if data.Name != "" { 848 + name = data.Name 849 + } else { 850 + name = filepath.Base(source) 851 + } 852 + 853 + branch := chi.URLParam(r, "*") 854 + branch, _ = url.PathUnescape(branch) 855 + 856 + relativeRepoPath := filepath.Join(did, name) 857 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 858 + 859 + gr, err := git.Open(repoPath, branch) 860 + if err != nil { 861 + log.Println(err) 862 + notFound(w) 863 + return 864 + } 865 + 866 + err = gr.Sync() 867 + if err != nil { 868 + l.Error("error syncing repo fork", "error", err.Error()) 869 + writeError(w, err.Error(), http.StatusInternalServerError) 870 + return 871 + } 872 + 873 + w.WriteHeader(http.StatusNoContent) 874 + } 875 + 876 + func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 877 + l := h.l.With("handler", "RepoFork") 878 + 879 + data := struct { 880 + Did string `json:"did"` 881 + Source string `json:"source"` 882 + Name string `json:"name,omitempty"` 883 + }{} 884 + 885 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 886 + writeError(w, "invalid request body", http.StatusBadRequest) 887 + return 888 + } 889 + 890 + did := data.Did 891 + source := data.Source 892 + 893 + if did == "" || source == "" { 894 + l.Error("invalid request body, empty did or name") 895 + w.WriteHeader(http.StatusBadRequest) 896 + return 897 + } 898 + 899 + var name string 900 + if data.Name != "" { 901 + name = data.Name 902 + } else { 903 + name = filepath.Base(source) 904 + } 905 + 906 + relativeRepoPath := filepath.Join(did, name) 907 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 908 + 909 + err := git.Fork(repoPath, source) 910 + if err != nil { 911 + l.Error("forking repo", "error", err.Error()) 912 + writeError(w, err.Error(), http.StatusInternalServerError) 913 + return 914 + } 915 + 916 + // add perms for this user to access the repo 917 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 918 + if err != nil { 919 + l.Error("adding repo permissions", "error", err.Error()) 920 + writeError(w, err.Error(), http.StatusInternalServerError) 921 + return 922 + } 923 + 924 + hook.SetupRepo( 925 + hook.Config( 926 + hook.WithScanPath(h.c.Repo.ScanPath), 927 + hook.WithInternalApi(h.c.Server.InternalListenAddr), 928 + ), 929 + repoPath, 930 + ) 931 + 932 + w.WriteHeader(http.StatusNoContent) 933 + } 934 + 935 + func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 936 + l := h.l.With("handler", "RemoveRepo") 937 + 938 + data := struct { 939 + Did string `json:"did"` 940 + Name string `json:"name"` 941 + }{} 942 + 943 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 944 + writeError(w, "invalid request body", http.StatusBadRequest) 945 + return 946 + } 947 + 948 + did := data.Did 949 + name := data.Name 950 + 951 + if did == "" || name == "" { 952 + l.Error("invalid request body, empty did or name") 953 + w.WriteHeader(http.StatusBadRequest) 954 + return 955 + } 956 + 957 + relativeRepoPath := filepath.Join(did, name) 958 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 959 + err := os.RemoveAll(repoPath) 960 + if err != nil { 961 + l.Error("removing repo", "error", err.Error()) 962 + writeError(w, err.Error(), http.StatusInternalServerError) 963 + return 964 + } 965 + 966 + w.WriteHeader(http.StatusNoContent) 967 + 968 + } 969 + func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 970 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 971 + 972 + data := types.MergeRequest{} 973 + 974 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 975 + writeError(w, err.Error(), http.StatusBadRequest) 976 + h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 977 + return 978 + } 979 + 980 + mo := &git.MergeOptions{ 981 + AuthorName: data.AuthorName, 982 + AuthorEmail: data.AuthorEmail, 983 + CommitBody: data.CommitBody, 984 + CommitMessage: data.CommitMessage, 985 + } 986 + 987 + patch := data.Patch 988 + branch := data.Branch 989 + gr, err := git.Open(path, branch) 990 + if err != nil { 991 + notFound(w) 992 + return 993 + } 994 + 995 + mo.FormatPatch = patchutil.IsFormatPatch(patch) 996 + 997 + if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 998 + var mergeErr *git.ErrMerge 999 + if errors.As(err, &mergeErr) { 1000 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1001 + for i, conflict := range mergeErr.Conflicts { 1002 + conflicts[i] = types.ConflictInfo{ 1003 + Filename: conflict.Filename, 1004 + Reason: conflict.Reason, 1005 + } 1006 + } 1007 + response := types.MergeCheckResponse{ 1008 + IsConflicted: true, 1009 + Conflicts: conflicts, 1010 + Message: mergeErr.Message, 1011 + } 1012 + writeConflict(w, response) 1013 + h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1014 + } else { 1015 + writeError(w, err.Error(), http.StatusBadRequest) 1016 + h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 1017 + } 1018 + return 1019 + } 1020 + 1021 + w.WriteHeader(http.StatusOK) 1022 + } 1023 + 1024 + func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1025 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1026 + 1027 + var data struct { 1028 + Patch string `json:"patch"` 1029 + Branch string `json:"branch"` 1030 + } 1031 + 1032 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1033 + writeError(w, err.Error(), http.StatusBadRequest) 1034 + h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1035 + return 1036 + } 1037 + 1038 + patch := data.Patch 1039 + branch := data.Branch 1040 + gr, err := git.Open(path, branch) 1041 + if err != nil { 1042 + notFound(w) 1043 + return 1044 + } 1045 + 1046 + err = gr.MergeCheck([]byte(patch), branch) 1047 + if err == nil { 1048 + response := types.MergeCheckResponse{ 1049 + IsConflicted: false, 1050 + } 1051 + writeJSON(w, response) 1052 + return 1053 + } 1054 + 1055 + var mergeErr *git.ErrMerge 1056 + if errors.As(err, &mergeErr) { 1057 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1058 + for i, conflict := range mergeErr.Conflicts { 1059 + conflicts[i] = types.ConflictInfo{ 1060 + Filename: conflict.Filename, 1061 + Reason: conflict.Reason, 1062 + } 1063 + } 1064 + response := types.MergeCheckResponse{ 1065 + IsConflicted: true, 1066 + Conflicts: conflicts, 1067 + Message: mergeErr.Message, 1068 + } 1069 + writeConflict(w, response) 1070 + h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1071 + return 1072 + } 1073 + writeError(w, err.Error(), http.StatusInternalServerError) 1074 + h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1075 + } 1076 + 1077 + func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1078 + rev1 := chi.URLParam(r, "rev1") 1079 + rev1, _ = url.PathUnescape(rev1) 1080 + 1081 + rev2 := chi.URLParam(r, "rev2") 1082 + rev2, _ = url.PathUnescape(rev2) 1083 + 1084 + l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1085 + 1086 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1087 + gr, err := git.PlainOpen(path) 1088 + if err != nil { 1089 + notFound(w) 1090 + return 1091 + } 1092 + 1093 + commit1, err := gr.ResolveRevision(rev1) 1094 + if err != nil { 1095 + l.Error("error resolving revision 1", "msg", err.Error()) 1096 + writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1097 + return 1098 + } 1099 + 1100 + commit2, err := gr.ResolveRevision(rev2) 1101 + if err != nil { 1102 + l.Error("error resolving revision 2", "msg", err.Error()) 1103 + writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1104 + return 1105 + } 1106 + 1107 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1108 + if err != nil { 1109 + l.Error("error comparing revisions", "msg", err.Error()) 1110 + writeError(w, "error comparing revisions", http.StatusBadRequest) 1111 + return 1112 + } 1113 + 1114 + writeJSON(w, types.RepoFormatPatchResponse{ 1115 + Rev1: commit1.Hash.String(), 1116 + Rev2: commit2.Hash.String(), 1117 + FormatPatch: formatPatch, 1118 + Patch: rawPatch, 1119 + }) 1120 + return 1121 + } 1122 + 1123 + func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1124 + l := h.l.With("handler", "NewHiddenRef") 1125 + 1126 + forkRef := chi.URLParam(r, "forkRef") 1127 + forkRef, _ = url.PathUnescape(forkRef) 1128 + 1129 + remoteRef := chi.URLParam(r, "remoteRef") 1130 + remoteRef, _ = url.PathUnescape(remoteRef) 1131 + 1132 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1133 + gr, err := git.PlainOpen(path) 1134 + if err != nil { 1135 + notFound(w) 1136 + return 1137 + } 1138 + 1139 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1140 + if err != nil { 1141 + l.Error("error tracking hidden remote ref", "msg", err.Error()) 1142 + writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1143 + return 1144 + } 1145 + 1146 + w.WriteHeader(http.StatusNoContent) 1147 + return 1148 + } 1149 + 1150 + func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1151 + l := h.l.With("handler", "AddMember") 1152 + 1153 + data := struct { 1154 + Did string `json:"did"` 1155 + }{} 1156 + 1157 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1158 + writeError(w, "invalid request body", http.StatusBadRequest) 1159 + return 1160 + } 1161 + 1162 + did := data.Did 1163 + 1164 + if err := h.db.AddDid(did); err != nil { 1165 + l.Error("adding did", "error", err.Error()) 1166 + writeError(w, err.Error(), http.StatusInternalServerError) 1167 + return 1168 + } 1169 + h.jc.AddDid(did) 1170 + 1171 + if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { 1172 + l.Error("adding member", "error", err.Error()) 1173 + writeError(w, err.Error(), http.StatusInternalServerError) 1174 + return 1175 + } 1176 + 1177 + if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1178 + l.Error("fetching and adding keys", "error", err.Error()) 1179 + writeError(w, err.Error(), http.StatusInternalServerError) 1180 + return 1181 + } 1182 + 1183 + w.WriteHeader(http.StatusNoContent) 1184 + } 1185 + 1186 + func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1187 + l := h.l.With("handler", "AddRepoCollaborator") 1188 + 1189 + data := struct { 1190 + Did string `json:"did"` 1191 + }{} 1192 + 1193 + ownerDid := chi.URLParam(r, "did") 1194 + repo := chi.URLParam(r, "name") 1195 + 1196 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1197 + writeError(w, "invalid request body", http.StatusBadRequest) 1198 + return 1199 + } 1200 + 1201 + if err := h.db.AddDid(data.Did); err != nil { 1202 + l.Error("adding did", "error", err.Error()) 1203 + writeError(w, err.Error(), http.StatusInternalServerError) 1204 + return 1205 + } 1206 + h.jc.AddDid(data.Did) 1207 + 1208 + repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1209 + if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { 1210 + l.Error("adding repo collaborator", "error", err.Error()) 1211 + writeError(w, err.Error(), http.StatusInternalServerError) 1212 + return 1213 + } 1214 + 1215 + if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1216 + l.Error("fetching and adding keys", "error", err.Error()) 1217 + writeError(w, err.Error(), http.StatusInternalServerError) 1218 + return 1219 + } 1220 + 1221 + w.WriteHeader(http.StatusNoContent) 1222 + } 1223 + 1224 + func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1225 + l := h.l.With("handler", "DefaultBranch") 1226 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1227 + 1228 + gr, err := git.Open(path, "") 1229 + if err != nil { 1230 + notFound(w) 1231 + return 1232 + } 1233 + 1234 + branch, err := gr.FindMainBranch() 1235 + if err != nil { 1236 + writeError(w, err.Error(), http.StatusInternalServerError) 1237 + l.Error("getting default branch", "error", err.Error()) 1238 + return 1239 + } 1240 + 1241 + writeJSON(w, types.RepoDefaultBranchResponse{ 1242 + Branch: branch, 1243 + }) 1244 + } 1245 + 1246 + func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1247 + l := h.l.With("handler", "SetDefaultBranch") 1248 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1249 + 1250 + data := struct { 1251 + Branch string `json:"branch"` 1252 + }{} 1253 + 1254 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1255 + writeError(w, err.Error(), http.StatusBadRequest) 1256 + return 1257 + } 1258 + 1259 + gr, err := git.PlainOpen(path) 1260 + if err != nil { 1261 + notFound(w) 1262 + return 1263 + } 1264 + 1265 + err = gr.SetDefaultBranch(data.Branch) 1266 + if err != nil { 1267 + writeError(w, err.Error(), http.StatusInternalServerError) 1268 + l.Error("setting default branch", "error", err.Error()) 1269 + return 1270 + } 1271 + 1272 + w.WriteHeader(http.StatusNoContent) 1273 + } 1274 + 1275 + func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1276 + l := h.l.With("handler", "Init") 1277 + 1278 + if h.knotInitialized { 1279 + writeError(w, "knot already initialized", http.StatusConflict) 1280 + return 1281 + } 1282 + 1283 + data := struct { 1284 + Did string `json:"did"` 1285 + }{} 1286 + 1287 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1288 + l.Error("failed to decode request body", "error", err.Error()) 1289 + writeError(w, "invalid request body", http.StatusBadRequest) 1290 + return 1291 + } 1292 + 1293 + if data.Did == "" { 1294 + l.Error("empty DID in request", "did", data.Did) 1295 + writeError(w, "did is empty", http.StatusBadRequest) 1296 + return 1297 + } 1298 + 1299 + if err := h.db.AddDid(data.Did); err != nil { 1300 + l.Error("failed to add DID", "error", err.Error()) 1301 + writeError(w, err.Error(), http.StatusInternalServerError) 1302 + return 1303 + } 1304 + h.jc.AddDid(data.Did) 1305 + 1306 + if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil { 1307 + l.Error("adding owner", "error", err.Error()) 1308 + writeError(w, err.Error(), http.StatusInternalServerError) 1309 + return 1310 + } 1311 + 1312 + if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1313 + l.Error("fetching and adding keys", "error", err.Error()) 1314 + writeError(w, err.Error(), http.StatusInternalServerError) 1315 + return 1316 + } 1317 + 1318 + close(h.init) 1319 + 1320 + mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1321 + mac.Write([]byte("ok")) 1322 + w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1323 + 1324 + w.WriteHeader(http.StatusNoContent) 1325 + } 1326 + 1327 + func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1328 + w.Write([]byte("ok")) 1329 + } 1330 + 1331 + func validateRepoName(name string) error { 1332 + // check for path traversal attempts 1333 + if name == "." || name == ".." || 1334 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 1335 + return fmt.Errorf("Repository name contains invalid path characters") 1336 + } 1337 + 1338 + // check for sequences that could be used for traversal when normalized 1339 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 1340 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1341 + return fmt.Errorf("Repository name contains invalid path sequence") 1342 + } 1343 + 1344 + // then continue with character validation 1345 + for _, char := range name { 1346 + if !((char >= 'a' && char <= 'z') || 1347 + (char >= 'A' && char <= 'Z') || 1348 + (char >= '0' && char <= '9') || 1349 + char == '-' || char == '_' || char == '.') { 1350 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1351 + } 1352 + } 1353 + 1354 + // additional check to prevent multiple sequential dots 1355 + if strings.Contains(name, "..") { 1356 + return fmt.Errorf("Repository name cannot contain sequential dots") 1357 + } 1358 + 1359 + // if all checks pass 1360 + return nil 207 1361 }
-156
knotserver/xrpc/create_repo.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "errors" 6 - "fmt" 7 - "net/http" 8 - "path/filepath" 9 - "strings" 10 - 11 - comatproto "github.com/bluesky-social/indigo/api/atproto" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "github.com/bluesky-social/indigo/xrpc" 14 - securejoin "github.com/cyphar/filepath-securejoin" 15 - gogit "github.com/go-git/go-git/v5" 16 - "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/hook" 18 - "tangled.sh/tangled.sh/core/knotserver/git" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 21 - ) 22 - 23 - func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 24 - l := h.Logger.With("handler", "NewRepo") 25 - fail := func(e xrpcerr.XrpcError) { 26 - l.Error("failed", "kind", e.Tag, "error", e.Message) 27 - writeError(w, e, http.StatusBadRequest) 28 - } 29 - 30 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 31 - if !ok { 32 - fail(xrpcerr.MissingActorDidError) 33 - return 34 - } 35 - 36 - isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 37 - if err != nil { 38 - fail(xrpcerr.GenericError(err)) 39 - return 40 - } 41 - if !isMember { 42 - fail(xrpcerr.AccessControlError(actorDid.String())) 43 - return 44 - } 45 - 46 - var data tangled.RepoCreate_Input 47 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 48 - fail(xrpcerr.GenericError(err)) 49 - return 50 - } 51 - 52 - rkey := data.Rkey 53 - 54 - ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) 55 - if err != nil || ident.Handle.IsInvalidHandle() { 56 - fail(xrpcerr.GenericError(err)) 57 - return 58 - } 59 - 60 - xrpcc := xrpc.Client{ 61 - Host: ident.PDSEndpoint(), 62 - } 63 - 64 - resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 65 - if err != nil { 66 - fail(xrpcerr.GenericError(err)) 67 - return 68 - } 69 - 70 - repo := resp.Value.Val.(*tangled.Repo) 71 - 72 - defaultBranch := h.Config.Repo.MainBranch 73 - if data.DefaultBranch != nil && *data.DefaultBranch != "" { 74 - defaultBranch = *data.DefaultBranch 75 - } 76 - 77 - if err := validateRepoName(repo.Name); err != nil { 78 - l.Error("creating repo", "error", err.Error()) 79 - fail(xrpcerr.GenericError(err)) 80 - return 81 - } 82 - 83 - relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) 84 - repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 - 86 - if data.Source != nil && *data.Source != "" { 87 - err = git.Fork(repoPath, *data.Source) 88 - if err != nil { 89 - l.Error("forking repo", "error", err.Error()) 90 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 - return 92 - } 93 - } else { 94 - err = git.InitBare(repoPath, defaultBranch) 95 - if err != nil { 96 - l.Error("initializing bare repo", "error", err.Error()) 97 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 98 - fail(xrpcerr.RepoExistsError("repository already exists")) 99 - return 100 - } else { 101 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 - return 103 - } 104 - } 105 - } 106 - 107 - // add perms for this user to access the repo 108 - err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath) 109 - if err != nil { 110 - l.Error("adding repo permissions", "error", err.Error()) 111 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 - return 113 - } 114 - 115 - hook.SetupRepo( 116 - hook.Config( 117 - hook.WithScanPath(h.Config.Repo.ScanPath), 118 - hook.WithInternalApi(h.Config.Server.InternalListenAddr), 119 - ), 120 - repoPath, 121 - ) 122 - 123 - w.WriteHeader(http.StatusOK) 124 - } 125 - 126 - func validateRepoName(name string) error { 127 - // check for path traversal attempts 128 - if name == "." || name == ".." || 129 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 130 - return fmt.Errorf("Repository name contains invalid path characters") 131 - } 132 - 133 - // check for sequences that could be used for traversal when normalized 134 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 135 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 136 - return fmt.Errorf("Repository name contains invalid path sequence") 137 - } 138 - 139 - // then continue with character validation 140 - for _, char := range name { 141 - if !((char >= 'a' && char <= 'z') || 142 - (char >= 'A' && char <= 'Z') || 143 - (char >= '0' && char <= '9') || 144 - char == '-' || char == '_' || char == '.') { 145 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 146 - } 147 - } 148 - 149 - // additional check to prevent multiple sequential dots 150 - if strings.Contains(name, "..") { 151 - return fmt.Errorf("Repository name cannot contain sequential dots") 152 - } 153 - 154 - // if all checks pass 155 - return nil 156 - }
-96
knotserver/xrpc/delete_repo.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "os" 8 - "path/filepath" 9 - 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/indigo/xrpc" 13 - securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/rbac" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 - ) 18 - 19 - func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 - l := x.Logger.With("handler", "DeleteRepo") 21 - fail := func(e xrpcerr.XrpcError) { 22 - l.Error("failed", "kind", e.Tag, "error", e.Message) 23 - writeError(w, e, http.StatusBadRequest) 24 - } 25 - 26 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 - if !ok { 28 - fail(xrpcerr.MissingActorDidError) 29 - return 30 - } 31 - 32 - var data tangled.RepoDelete_Input 33 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(xrpcerr.GenericError(err)) 35 - return 36 - } 37 - 38 - did := data.Did 39 - name := data.Name 40 - rkey := data.Rkey 41 - 42 - if did == "" || name == "" { 43 - fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 - return 45 - } 46 - 47 - ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 - if err != nil || ident.Handle.IsInvalidHandle() { 49 - fail(xrpcerr.GenericError(err)) 50 - return 51 - } 52 - 53 - xrpcc := xrpc.Client{ 54 - Host: ident.PDSEndpoint(), 55 - } 56 - 57 - // ensure that the record does not exists 58 - _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 - if err == nil { 60 - fail(xrpcerr.RecordExistsError(rkey)) 61 - return 62 - } 63 - 64 - relativeRepoPath := filepath.Join(did, name) 65 - isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 - if err != nil { 67 - fail(xrpcerr.GenericError(err)) 68 - return 69 - } 70 - if !isDeleteAllowed { 71 - fail(xrpcerr.AccessControlError(actorDid.String())) 72 - return 73 - } 74 - 75 - repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 - if err != nil { 77 - fail(xrpcerr.GenericError(err)) 78 - return 79 - } 80 - 81 - err = os.RemoveAll(repoPath) 82 - if err != nil { 83 - l.Error("deleting repo", "error", err.Error()) 84 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 - return 86 - } 87 - 88 - err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 - if err != nil { 90 - l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 - return 93 - } 94 - 95 - w.WriteHeader(http.StatusOK) 96 - }
-111
knotserver/xrpc/fork_status.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "path/filepath" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/types" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 - ) 17 - 18 - func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 - l := x.Logger.With("handler", "ForkStatus") 20 - fail := func(e xrpcerr.XrpcError) { 21 - l.Error("failed", "kind", e.Tag, "error", e.Message) 22 - writeError(w, e, http.StatusBadRequest) 23 - } 24 - 25 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 - if !ok { 27 - fail(xrpcerr.MissingActorDidError) 28 - return 29 - } 30 - 31 - var data tangled.RepoForkStatus_Input 32 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(xrpcerr.GenericError(err)) 34 - return 35 - } 36 - 37 - did := data.Did 38 - source := data.Source 39 - branch := data.Branch 40 - hiddenRef := data.HiddenRef 41 - 42 - if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 - fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 - return 45 - } 46 - 47 - var name string 48 - if data.Name != "" { 49 - name = data.Name 50 - } else { 51 - name = filepath.Base(source) 52 - } 53 - 54 - relativeRepoPath := filepath.Join(did, name) 55 - 56 - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 - l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 - return 60 - } 61 - 62 - repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 - if err != nil { 64 - fail(xrpcerr.GenericError(err)) 65 - return 66 - } 67 - 68 - gr, err := git.PlainOpen(repoPath) 69 - if err != nil { 70 - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 - return 72 - } 73 - 74 - forkCommit, err := gr.ResolveRevision(branch) 75 - if err != nil { 76 - l.Error("error resolving ref revision", "msg", err.Error()) 77 - fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 - return 79 - } 80 - 81 - sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 - if err != nil { 83 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 - fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 - return 86 - } 87 - 88 - status := types.UpToDate 89 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 - if err != nil { 92 - l.Error("error checking ancestor relationship", "error", err.Error()) 93 - fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 - return 95 - } 96 - 97 - if isAncestor { 98 - status = types.FastForwardable 99 - } else { 100 - status = types.Conflict 101 - } 102 - } 103 - 104 - response := tangled.RepoForkStatus_Output{ 105 - Status: int64(status), 106 - } 107 - 108 - w.Header().Set("Content-Type", "application/json") 109 - w.WriteHeader(http.StatusOK) 110 - json.NewEncoder(w).Encode(response) 111 - }
-73
knotserver/xrpc/fork_sync.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "path/filepath" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 - ) 16 - 17 - func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 - l := x.Logger.With("handler", "ForkSync") 19 - fail := func(e xrpcerr.XrpcError) { 20 - l.Error("failed", "kind", e.Tag, "error", e.Message) 21 - writeError(w, e, http.StatusBadRequest) 22 - } 23 - 24 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 - if !ok { 26 - fail(xrpcerr.MissingActorDidError) 27 - return 28 - } 29 - 30 - var data tangled.RepoForkSync_Input 31 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 - fail(xrpcerr.GenericError(err)) 33 - return 34 - } 35 - 36 - did := data.Did 37 - name := data.Name 38 - branch := data.Branch 39 - 40 - if did == "" || name == "" { 41 - fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) 42 - return 43 - } 44 - 45 - relativeRepoPath := filepath.Join(did, name) 46 - 47 - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 - l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 - return 51 - } 52 - 53 - repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 - if err != nil { 55 - fail(xrpcerr.GenericError(err)) 56 - return 57 - } 58 - 59 - gr, err := git.Open(repoPath, branch) 60 - if err != nil { 61 - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 - return 63 - } 64 - 65 - err = gr.Sync() 66 - if err != nil { 67 - l.Error("error syncing repo fork", "error", err.Error()) 68 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 - return 70 - } 71 - 72 - w.WriteHeader(http.StatusOK) 73 - }
-104
knotserver/xrpc/hidden_ref.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - 8 - comatproto "github.com/bluesky-social/indigo/api/atproto" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/bluesky-social/indigo/xrpc" 11 - securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 - ) 17 - 18 - func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 - l := x.Logger.With("handler", "HiddenRef") 20 - fail := func(e xrpcerr.XrpcError) { 21 - l.Error("failed", "kind", e.Tag, "error", e.Message) 22 - writeError(w, e, http.StatusBadRequest) 23 - } 24 - 25 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 - if !ok { 27 - fail(xrpcerr.MissingActorDidError) 28 - return 29 - } 30 - 31 - var data tangled.RepoHiddenRef_Input 32 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(xrpcerr.GenericError(err)) 34 - return 35 - } 36 - 37 - forkRef := data.ForkRef 38 - remoteRef := data.RemoteRef 39 - repoAtUri := data.Repo 40 - 41 - if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 - fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 - return 44 - } 45 - 46 - repoAt, err := syntax.ParseATURI(repoAtUri) 47 - if err != nil { 48 - fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 - return 50 - } 51 - 52 - ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 - if err != nil || ident.Handle.IsInvalidHandle() { 54 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 - return 56 - } 57 - 58 - xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 - resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 - if err != nil { 61 - fail(xrpcerr.GenericError(err)) 62 - return 63 - } 64 - 65 - repo := resp.Value.Val.(*tangled.Repo) 66 - didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 - if err != nil { 68 - fail(xrpcerr.GenericError(err)) 69 - return 70 - } 71 - 72 - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 - l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 - return 76 - } 77 - 78 - repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 - if err != nil { 80 - fail(xrpcerr.GenericError(err)) 81 - return 82 - } 83 - 84 - gr, err := git.PlainOpen(repoPath) 85 - if err != nil { 86 - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 - return 88 - } 89 - 90 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 - if err != nil { 92 - l.Error("error tracking hidden remote ref", "error", err.Error()) 93 - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 - return 95 - } 96 - 97 - response := tangled.RepoHiddenRef_Output{ 98 - Success: true, 99 - } 100 - 101 - w.Header().Set("Content-Type", "application/json") 102 - w.WriteHeader(http.StatusOK) 103 - json.NewEncoder(w).Encode(response) 104 - }
-112
knotserver/xrpc/merge.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "errors" 6 - "fmt" 7 - "net/http" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/patchutil" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/types" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 - ) 18 - 19 - func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 - l := x.Logger.With("handler", "Merge") 21 - fail := func(e xrpcerr.XrpcError) { 22 - l.Error("failed", "kind", e.Tag, "error", e.Message) 23 - writeError(w, e, http.StatusBadRequest) 24 - } 25 - 26 - actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 - if !ok { 28 - fail(xrpcerr.MissingActorDidError) 29 - return 30 - } 31 - 32 - var data tangled.RepoMerge_Input 33 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(xrpcerr.GenericError(err)) 35 - return 36 - } 37 - 38 - did := data.Did 39 - name := data.Name 40 - 41 - if did == "" || name == "" { 42 - fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 - return 44 - } 45 - 46 - relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 - if err != nil { 48 - fail(xrpcerr.GenericError(err)) 49 - return 50 - } 51 - 52 - if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 - l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 - return 56 - } 57 - 58 - repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 - if err != nil { 60 - fail(xrpcerr.GenericError(err)) 61 - return 62 - } 63 - 64 - gr, err := git.Open(repoPath, data.Branch) 65 - if err != nil { 66 - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 - return 68 - } 69 - 70 - mo := &git.MergeOptions{} 71 - if data.AuthorName != nil { 72 - mo.AuthorName = *data.AuthorName 73 - } 74 - if data.AuthorEmail != nil { 75 - mo.AuthorEmail = *data.AuthorEmail 76 - } 77 - if data.CommitBody != nil { 78 - mo.CommitBody = *data.CommitBody 79 - } 80 - if data.CommitMessage != nil { 81 - mo.CommitMessage = *data.CommitMessage 82 - } 83 - 84 - mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 - 86 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 87 - if err != nil { 88 - var mergeErr *git.ErrMerge 89 - if errors.As(err, &mergeErr) { 90 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 91 - for i, conflict := range mergeErr.Conflicts { 92 - conflicts[i] = types.ConflictInfo{ 93 - Filename: conflict.Filename, 94 - Reason: conflict.Reason, 95 - } 96 - } 97 - 98 - conflictErr := xrpcerr.NewXrpcError( 99 - xrpcerr.WithTag("MergeConflict"), 100 - xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 101 - ) 102 - writeError(w, conflictErr, http.StatusConflict) 103 - return 104 - } else { 105 - l.Error("failed to merge", "error", err.Error()) 106 - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 107 - return 108 - } 109 - } 110 - 111 - w.WriteHeader(http.StatusOK) 112 - }
-87
knotserver/xrpc/merge_check.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "errors" 6 - "fmt" 7 - "net/http" 8 - 9 - securejoin "github.com/cyphar/filepath-securejoin" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 - ) 14 - 15 - func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 16 - l := x.Logger.With("handler", "MergeCheck") 17 - fail := func(e xrpcerr.XrpcError) { 18 - l.Error("failed", "kind", e.Tag, "error", e.Message) 19 - writeError(w, e, http.StatusBadRequest) 20 - } 21 - 22 - var data tangled.RepoMergeCheck_Input 23 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 24 - fail(xrpcerr.GenericError(err)) 25 - return 26 - } 27 - 28 - did := data.Did 29 - name := data.Name 30 - 31 - if did == "" || name == "" { 32 - fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 33 - return 34 - } 35 - 36 - relativeRepoPath, err := securejoin.SecureJoin(did, name) 37 - if err != nil { 38 - fail(xrpcerr.GenericError(err)) 39 - return 40 - } 41 - 42 - repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 43 - if err != nil { 44 - fail(xrpcerr.GenericError(err)) 45 - return 46 - } 47 - 48 - gr, err := git.Open(repoPath, data.Branch) 49 - if err != nil { 50 - fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 51 - return 52 - } 53 - 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 - 56 - response := tangled.RepoMergeCheck_Output{ 57 - Is_conflicted: false, 58 - } 59 - 60 - if err != nil { 61 - var mergeErr *git.ErrMerge 62 - if errors.As(err, &mergeErr) { 63 - response.Is_conflicted = true 64 - 65 - conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 66 - for i, conflict := range mergeErr.Conflicts { 67 - conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 68 - Filename: conflict.Filename, 69 - Reason: conflict.Reason, 70 - } 71 - } 72 - response.Conflicts = conflicts 73 - 74 - if mergeErr.Message != "" { 75 - response.Message = &mergeErr.Message 76 - } 77 - } else { 78 - response.Is_conflicted = true 79 - errMsg := err.Error() 80 - response.Error = &errMsg 81 - } 82 - } 83 - 84 - w.Header().Set("Content-Type", "application/json") 85 - w.WriteHeader(http.StatusOK) 86 - json.NewEncoder(w).Encode(response) 87 - }
+149
knotserver/xrpc/router.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "strings" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/jetstream" 14 + "tangled.sh/tangled.sh/core/knotserver/config" 15 + "tangled.sh/tangled.sh/core/knotserver/db" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + 19 + "github.com/bluesky-social/indigo/atproto/auth" 20 + "github.com/go-chi/chi/v5" 21 + ) 22 + 23 + type Xrpc struct { 24 + Config *config.Config 25 + Db *db.DB 26 + Ingester *jetstream.JetstreamClient 27 + Enforcer *rbac.Enforcer 28 + Logger *slog.Logger 29 + Notifier *notifier.Notifier 30 + Resolver *idresolver.Resolver 31 + } 32 + 33 + func (x *Xrpc) Router() http.Handler { 34 + r := chi.NewRouter() 35 + 36 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 37 + 38 + return r 39 + } 40 + 41 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 42 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 + l := x.Logger.With("url", r.URL) 44 + 45 + token := r.Header.Get("Authorization") 46 + token = strings.TrimPrefix(token, "Bearer ") 47 + 48 + s := auth.ServiceAuthValidator{ 49 + Audience: x.Config.Server.Did().String(), 50 + Dir: x.Resolver.Directory(), 51 + } 52 + 53 + did, err := s.Validate(r.Context(), token, nil) 54 + if err != nil { 55 + l.Error("signature verification failed", "err", err) 56 + writeError(w, AuthError(err), http.StatusForbidden) 57 + return 58 + } 59 + 60 + r = r.WithContext( 61 + context.WithValue(r.Context(), ActorDid, did), 62 + ) 63 + 64 + next.ServeHTTP(w, r) 65 + }) 66 + } 67 + 68 + type XrpcError struct { 69 + Tag string `json:"error"` 70 + Message string `json:"message"` 71 + } 72 + 73 + func NewXrpcError(opts ...ErrOpt) XrpcError { 74 + x := XrpcError{} 75 + for _, o := range opts { 76 + o(&x) 77 + } 78 + 79 + return x 80 + } 81 + 82 + type ErrOpt = func(xerr *XrpcError) 83 + 84 + func WithTag(tag string) ErrOpt { 85 + return func(xerr *XrpcError) { 86 + xerr.Tag = tag 87 + } 88 + } 89 + 90 + func WithMessage[S ~string](s S) ErrOpt { 91 + return func(xerr *XrpcError) { 92 + xerr.Message = string(s) 93 + } 94 + } 95 + 96 + func WithError(e error) ErrOpt { 97 + return func(xerr *XrpcError) { 98 + xerr.Message = e.Error() 99 + } 100 + } 101 + 102 + var MissingActorDidError = NewXrpcError( 103 + WithTag("MissingActorDid"), 104 + WithMessage("actor DID not supplied"), 105 + ) 106 + 107 + var AuthError = func(err error) XrpcError { 108 + return NewXrpcError( 109 + WithTag("Auth"), 110 + WithError(fmt.Errorf("signature verification failed: %w", err)), 111 + ) 112 + } 113 + 114 + var InvalidRepoError = func(r string) XrpcError { 115 + return NewXrpcError( 116 + WithTag("InvalidRepo"), 117 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 118 + ) 119 + } 120 + 121 + var AccessControlError = func(d string) XrpcError { 122 + return NewXrpcError( 123 + WithTag("AccessControl"), 124 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 125 + ) 126 + } 127 + 128 + var GitError = func(e error) XrpcError { 129 + return NewXrpcError( 130 + WithTag("Git"), 131 + WithError(fmt.Errorf("git error: %w", e)), 132 + ) 133 + } 134 + 135 + func GenericError(err error) XrpcError { 136 + return NewXrpcError( 137 + WithTag("Generic"), 138 + WithError(err), 139 + ) 140 + } 141 + 142 + // this is slightly different from http_util::write_error to follow the spec: 143 + // 144 + // the json object returned must include an "error" and a "message" 145 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 146 + w.Header().Set("Content-Type", "application/json") 147 + w.WriteHeader(status) 148 + json.NewEncoder(w).Encode(e) 149 + }
+10 -12
knotserver/xrpc/set_default_branch.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/knotserver/git" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 - 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 15 ) 18 16 19 17 const ActorDid string = "ActorDid" 20 18 21 19 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 22 20 l := x.Logger 23 - fail := func(e xrpcerr.XrpcError) { 21 + fail := func(e XrpcError) { 24 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 25 23 writeError(w, e, http.StatusBadRequest) 26 24 } 27 25 28 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 27 if !ok { 30 - fail(xrpcerr.MissingActorDidError) 28 + fail(MissingActorDidError) 31 29 return 32 30 } 33 31 34 32 var data tangled.RepoSetDefaultBranch_Input 35 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 36 - fail(xrpcerr.GenericError(err)) 34 + fail(GenericError(err)) 37 35 return 38 36 } 39 37 40 38 // unfortunately we have to resolve repo-at here 41 39 repoAt, err := syntax.ParseATURI(data.Repo) 42 40 if err != nil { 43 - fail(xrpcerr.InvalidRepoError(data.Repo)) 41 + fail(InvalidRepoError(data.Repo)) 44 42 return 45 43 } 46 44 47 45 // resolve this aturi to extract the repo record 48 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 49 47 if err != nil || ident.Handle.IsInvalidHandle() { 50 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 51 49 return 52 50 } 53 51 54 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 55 53 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 56 54 if err != nil { 57 - fail(xrpcerr.GenericError(err)) 55 + fail(GenericError(err)) 58 56 return 59 57 } 60 58 61 59 repo := resp.Value.Val.(*tangled.Repo) 62 60 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 63 61 if err != nil { 64 - fail(xrpcerr.GenericError(err)) 62 + fail(GenericError(err)) 65 63 return 66 64 } 67 65 68 66 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 69 67 l.Error("insufficent permissions", "did", actorDid.String()) 70 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 71 69 return 72 70 } 73 71 74 72 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 75 73 gr, err := git.PlainOpen(path) 76 74 if err != nil { 77 - fail(xrpcerr.GenericError(err)) 75 + fail(InvalidRepoError(data.Repo)) 78 76 return 79 77 } 80 78 81 79 err = gr.SetDefaultBranch(data.DefaultBranch) 82 80 if err != nil { 83 81 l.Error("setting default branch", "error", err.Error()) 84 - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 82 + writeError(w, GitError(err), http.StatusInternalServerError) 85 83 return 86 84 } 87 85
-60
knotserver/xrpc/xrpc.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "encoding/json" 5 - "log/slog" 6 - "net/http" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/idresolver" 10 - "tangled.sh/tangled.sh/core/jetstream" 11 - "tangled.sh/tangled.sh/core/knotserver/config" 12 - "tangled.sh/tangled.sh/core/knotserver/db" 13 - "tangled.sh/tangled.sh/core/notifier" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 17 - 18 - "github.com/go-chi/chi/v5" 19 - ) 20 - 21 - type Xrpc struct { 22 - Config *config.Config 23 - Db *db.DB 24 - Ingester *jetstream.JetstreamClient 25 - Enforcer *rbac.Enforcer 26 - Logger *slog.Logger 27 - Notifier *notifier.Notifier 28 - Resolver *idresolver.Resolver 29 - ServiceAuth *serviceauth.ServiceAuth 30 - } 31 - 32 - func (x *Xrpc) Router() http.Handler { 33 - r := chi.NewRouter() 34 - 35 - r.Group(func(r chi.Router) { 36 - r.Use(x.ServiceAuth.VerifyServiceAuth) 37 - 38 - r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 39 - r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 40 - r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 41 - r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 - r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 43 - r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 44 - r.Post("/"+tangled.RepoMergeNSID, x.Merge) 45 - }) 46 - 47 - // merge check is an open endpoint 48 - // 49 - // TODO: should we constrain this more? 50 - // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 - // - use ETags on clients to keep requests to a minimum 52 - r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 - return r 54 - } 55 - 56 - func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 - w.Header().Set("Content-Type", "application/json") 58 - w.WriteHeader(status) 59 - json.NewEncoder(w).Encode(e) 60 - }
+1 -8
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "issue", 14 - "body", 15 - "createdAt" 16 - ], 12 + "required": ["issue", "body", "createdAt"], 17 13 "properties": { 18 14 "issue": { 19 15 "type": "string", ··· 22 18 "repo": { 23 19 "type": "string", 24 20 "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 21 }, 29 22 "owner": { 30 23 "type": "string",
+1 -10
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "owner", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 17 }, 27 18 "owner": { 28 19 "type": "string",
-24
lexicons/knot/knot.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.knot", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - }
-33
lexicons/repo/create.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.create", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Create a new repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "rkey" 14 - ], 15 - "properties": { 16 - "rkey": { 17 - "type": "string", 18 - "description": "Rkey of the repository record" 19 - }, 20 - "defaultBranch": { 21 - "type": "string", 22 - "description": "Default branch to push to" 23 - }, 24 - "source": { 25 - "type": "string", 26 - "description": "A source URL to clone from, populate this when forking or importing a repository." 27 - } 28 - } 29 - } 30 - } 31 - } 32 - } 33 - }
-32
lexicons/repo/delete.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.delete", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Delete a repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["did", "name", "rkey"], 13 - "properties": { 14 - "did": { 15 - "type": "string", 16 - "format": "did", 17 - "description": "DID of the repository owner" 18 - }, 19 - "name": { 20 - "type": "string", 21 - "description": "Name of the repository to delete" 22 - }, 23 - "rkey": { 24 - "type": "string", 25 - "description": "Rkey of the repository record" 26 - } 27 - } 28 - } 29 - } 30 - } 31 - } 32 - }
-53
lexicons/repo/forkStatus.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.forkStatus", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Check fork status relative to upstream source", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["did", "name", "source", "branch", "hiddenRef"], 13 - "properties": { 14 - "did": { 15 - "type": "string", 16 - "format": "did", 17 - "description": "DID of the fork owner" 18 - }, 19 - "name": { 20 - "type": "string", 21 - "description": "Name of the forked repository" 22 - }, 23 - "source": { 24 - "type": "string", 25 - "description": "Source repository URL" 26 - }, 27 - "branch": { 28 - "type": "string", 29 - "description": "Branch to check status for" 30 - }, 31 - "hiddenRef": { 32 - "type": "string", 33 - "description": "Hidden ref to use for comparison" 34 - } 35 - } 36 - } 37 - }, 38 - "output": { 39 - "encoding": "application/json", 40 - "schema": { 41 - "type": "object", 42 - "required": ["status"], 43 - "properties": { 44 - "status": { 45 - "type": "integer", 46 - "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 - } 48 - } 49 - } 50 - } 51 - } 52 - } 53 - }
-42
lexicons/repo/forkSync.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.forkSync", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Sync a forked repository with its upstream source", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "did", 14 - "source", 15 - "name", 16 - "branch" 17 - ], 18 - "properties": { 19 - "did": { 20 - "type": "string", 21 - "format": "did", 22 - "description": "DID of the fork owner" 23 - }, 24 - "source": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "AT-URI of the source repository" 28 - }, 29 - "name": { 30 - "type": "string", 31 - "description": "Name of the forked repository" 32 - }, 33 - "branch": { 34 - "type": "string", 35 - "description": "Branch to sync" 36 - } 37 - } 38 - } 39 - } 40 - } 41 - } 42 - }
-59
lexicons/repo/hiddenRef.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.hiddenRef", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Create a hidden ref in a repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "forkRef", 15 - "remoteRef" 16 - ], 17 - "properties": { 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri", 21 - "description": "AT-URI of the repository" 22 - }, 23 - "forkRef": { 24 - "type": "string", 25 - "description": "Fork reference name" 26 - }, 27 - "remoteRef": { 28 - "type": "string", 29 - "description": "Remote reference name" 30 - } 31 - } 32 - } 33 - }, 34 - "output": { 35 - "encoding": "application/json", 36 - "schema": { 37 - "type": "object", 38 - "required": [ 39 - "success" 40 - ], 41 - "properties": { 42 - "success": { 43 - "type": "boolean", 44 - "description": "Whether the hidden ref was created successfully" 45 - }, 46 - "ref": { 47 - "type": "string", 48 - "description": "The created hidden ref name" 49 - }, 50 - "error": { 51 - "type": "string", 52 - "description": "Error message if creation failed" 53 - } 54 - } 55 - } 56 - } 57 - } 58 - } 59 - }
-52
lexicons/repo/merge.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.merge", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Merge a patch into a repository branch", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["did", "name", "patch", "branch"], 13 - "properties": { 14 - "did": { 15 - "type": "string", 16 - "format": "did", 17 - "description": "DID of the repository owner" 18 - }, 19 - "name": { 20 - "type": "string", 21 - "description": "Name of the repository" 22 - }, 23 - "patch": { 24 - "type": "string", 25 - "description": "Patch content to merge" 26 - }, 27 - "branch": { 28 - "type": "string", 29 - "description": "Target branch to merge into" 30 - }, 31 - "authorName": { 32 - "type": "string", 33 - "description": "Author name for the merge commit" 34 - }, 35 - "authorEmail": { 36 - "type": "string", 37 - "description": "Author email for the merge commit" 38 - }, 39 - "commitBody": { 40 - "type": "string", 41 - "description": "Additional commit message body" 42 - }, 43 - "commitMessage": { 44 - "type": "string", 45 - "description": "Merge commit message" 46 - } 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
-79
lexicons/repo/mergeCheck.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.mergeCheck", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Check if a merge is possible between two branches", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["did", "name", "patch", "branch"], 13 - "properties": { 14 - "did": { 15 - "type": "string", 16 - "format": "did", 17 - "description": "DID of the repository owner" 18 - }, 19 - "name": { 20 - "type": "string", 21 - "description": "Name of the repository" 22 - }, 23 - "patch": { 24 - "type": "string", 25 - "description": "Patch or pull request to check for merge conflicts" 26 - }, 27 - "branch": { 28 - "type": "string", 29 - "description": "Target branch to merge into" 30 - } 31 - } 32 - } 33 - }, 34 - "output": { 35 - "encoding": "application/json", 36 - "schema": { 37 - "type": "object", 38 - "required": ["is_conflicted"], 39 - "properties": { 40 - "is_conflicted": { 41 - "type": "boolean", 42 - "description": "Whether the merge has conflicts" 43 - }, 44 - "conflicts": { 45 - "type": "array", 46 - "description": "List of files with merge conflicts", 47 - "items": { 48 - "type": "ref", 49 - "ref": "#conflictInfo" 50 - } 51 - }, 52 - "message": { 53 - "type": "string", 54 - "description": "Additional message about the merge check" 55 - }, 56 - "error": { 57 - "type": "string", 58 - "description": "Error message if check failed" 59 - } 60 - } 61 - } 62 - } 63 - }, 64 - "conflictInfo": { 65 - "type": "object", 66 - "required": ["filename", "reason"], 67 - "properties": { 68 - "filename": { 69 - "type": "string", 70 - "description": "Name of the conflicted file" 71 - }, 72 - "reason": { 73 - "type": "string", 74 - "description": "Reason for the conflict" 75 - } 76 - } 77 - } 78 - } 79 - }
+5 -5
nix/modules/knot.nix
··· 93 93 description = "Internal address for inter-service communication"; 94 94 }; 95 95 96 - owner = mkOption { 97 - type = types.str; 98 - example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 - description = "DID of owner (required)"; 96 + secretFile = mkOption { 97 + type = lib.types.path; 98 + example = "KNOT_SERVER_SECRET=<hash>"; 99 + description = "File containing secret key provided by appview (required)"; 100 100 }; 101 101 102 102 dbPath = mkOption { ··· 199 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 - "KNOT_SERVER_OWNER=${cfg.server.owner}" 203 202 ]; 203 + EnvironmentFile = cfg.server.secretFile; 204 204 ExecStart = "${cfg.package}/bin/knot server"; 205 205 Restart = "always"; 206 206 };
+1 -2
nix/vm.nix
··· 70 70 }; 71 71 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 72 networking.firewall.enable = false; 73 - time.timeZone = "Europe/London"; 74 73 services.getty.autologinUser = "root"; 75 74 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 75 services.tangled-knot = { 77 76 enable = true; 78 77 motd = "Welcome to the development knot!\n"; 79 78 server = { 80 - owner = envVar "TANGLED_VM_KNOT_OWNER"; 79 + secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 81 80 hostname = "localhost:6000"; 82 81 listenAddr = "0.0.0.0:6000"; 83 82 };
-13
rbac/rbac.go
··· 100 100 return err 101 101 } 102 102 103 - func (e *Enforcer) RemoveKnot(knot string) error { 104 - _, err := e.E.DeleteDomains(knot) 105 - return err 106 - } 107 - 108 103 func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) { 109 104 keepFunc := isNotSpindle 110 105 stripFunc := unSpindle ··· 275 270 276 271 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 277 272 return e.isInviteAllowed(user, intoSpindle(domain)) 278 - } 279 - 280 - func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 - return e.E.Enforce(user, domain, domain, "repo:create") 282 - } 283 - 284 - func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 - return e.E.Enforce(user, domain, repo, "repo:delete") 286 273 } 287 274 288 275 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
-3
spindle/engines/nixery/engine.go
··· 201 201 Tty: false, 202 202 Hostname: "spindle", 203 203 WorkingDir: workspaceDir, 204 - Labels: map[string]string{ 205 - "sh.tangled.pipeline/workflow_id": wid.String(), 206 - }, 207 204 // TODO(winter): investigate whether environment variables passed here 208 205 // get propagated to ContainerExec processes 209 206 }, &container.HostConfig{
+7 -11
spindle/server.go
··· 25 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 26 "tangled.sh/tangled.sh/core/spindle/secrets" 27 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 29 28 ) 30 29 31 30 //go:embed motd ··· 214 213 func (s *Spindle) XrpcRouter() http.Handler { 215 214 logger := s.l.With("route", "xrpc") 216 215 217 - serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 - 219 216 x := xrpc.Xrpc{ 220 - Logger: logger, 221 - Db: s.db, 222 - Enforcer: s.e, 223 - Engines: s.engs, 224 - Config: s.cfg, 225 - Resolver: s.res, 226 - Vault: s.vault, 227 - ServiceAuth: serviceAuth, 217 + Logger: logger, 218 + Db: s.db, 219 + Enforcer: s.e, 220 + Engines: s.engs, 221 + Config: s.cfg, 222 + Resolver: s.res, 223 + Vault: s.vault, 228 224 } 229 225 230 226 return x.Router()
+10 -11
spindle/xrpc/add_secret.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 16 ) 18 17 19 18 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 19 l := x.Logger 21 - fail := func(e xrpcerr.XrpcError) { 20 + fail := func(e XrpcError) { 22 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 22 writeError(w, e, http.StatusBadRequest) 24 23 } 25 24 26 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 26 if !ok { 28 - fail(xrpcerr.MissingActorDidError) 27 + fail(MissingActorDidError) 29 28 return 30 29 } 31 30 32 31 var data tangled.RepoAddSecret_Input 33 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(xrpcerr.GenericError(err)) 33 + fail(GenericError(err)) 35 34 return 36 35 } 37 36 38 37 if err := secrets.ValidateKey(data.Key); err != nil { 39 - fail(xrpcerr.GenericError(err)) 38 + fail(GenericError(err)) 40 39 return 41 40 } 42 41 43 42 // unfortunately we have to resolve repo-at here 44 43 repoAt, err := syntax.ParseATURI(data.Repo) 45 44 if err != nil { 46 - fail(xrpcerr.InvalidRepoError(data.Repo)) 45 + fail(InvalidRepoError(data.Repo)) 47 46 return 48 47 } 49 48 50 49 // resolve this aturi to extract the repo record 51 50 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 51 if err != nil || ident.Handle.IsInvalidHandle() { 53 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 52 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 53 return 55 54 } 56 55 57 56 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 57 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 58 if err != nil { 60 - fail(xrpcerr.GenericError(err)) 59 + fail(GenericError(err)) 61 60 return 62 61 } 63 62 64 63 repo := resp.Value.Val.(*tangled.Repo) 65 64 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 65 if err != nil { 67 - fail(xrpcerr.GenericError(err)) 66 + fail(GenericError(err)) 68 67 return 69 68 } 70 69 71 70 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 71 l.Error("insufficent permissions", "did", actorDid.String()) 73 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 72 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 73 return 75 74 } 76 75 ··· 84 83 err = x.Vault.AddSecret(r.Context(), secret) 85 84 if err != nil { 86 85 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 86 + writeError(w, GenericError(err), http.StatusInternalServerError) 88 87 return 89 88 } 90 89
+9 -10
spindle/xrpc/list_secrets.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 16 ) 18 17 19 18 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 19 l := x.Logger 21 - fail := func(e xrpcerr.XrpcError) { 20 + fail := func(e XrpcError) { 22 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 22 writeError(w, e, http.StatusBadRequest) 24 23 } 25 24 26 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 26 if !ok { 28 - fail(xrpcerr.MissingActorDidError) 27 + fail(MissingActorDidError) 29 28 return 30 29 } 31 30 32 31 repoParam := r.URL.Query().Get("repo") 33 32 if repoParam == "" { 34 - fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 33 + fail(GenericError(fmt.Errorf("empty params"))) 35 34 return 36 35 } 37 36 38 37 // unfortunately we have to resolve repo-at here 39 38 repoAt, err := syntax.ParseATURI(repoParam) 40 39 if err != nil { 41 - fail(xrpcerr.InvalidRepoError(repoParam)) 40 + fail(InvalidRepoError(repoParam)) 42 41 return 43 42 } 44 43 45 44 // resolve this aturi to extract the repo record 46 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 46 if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 48 return 50 49 } 51 50 52 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 53 if err != nil { 55 - fail(xrpcerr.GenericError(err)) 54 + fail(GenericError(err)) 56 55 return 57 56 } 58 57 59 58 repo := resp.Value.Val.(*tangled.Repo) 60 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 60 if err != nil { 62 - fail(xrpcerr.GenericError(err)) 61 + fail(GenericError(err)) 63 62 return 64 63 } 65 64 66 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 66 l.Error("insufficent permissions", "did", actorDid.String()) 68 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 68 return 70 69 } 71 70 72 71 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 72 if err != nil { 74 73 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 74 + writeError(w, GenericError(err), http.StatusInternalServerError) 76 75 return 77 76 } 78 77
+9 -10
spindle/xrpc/remove_secret.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/rbac" 14 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 15 ) 17 16 18 17 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 18 l := x.Logger 20 - fail := func(e xrpcerr.XrpcError) { 19 + fail := func(e XrpcError) { 21 20 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 21 writeError(w, e, http.StatusBadRequest) 23 22 } 24 23 25 24 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 25 if !ok { 27 - fail(xrpcerr.MissingActorDidError) 26 + fail(MissingActorDidError) 28 27 return 29 28 } 30 29 31 30 var data tangled.RepoRemoveSecret_Input 32 31 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(xrpcerr.GenericError(err)) 32 + fail(GenericError(err)) 34 33 return 35 34 } 36 35 37 36 // unfortunately we have to resolve repo-at here 38 37 repoAt, err := syntax.ParseATURI(data.Repo) 39 38 if err != nil { 40 - fail(xrpcerr.InvalidRepoError(data.Repo)) 39 + fail(InvalidRepoError(data.Repo)) 41 40 return 42 41 } 43 42 44 43 // resolve this aturi to extract the repo record 45 44 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 45 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 46 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 47 return 49 48 } 50 49 51 50 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 51 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 52 if err != nil { 54 - fail(xrpcerr.GenericError(err)) 53 + fail(GenericError(err)) 55 54 return 56 55 } 57 56 58 57 repo := resp.Value.Val.(*tangled.Repo) 59 58 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 59 if err != nil { 61 - fail(xrpcerr.GenericError(err)) 60 + fail(GenericError(err)) 62 61 return 63 62 } 64 63 65 64 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 65 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 66 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 67 return 69 68 } 70 69 ··· 75 74 err = x.Vault.RemoveSecret(r.Context(), secret) 76 75 if err != nil { 77 76 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 77 + writeError(w, GenericError(err), http.StatusInternalServerError) 79 78 return 80 79 } 81 80
+109 -14
spindle/xrpc/xrpc.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "context" 4 5 _ "embed" 5 6 "encoding/json" 7 + "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "strings" 8 11 12 + "github.com/bluesky-social/indigo/atproto/auth" 9 13 "github.com/go-chi/chi/v5" 10 14 11 15 "tangled.sh/tangled.sh/core/api/tangled" ··· 15 19 "tangled.sh/tangled.sh/core/spindle/db" 16 20 "tangled.sh/tangled.sh/core/spindle/models" 17 21 "tangled.sh/tangled.sh/core/spindle/secrets" 18 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 22 ) 21 23 22 24 const ActorDid string = "ActorDid" 23 25 24 26 type Xrpc struct { 25 - Logger *slog.Logger 26 - Db *db.DB 27 - Enforcer *rbac.Enforcer 28 - Engines map[string]models.Engine 29 - Config *config.Config 30 - Resolver *idresolver.Resolver 31 - Vault secrets.Manager 32 - ServiceAuth *serviceauth.ServiceAuth 27 + Logger *slog.Logger 28 + Db *db.DB 29 + Enforcer *rbac.Enforcer 30 + Engines map[string]models.Engine 31 + Config *config.Config 32 + Resolver *idresolver.Resolver 33 + Vault secrets.Manager 33 34 } 34 35 35 36 func (x *Xrpc) Router() http.Handler { 36 37 r := chi.NewRouter() 37 38 38 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 - r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 39 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 + r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 41 42 42 43 return r 43 44 } 44 45 46 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 + l := x.Logger.With("url", r.URL) 49 + 50 + token := r.Header.Get("Authorization") 51 + token = strings.TrimPrefix(token, "Bearer ") 52 + 53 + s := auth.ServiceAuthValidator{ 54 + Audience: x.Config.Server.Did().String(), 55 + Dir: x.Resolver.Directory(), 56 + } 57 + 58 + did, err := s.Validate(r.Context(), token, nil) 59 + if err != nil { 60 + l.Error("signature verification failed", "err", err) 61 + writeError(w, AuthError(err), http.StatusForbidden) 62 + return 63 + } 64 + 65 + r = r.WithContext( 66 + context.WithValue(r.Context(), ActorDid, did), 67 + ) 68 + 69 + next.ServeHTTP(w, r) 70 + }) 71 + } 72 + 73 + type XrpcError struct { 74 + Tag string `json:"error"` 75 + Message string `json:"message"` 76 + } 77 + 78 + func NewXrpcError(opts ...ErrOpt) XrpcError { 79 + x := XrpcError{} 80 + for _, o := range opts { 81 + o(&x) 82 + } 83 + 84 + return x 85 + } 86 + 87 + type ErrOpt = func(xerr *XrpcError) 88 + 89 + func WithTag(tag string) ErrOpt { 90 + return func(xerr *XrpcError) { 91 + xerr.Tag = tag 92 + } 93 + } 94 + 95 + func WithMessage[S ~string](s S) ErrOpt { 96 + return func(xerr *XrpcError) { 97 + xerr.Message = string(s) 98 + } 99 + } 100 + 101 + func WithError(e error) ErrOpt { 102 + return func(xerr *XrpcError) { 103 + xerr.Message = e.Error() 104 + } 105 + } 106 + 107 + var MissingActorDidError = NewXrpcError( 108 + WithTag("MissingActorDid"), 109 + WithMessage("actor DID not supplied"), 110 + ) 111 + 112 + var AuthError = func(err error) XrpcError { 113 + return NewXrpcError( 114 + WithTag("Auth"), 115 + WithError(fmt.Errorf("signature verification failed: %w", err)), 116 + ) 117 + } 118 + 119 + var InvalidRepoError = func(r string) XrpcError { 120 + return NewXrpcError( 121 + WithTag("InvalidRepo"), 122 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 + ) 124 + } 125 + 126 + func GenericError(err error) XrpcError { 127 + return NewXrpcError( 128 + WithTag("Generic"), 129 + WithError(err), 130 + ) 131 + } 132 + 133 + var AccessControlError = func(d string) XrpcError { 134 + return NewXrpcError( 135 + WithTag("AccessControl"), 136 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 + ) 138 + } 139 + 45 140 // this is slightly different from http_util::write_error to follow the spec: 46 141 // 47 142 // the json object returned must include an "error" and a "message" 48 - func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 143 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 49 144 w.Header().Set("Content-Type", "application/json") 50 145 w.WriteHeader(status) 51 146 json.NewEncoder(w).Encode(e)
-110
xrpc/errors/errors.go
··· 1 - package errors 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - ) 7 - 8 - type XrpcError struct { 9 - Tag string `json:"error"` 10 - Message string `json:"message"` 11 - } 12 - 13 - func (x XrpcError) Error() string { 14 - if x.Message != "" { 15 - return fmt.Sprintf("%s: %s", x.Tag, x.Message) 16 - } 17 - return x.Tag 18 - } 19 - 20 - func NewXrpcError(opts ...ErrOpt) XrpcError { 21 - x := XrpcError{} 22 - for _, o := range opts { 23 - o(&x) 24 - } 25 - 26 - return x 27 - } 28 - 29 - type ErrOpt = func(xerr *XrpcError) 30 - 31 - func WithTag(tag string) ErrOpt { 32 - return func(xerr *XrpcError) { 33 - xerr.Tag = tag 34 - } 35 - } 36 - 37 - func WithMessage[S ~string](s S) ErrOpt { 38 - return func(xerr *XrpcError) { 39 - xerr.Message = string(s) 40 - } 41 - } 42 - 43 - func WithError(e error) ErrOpt { 44 - return func(xerr *XrpcError) { 45 - xerr.Message = e.Error() 46 - } 47 - } 48 - 49 - var MissingActorDidError = NewXrpcError( 50 - WithTag("MissingActorDid"), 51 - WithMessage("actor DID not supplied"), 52 - ) 53 - 54 - var AuthError = func(err error) XrpcError { 55 - return NewXrpcError( 56 - WithTag("Auth"), 57 - WithError(fmt.Errorf("signature verification failed: %w", err)), 58 - ) 59 - } 60 - 61 - var InvalidRepoError = func(r string) XrpcError { 62 - return NewXrpcError( 63 - WithTag("InvalidRepo"), 64 - WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 65 - ) 66 - } 67 - 68 - var GitError = func(e error) XrpcError { 69 - return NewXrpcError( 70 - WithTag("Git"), 71 - WithError(fmt.Errorf("git error: %w", e)), 72 - ) 73 - } 74 - 75 - var AccessControlError = func(d string) XrpcError { 76 - return NewXrpcError( 77 - WithTag("AccessControl"), 78 - WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 79 - ) 80 - } 81 - 82 - var RepoExistsError = func(r string) XrpcError { 83 - return NewXrpcError( 84 - WithTag("RepoExists"), 85 - WithError(fmt.Errorf("repo already exists: %s", r)), 86 - ) 87 - } 88 - 89 - var RecordExistsError = func(r string) XrpcError { 90 - return NewXrpcError( 91 - WithTag("RecordExists"), 92 - WithError(fmt.Errorf("repo already exists: %s", r)), 93 - ) 94 - } 95 - 96 - func GenericError(err error) XrpcError { 97 - return NewXrpcError( 98 - WithTag("Generic"), 99 - WithError(err), 100 - ) 101 - } 102 - 103 - func Unmarshal(errStr string) (XrpcError, error) { 104 - var xerr XrpcError 105 - err := json.Unmarshal([]byte(errStr), &xerr) 106 - if err != nil { 107 - return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 108 - } 109 - return xerr, nil 110 - }
-65
xrpc/serviceauth/service_auth.go
··· 1 - package serviceauth 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "log/slog" 7 - "net/http" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/atproto/auth" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 - ) 14 - 15 - const ActorDid string = "ActorDid" 16 - 17 - type ServiceAuth struct { 18 - logger *slog.Logger 19 - resolver *idresolver.Resolver 20 - audienceDid string 21 - } 22 - 23 - func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 - return &ServiceAuth{ 25 - logger: logger, 26 - resolver: resolver, 27 - audienceDid: audienceDid, 28 - } 29 - } 30 - 31 - func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 - token := r.Header.Get("Authorization") 36 - token = strings.TrimPrefix(token, "Bearer ") 37 - 38 - s := auth.ServiceAuthValidator{ 39 - Audience: sa.audienceDid, 40 - Dir: sa.resolver.Directory(), 41 - } 42 - 43 - did, err := s.Validate(r.Context(), token, nil) 44 - if err != nil { 45 - l.Error("signature verification failed", "err", err) 46 - writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 - return 48 - } 49 - 50 - r = r.WithContext( 51 - context.WithValue(r.Context(), ActorDid, did), 52 - ) 53 - 54 - next.ServeHTTP(w, r) 55 - }) 56 - } 57 - 58 - // this is slightly different from http_util::write_error to follow the spec: 59 - // 60 - // the json object returned must include an "error" and a "message" 61 - func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 62 - w.Header().Set("Content-Type", "application/json") 63 - w.WriteHeader(status) 64 - json.NewEncoder(w).Encode(e) 65 - }