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

Compare changes

Choose any two refs to compare.

Changed files
+6058 -3997
api
appview
cmd
docs
knotclient
knotserver
lexicons
nix
modules
rbac
spindle
xrpc
errors
serviceauth
+252 -2
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 + } 2144 2274 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2145 2275 if t == nil { 2146 2276 _, err := w.Write(cbg.CborNull) ··· 5512 5642 } 5513 5643 5514 5644 cw := cbg.NewCborWriter(w) 5515 - fieldCount := 6 5645 + fieldCount := 7 5516 5646 5517 5647 if t.Body == nil { 5518 5648 fieldCount-- ··· 5642 5772 return err 5643 5773 } 5644 5774 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 + 5645 5797 // t.CreatedAt (string) (string) 5646 5798 if len("createdAt") > 1000000 { 5647 5799 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5773 5925 5774 5926 t.Title = string(sval) 5775 5927 } 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 + } 5776 5954 // t.CreatedAt (string) (string) 5777 5955 case "createdAt": 5778 5956 ··· 5802 5980 } 5803 5981 5804 5982 cw := cbg.NewCborWriter(w) 5805 - fieldCount := 6 5983 + fieldCount := 7 5984 + 5985 + if t.CommentId == nil { 5986 + fieldCount-- 5987 + } 5806 5988 5807 5989 if t.Owner == nil { 5808 5990 fieldCount-- ··· 5945 6127 } 5946 6128 } 5947 6129 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 + 5948 6162 // t.CreatedAt (string) (string) 5949 6163 if len("createdAt") > 1000000 { 5950 6164 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6084 6298 } 6085 6299 6086 6300 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) 6087 6337 } 6088 6338 } 6089 6339 // 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"` 22 23 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 24 Issue string `json:"issue" cborgen:"issue"` 24 25 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"` 23 24 Owner string `json:"owner" cborgen:"owner"` 24 25 Repo string `json:"repo" cborgen:"repo"` 25 26 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 spindle 20 + // temporarily, to add users to default knot and 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 + 615 640 // recreate and add rkey + created columns with default constraint 616 641 runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 617 642 // create new table
+144 -41
appview/db/follow.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 6 + "strings" 5 7 "time" 6 8 ) 7 9 ··· 53 55 return err 54 56 } 55 57 56 - func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 58 + type FollowStats struct { 59 + Followers int 60 + Following int 61 + } 62 + 63 + func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 57 64 followers, following := 0, 0 58 65 err := e.QueryRow( 59 - `SELECT 66 + `SELECT 60 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 61 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 62 69 FROM follows;`, did, did).Scan(&followers, &following) 63 70 if err != nil { 64 - return 0, 0, err 71 + return FollowStats{}, err 65 72 } 66 - return followers, following, nil 73 + return FollowStats{ 74 + Followers: followers, 75 + Following: following, 76 + }, nil 67 77 } 68 78 69 - type FollowStatus int 79 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 + if len(dids) == 0 { 81 + return nil, nil 82 + } 83 + 84 + placeholders := make([]string, len(dids)) 85 + for i := range placeholders { 86 + placeholders[i] = "?" 87 + } 88 + placeholderStr := strings.Join(placeholders, ",") 89 + 90 + args := make([]any, len(dids)*2) 91 + for i, did := range dids { 92 + args[i] = did 93 + args[i+len(dids)] = did 94 + } 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) 70 114 71 - const ( 72 - IsNotFollowing FollowStatus = iota 73 - IsFollowing 74 - IsSelf 75 - ) 115 + result := make(map[string]FollowStats) 76 116 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" 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 87 120 } 88 - } 121 + defer rows.Close() 122 + 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 + } 133 + } 89 134 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 135 + for _, did := range dids { 136 + if _, exists := result[did]; !exists { 137 + result[did] = FollowStats{ 138 + Followers: 0, 139 + Following: 0, 140 + } 141 + } 97 142 } 143 + 144 + return result, nil 98 145 } 99 146 100 - func GetAllFollows(e Execer, limit int) ([]Follow, error) { 147 + func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 101 148 var follows []Follow 102 149 103 - rows, err := e.Query(` 104 - select user_did, subject_did, followed_at, rkey 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 105 169 from follows 170 + %s 106 171 order by followed_at desc 107 - limit ?`, limit, 108 - ) 172 + %s 173 + `, whereClause, limitClause) 174 + 175 + rows, err := e.Query(query, args...) 109 176 if err != nil { 110 177 return nil, err 111 178 } 112 - defer rows.Close() 113 - 114 179 for rows.Next() { 115 180 var follow Follow 116 181 var followedAt string 117 - if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 182 + err := rows.Scan( 183 + &follow.UserDid, 184 + &follow.SubjectDid, 185 + &followedAt, 186 + &follow.Rkey, 187 + ) 188 + if err != nil { 118 189 return nil, err 119 190 } 120 - 121 191 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 122 192 if err != nil { 123 193 log.Println("unable to determine followed at time") ··· 125 195 } else { 126 196 follow.FollowedAt = followedAtTime 127 197 } 128 - 129 198 follows = append(follows, follow) 130 199 } 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 + } 131 206 132 - if err := rows.Err(); err != nil { 133 - return nil, err 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" 134 229 } 230 + } 135 231 136 - return follows, nil 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 + } 137 240 }
-105
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 - mathrand "math/rand/v2" 7 6 "strings" 8 7 "time" 9 8 ··· 48 47 49 48 func (i *Issue) AtUri() syntax.ATURI { 50 49 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 123 50 } 124 51 125 52 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 623 550 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 624 551 where repo_at = ? and issue_id = ? and comment_id = ? 625 552 `, 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) 658 553 return err 659 554 } 660 555
+2 -7
appview/db/profile.go
··· 348 348 return tx.Commit() 349 349 } 350 350 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 351 + func GetProfiles(e Execer, filters ...filter) (map[string]*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 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 451 + return profileMap, nil 457 452 } 458 453 459 454 func GetProfile(e Execer, did string) (*Profile, error) {
+89 -125
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/rand" 5 4 "database/sql" 6 - "encoding/hex" 7 5 "fmt" 8 - "log" 6 + "strings" 9 7 "time" 10 8 ) 11 9 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 18 19 } 19 20 20 21 func (r *Registration) Status() Status { 21 - if r.Registered != nil { 22 + if r.ReadOnly { 23 + return ReadOnly 24 + } else if r.Registered != nil { 22 25 return Registered 23 26 } else { 24 27 return Pending 25 28 } 26 29 } 27 30 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 + 28 43 type Status uint32 29 44 30 45 const ( 31 46 Registered Status = iota 32 47 Pending 48 + ReadOnly 33 49 ) 34 50 35 - // returns registered status, did of owner, error 36 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 37 52 var registrations []Registration 38 53 39 - rows, err := e.Query(` 40 - select id, domain, did, created, registered from registrations 41 - where did = ? 42 - `, did) 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...) 43 76 if err != nil { 44 77 return nil, err 45 78 } 46 79 47 80 for rows.Next() { 48 - var createdAt *string 49 - var registeredAt *string 50 - var registration Registration 51 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var readOnly int 84 + var reg Registration 52 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 53 87 if err != nil { 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 - } 88 + return nil, err 89 + } 62 90 63 - registration.Created = &createdAtTime 64 - registration.Registered = registeredAtTime 65 - registrations = append(registrations, registration) 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 66 93 } 67 - } 68 94 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 77 - 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) 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 82 100 83 - if err != nil { 84 - if err == sql.ErrNoRows { 85 - return nil, nil 86 - } else { 87 - return nil, err 101 + if readOnly != 0 { 102 + reg.ReadOnly = true 88 103 } 89 - } 90 104 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 105 + registrations = append(registrations, reg) 96 106 } 97 107 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) 108 + return registrations, nil 108 109 } 109 110 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 115 - } 116 - 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 - } 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()...) 127 117 } 128 118 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 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 ") 139 122 } 140 123 141 - return secret, nil 124 + _, err := e.Exec(query, args...) 125 + return err 142 126 } 143 127 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 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 154 134 } 155 135 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 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()...) 160 142 } 161 143 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 - } 172 - } 173 - 174 - if err = rows.Err(); err != nil { 175 - return nil, err 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 176 147 } 177 148 178 - return domains, nil 179 - } 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 180 150 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) 187 - 151 + _, err := e.Exec(query, args...) 188 152 return err 189 153 }
+6 -22
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 - type FollowStats struct { 24 - Followers int 25 - Following int 26 - } 27 - 28 23 const Limit = 50 29 24 30 25 // TODO: this gathers heterogenous events from different sources and aggregates ··· 137 132 } 138 133 139 134 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 - follows, err := GetAllFollows(e, Limit) 135 + follows, err := GetFollows(e, Limit) 141 136 if err != nil { 142 137 return nil, err 143 138 } ··· 151 146 return nil, nil 152 147 } 153 148 154 - profileMap := make(map[string]Profile) 155 149 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 150 if err != nil { 157 151 return nil, err 158 152 } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 - } 162 153 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 - } 154 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 155 + if err != nil { 156 + return nil, err 173 157 } 174 158 175 159 var events []TimelineEvent 176 160 for _, f := range follows { 177 - profile, _ := profileMap[f.SubjectDid] 161 + profile, _ := profiles[f.SubjectDid] 178 162 followStatMap, _ := followStatMap[f.SubjectDid] 179 163 180 164 events = append(events, TimelineEvent{ 181 165 Follow: &f, 182 - Profile: &profile, 166 + Profile: profile, 183 167 FollowStats: &followStatMap, 184 168 EventAt: f.FollowedAt, 185 169 })
+98 -111
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 - "strings" 9 8 "time" 10 9 11 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 14 "tangled.sh/tangled.sh/core/api/tangled" 16 15 "tangled.sh/tangled.sh/core/appview/config" 17 16 "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/pages/markup" 19 - "tangled.sh/tangled.sh/core/appview/spindleverify" 17 + "tangled.sh/tangled.sh/core/appview/serververify" 20 18 "tangled.sh/tangled.sh/core/idresolver" 21 19 "tangled.sh/tangled.sh/core/rbac" 22 20 ) ··· 63 61 case tangled.ActorProfileNSID: 64 62 err = i.ingestProfile(e) 65 63 case tangled.SpindleMemberNSID: 66 - err = i.ingestSpindleMember(ctx, e) 64 + err = i.ingestSpindleMember(e) 67 65 case tangled.SpindleNSID: 68 - err = i.ingestSpindle(ctx, e) 66 + err = i.ingestSpindle(e) 67 + case tangled.KnotMemberNSID: 68 + err = i.ingestKnotMember(e) 69 + case tangled.KnotNSID: 70 + err = i.ingestKnot(e) 69 71 case tangled.StringNSID: 70 72 err = i.ingestString(e) 71 - case tangled.RepoIssueNSID: 72 - err = i.ingestIssue(ctx, e) 73 - case tangled.RepoIssueCommentNSID: 74 - err = i.ingestIssueComment(e) 75 73 } 76 74 l = i.Logger.With("nsid", e.Commit.Collection) 77 75 } ··· 342 340 return nil 343 341 } 344 342 345 - func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 343 + func (i *Ingester) ingestSpindleMember(e *models.Event) error { 346 344 did := e.Did 347 345 var err error 348 346 ··· 365 363 return fmt.Errorf("failed to enforce permissions: %w", err) 366 364 } 367 365 368 - memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 366 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 369 367 if err != nil { 370 368 return err 371 369 } ··· 448 446 return nil 449 447 } 450 448 451 - func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 449 + func (i *Ingester) ingestSpindle(e *models.Event) error { 452 450 did := e.Did 453 451 var err error 454 452 ··· 481 479 return err 482 480 } 483 481 484 - err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 482 + err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 485 483 if err != nil { 486 484 l.Error("failed to add spindle to db", "err", err, "instance", instance) 487 485 return err 488 486 } 489 487 490 - _, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did) 488 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 491 489 if err != nil { 492 490 return fmt.Errorf("failed to mark verified: %w", err) 493 491 } ··· 616 614 return nil 617 615 } 618 616 619 - func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 617 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 620 618 did := e.Did 621 - rkey := e.Commit.RKey 622 - 623 619 var err error 624 620 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 - } 621 + l := i.Logger.With("handler", "ingestKnotMember") 622 + l = l.With("nsid", e.Commit.Collection) 632 623 633 624 switch e.Commit.Operation { 634 625 case models.CommitOperationCreate: 635 626 raw := json.RawMessage(e.Commit.Record) 636 - record := tangled.RepoIssue{} 627 + record := tangled.KnotMember{} 637 628 err = json.Unmarshal(raw, &record) 638 629 if err != nil { 639 630 l.Error("invalid record", "err", err) 640 631 return err 641 632 } 642 633 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") 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) 651 638 } 652 639 653 - tx, err := ddb.BeginTx(ctx, nil) 640 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 654 641 if err != nil { 655 - l.Error("failed to begin transaction", "err", err) 656 642 return err 657 643 } 658 644 659 - err = db.NewIssue(tx, &issue) 660 - if err != nil { 661 - l.Error("failed to create issue", "err", err) 662 - return err 663 - } 664 - 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) 645 + if memberId.Handle.IsInvalidHandle() { 673 646 return err 674 647 } 675 648 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) 649 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 690 650 if err != nil { 691 - l.Error("failed to update issue", "err", err) 692 - return err 651 + return fmt.Errorf("failed to update ACLs: %w", err) 693 652 } 694 653 695 - return nil 696 - 654 + l.Info("added knot member") 697 655 case models.CommitOperationDelete: 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 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) 704 664 } 705 665 706 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 666 + return nil 707 667 } 708 668 709 - func (i *Ingester) ingestIssueComment(e *models.Event) error { 669 + func (i *Ingester) ingestKnot(e *models.Event) error { 710 670 did := e.Did 711 - rkey := e.Commit.RKey 712 - 713 671 var err error 714 672 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 - } 673 + l := i.Logger.With("handler", "ingestKnot") 674 + l = l.With("nsid", e.Commit.Collection) 722 675 723 676 switch e.Commit.Operation { 724 677 case models.CommitOperationCreate: 725 678 raw := json.RawMessage(e.Commit.Record) 726 - record := tangled.RepoIssueComment{} 679 + record := tangled.Knot{} 727 680 err = json.Unmarshal(raw, &record) 728 681 if err != nil { 729 682 l.Error("invalid record", "err", err) 730 683 return err 731 684 } 732 685 733 - comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 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) 734 694 if err != nil { 735 - l.Error("failed to parse comment from record", "err", err) 695 + l.Error("failed to add knot to db", "err", err, "domain", domain) 736 696 return err 737 697 } 738 698 739 - sanitizer := markup.NewSanitizer() 740 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 741 - return fmt.Errorf("body is empty after HTML sanitization") 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 742 703 } 743 704 744 - err = db.NewIssueComment(ddb, &comment) 705 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 745 706 if err != nil { 746 - l.Error("failed to create issue comment", "err", err) 747 - return err 707 + return fmt.Errorf("failed to mark verified: %w", err) 748 708 } 749 709 750 710 return nil 751 711 752 - case models.CommitOperationUpdate: 753 - raw := json.RawMessage(e.Commit.Record) 754 - record := tangled.RepoIssueComment{} 755 - err = json.Unmarshal(raw, &record) 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 + ) 756 726 if err != nil { 757 - l.Error("invalid record", "err", err) 758 - return err 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)) 759 731 } 732 + registration := registrations[0] 760 733 761 - sanitizer := markup.NewSanitizer() 762 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 763 - return fmt.Errorf("body is empty after HTML sanitization") 734 + tx, err := ddb.Begin() 735 + if err != nil { 736 + return err 764 737 } 738 + defer func() { 739 + tx.Rollback() 740 + i.Enforcer.E.LoadPolicy() 741 + }() 765 742 766 - err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 743 + err = db.DeleteKnot( 744 + tx, 745 + db.FilterEq("did", did), 746 + db.FilterEq("domain", domain), 747 + ) 767 748 if err != nil { 768 - l.Error("failed to update issue comment", "err", err) 769 749 return err 770 750 } 771 751 772 - return nil 752 + if registration.Registered != nil { 753 + err = i.Enforcer.RemoveKnot(domain) 754 + if err != nil { 755 + return err 756 + } 757 + } 773 758 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) 759 + err = tx.Commit() 760 + if err != nil { 761 + return err 778 762 } 779 763 780 - return nil 764 + err = i.Enforcer.E.SavePolicy() 765 + if err != nil { 766 + return err 767 + } 781 768 } 782 769 783 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 770 + return nil 784 771 }
+9 -4
appview/issues/issues.go
··· 278 278 } 279 279 280 280 createdAt := time.Now().Format(time.RFC3339) 281 + commentIdInt64 := int64(commentId) 281 282 ownerDid := user.Did 282 283 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 284 if err != nil { ··· 301 302 Val: &tangled.RepoIssueComment{ 302 303 Repo: &atUri, 303 304 Issue: issueAt, 305 + CommentId: &commentIdInt64, 304 306 Owner: &ownerDid, 305 307 Body: body, 306 308 CreatedAt: createdAt, ··· 449 451 repoAt := record["repo"].(string) 450 452 issueAt := record["issue"].(string) 451 453 createdAt := record["createdAt"].(string) 454 + commentIdInt64 := int64(commentIdInt) 452 455 453 456 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 457 Collection: tangled.RepoIssueCommentNSID, ··· 459 462 Val: &tangled.RepoIssueComment{ 460 463 Repo: &repoAt, 461 464 Issue: issueAt, 465 + CommentId: &commentIdInt64, 462 466 Owner: &comment.OwnerDid, 463 467 Body: newBody, 464 468 CreatedAt: createdAt, ··· 683 687 Rkey: issue.Rkey, 684 688 Record: &lexutil.LexiconTypeDecoder{ 685 689 Val: &tangled.RepoIssue{ 686 - Repo: atUri, 687 - Title: title, 688 - Body: &body, 689 - Owner: user.Did, 690 + Repo: atUri, 691 + Title: title, 692 + Body: &body, 693 + Owner: user.Did, 694 + IssueId: int64(issue.IssueId), 690 695 }, 691 696 }, 692 697 })
+444 -217
appview/knots/knots.go
··· 1 1 package knots 2 2 3 3 import ( 4 - "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 4 + "errors" 8 5 "fmt" 6 + "log" 9 7 "log/slog" 10 8 "net/http" 11 - "strings" 9 + "slices" 12 10 "time" 13 11 14 12 "github.com/go-chi/chi/v5" ··· 18 16 "tangled.sh/tangled.sh/core/appview/middleware" 19 17 "tangled.sh/tangled.sh/core/appview/oauth" 20 18 "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/serververify" 21 20 "tangled.sh/tangled.sh/core/eventconsumer" 22 21 "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/knotclient" 24 22 "tangled.sh/tangled.sh/core/rbac" 25 23 "tangled.sh/tangled.sh/core/tid" 26 24 ··· 39 37 Knotstream *eventconsumer.Consumer 40 38 } 41 39 42 - func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 40 + func (k *Knots) Router() http.Handler { 43 41 r := chi.NewRouter() 44 42 45 - r.Use(middleware.AuthMiddleware(k.OAuth)) 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 46 45 47 - r.Get("/", k.index) 48 - r.Post("/key", k.generateKey) 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 49 48 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 - }) 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) 52 + 53 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 60 54 61 55 return r 62 56 } 63 57 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 - 58 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 68 59 user := k.OAuth.GetUser(r) 69 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 60 + registrations, err := db.GetRegistrations( 61 + k.Db, 62 + db.FilterEq("did", user.Did), 63 + ) 70 64 if err != nil { 71 - l.Error("failed to get registrations by did", "err", err) 65 + k.Logger.Error("failed to fetch knot registrations", "err", err) 66 + w.WriteHeader(http.StatusInternalServerError) 67 + return 72 68 } 73 69 74 70 k.Pages.Knots(w, pages.KnotsParams{ ··· 77 73 }) 78 74 } 79 75 80 - // requires auth 81 - func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 - l := k.Logger.With("handler", "generateKey") 76 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 77 + l := k.Logger.With("handler", "dashboard") 83 78 84 79 user := k.OAuth.GetUser(r) 85 - did := user.Did 86 - l = l.With("did", did) 80 + l = l.With("user", user.Did) 87 81 88 - // check if domain is valid url, and strip extra bits down to just host 89 - domain := r.FormValue("domain") 82 + domain := chi.URLParam(r, "domain") 90 83 if domain == "" { 91 - l.Error("empty domain") 92 - http.Error(w, "Invalid form", http.StatusBadRequest) 93 84 return 94 85 } 95 86 l = l.With("domain", domain) 96 87 97 - noticeId := "registration-error" 98 - fail := func() { 99 - k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 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 100 97 } 98 + if len(registrations) != 1 { 99 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 100 + return 101 + } 102 + registration := registrations[0] 101 103 102 - key, err := db.GenerateRegistrationKey(k.Db, domain, did) 104 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 105 if err != nil { 104 - l.Error("failed to generate registration key", "err", err) 105 - fail() 106 + l.Error("failed to get knot members", "err", err) 107 + http.Error(w, "Not found", http.StatusInternalServerError) 106 108 return 107 109 } 110 + slices.Sort(members) 108 111 109 - allRegs, err := db.RegistrationsByDid(k.Db, did) 112 + repos, err := db.GetRepos( 113 + k.Db, 114 + 0, 115 + db.FilterEq("knot", domain), 116 + ) 110 117 if err != nil { 111 - l.Error("failed to generate registration key", "err", err) 112 - fail() 118 + l.Error("failed to get knot repos", "err", err) 119 + http.Error(w, "Not found", http.StatusInternalServerError) 113 120 return 114 121 } 115 122 116 - k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 - Registrations: allRegs, 118 - }) 119 - k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 - Secret: key, 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, 121 135 }) 122 136 } 123 137 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") 138 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 127 139 user := k.OAuth.GetUser(r) 140 + l := k.Logger.With("handler", "register") 128 141 129 - noticeId := "operation-error" 130 - defaultErr := "Failed to initialize knot. Try again later." 142 + noticeId := "register-error" 143 + defaultErr := "Failed to register knot. Try again later." 131 144 fail := func() { 132 145 k.Pages.Notice(w, noticeId, defaultErr) 133 146 } 134 147 135 - domain := chi.URLParam(r, "domain") 148 + domain := r.FormValue("domain") 136 149 if domain == "" { 137 - http.Error(w, "malformed url", http.StatusBadRequest) 150 + k.Pages.Notice(w, noticeId, "Incomplete form.") 138 151 return 139 152 } 140 153 l = l.With("domain", domain) 154 + l = l.With("user", user.Did) 141 155 142 - l.Info("checking domain") 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 + }() 143 166 144 - registration, err := db.RegistrationByDomain(k.Db, domain) 167 + err = db.AddKnot(tx, domain, user.Did) 145 168 if err != nil { 146 - l.Error("failed to get registration for domain", "err", err) 169 + l.Error("failed to insert", "err", err) 147 170 fail() 148 171 return 149 172 } 150 - if registration.ByDid != user.Did { 151 - l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 - w.WriteHeader(http.StatusUnauthorized) 173 + 174 + err = k.Enforcer.AddKnot(domain) 175 + if err != nil { 176 + l.Error("failed to create knot", "err", err) 177 + fail() 153 178 return 154 179 } 155 180 156 - secret, err := db.GetRegistrationKey(k.Db, domain) 181 + // create record on pds 182 + client, err := k.OAuth.AuthorizedClient(r) 157 183 if err != nil { 158 - l.Error("failed to get registration key for domain", "err", err) 184 + l.Error("failed to authorize client", "err", err) 159 185 fail() 160 186 return 161 187 } 162 188 163 - client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 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 + 164 208 if err != nil { 165 - l.Error("failed to create knotclient", "err", err) 209 + l.Error("failed to put record", "err", err) 166 210 fail() 167 211 return 168 212 } 169 213 170 - resp, err := client.Init(user.Did) 214 + err = tx.Commit() 171 215 if err != nil { 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) 216 + l.Error("failed to commit transaction", "err", err) 217 + fail() 174 218 return 175 219 } 176 220 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) 221 + err = k.Enforcer.E.SavePolicy() 222 + if err != nil { 223 + l.Error("failed to update ACL", "err", err) 224 + k.Pages.HxRefresh(w) 180 225 return 181 226 } 182 227 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) 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) 186 233 return 187 234 } 188 235 189 - // verify response mac 190 - signature := resp.Header.Get("X-Signature") 191 - signatureBytes, err := hex.DecodeString(signature) 236 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 192 237 if err != nil { 238 + l.Error("failed to mark verified", "err", err) 239 + k.Pages.HxRefresh(w) 193 240 return 194 241 } 195 242 196 - expectedMac := hmac.New(sha256.New, []byte(secret)) 197 - expectedMac.Write([]byte("ok")) 243 + // add this knot to knotstream 244 + go k.Knotstream.AddSource( 245 + r.Context(), 246 + eventconsumer.NewKnotSource(domain), 247 + ) 248 + 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) 261 + } 262 + 263 + domain := chi.URLParam(r, "domain") 264 + if domain == "" { 265 + l.Error("empty domain") 266 + fail() 267 + return 268 + } 198 269 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) 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 + ) 276 + if err != nil { 277 + l.Error("failed to get registration", "err", err) 278 + fail() 202 279 return 203 280 } 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] 204 287 205 - tx, err := k.Db.BeginTx(r.Context(), nil) 288 + tx, err := k.Db.Begin() 206 289 if err != nil { 207 - l.Error("failed to start tx", "err", err) 290 + l.Error("failed to start txn", "err", err) 208 291 fail() 209 292 return 210 293 } 211 294 defer func() { 212 295 tx.Rollback() 213 - err = k.Enforcer.E.LoadPolicy() 214 - if err != nil { 215 - l.Error("rollback failed", "err", err) 216 - } 296 + k.Enforcer.E.LoadPolicy() 217 297 }() 218 298 219 - // mark as registered 220 - err = db.Register(tx, domain) 299 + err = db.DeleteKnot( 300 + tx, 301 + db.FilterEq("did", user.Did), 302 + db.FilterEq("domain", domain), 303 + ) 221 304 if err != nil { 222 - l.Error("failed to register domain", "err", err) 305 + l.Error("failed to delete registration", "err", err) 223 306 fail() 224 307 return 225 308 } 226 309 227 - // set permissions for this did as owner 228 - reg, err := db.RegistrationByDomain(tx, domain) 229 - if err != nil { 230 - l.Error("failed get registration by domain", "err", err) 231 - fail() 232 - return 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 + } 233 318 } 234 319 235 - // add basic acls for this domain 236 - err = k.Enforcer.AddKnot(domain) 320 + client, err := k.OAuth.AuthorizedClient(r) 237 321 if err != nil { 238 - l.Error("failed to add knot to enforcer", "err", err) 322 + l.Error("failed to authorize client", "err", err) 239 323 fail() 240 324 return 241 325 } 242 326 243 - // add this did as owner of this domain 244 - err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 327 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 328 + Collection: tangled.KnotNSID, 329 + Repo: user.Did, 330 + Rkey: domain, 331 + }) 245 332 if err != nil { 246 - l.Error("failed to add knot owner to enforcer", "err", err) 247 - fail() 248 - return 333 + // non-fatal 334 + l.Error("failed to delete record", "err", err) 249 335 } 250 336 251 337 err = tx.Commit() 252 338 if err != nil { 253 - l.Error("failed to commit changes", "err", err) 339 + l.Error("failed to delete knot", "err", err) 254 340 fail() 255 341 return 256 342 } 257 343 258 344 err = k.Enforcer.E.SavePolicy() 259 345 if err != nil { 260 - l.Error("failed to update ACLs", "err", err) 261 - fail() 346 + l.Error("failed to update ACL", "err", err) 347 + k.Pages.HxRefresh(w) 262 348 return 263 349 } 264 350 265 - // add this knot to knotstream 266 - go k.Knotstream.AddSource( 267 - context.Background(), 268 - eventconsumer.NewKnotSource(domain), 269 - ) 351 + shouldRedirect := r.Header.Get("shouldRedirect") 352 + if shouldRedirect == "true" { 353 + k.Pages.HxRedirect(w, "/knots") 354 + return 355 + } 270 356 271 - k.Pages.KnotListing(w, pages.KnotListingParams{ 272 - Registration: *reg, 273 - }) 357 + w.Write([]byte{}) 274 358 } 275 359 276 - func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 - l := k.Logger.With("handler", "dashboard") 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." 278 366 fail := func() { 279 - w.WriteHeader(http.StatusInternalServerError) 367 + k.Pages.Notice(w, noticeId, defaultErr) 280 368 } 281 369 282 370 domain := chi.URLParam(r, "domain") 283 371 if domain == "" { 284 - http.Error(w, "malformed url", http.StatusBadRequest) 372 + l.Error("empty domain") 373 + fail() 285 374 return 286 375 } 287 376 l = l.With("domain", domain) 377 + l = l.With("user", user.Did) 288 378 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) 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 + ) 294 385 if err != nil { 295 - l.Error("failed to query enforcer", "err", err) 386 + l.Error("failed to get registration", "err", err) 296 387 fail() 388 + return 297 389 } 298 - if !ok { 299 - http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 390 + if len(registrations) != 1 { 391 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 392 + fail() 300 393 return 301 394 } 395 + registration := registrations[0] 302 396 303 - reg, err := db.RegistrationByDomain(k.Db, domain) 397 + // begin verification 398 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 304 399 if err != nil { 305 - l.Error("failed to get registration by domain", "err", err) 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 + 306 412 fail() 307 413 return 308 414 } 309 415 310 - var members []string 311 - if reg.Registered != nil { 312 - members, err = k.Enforcer.GetUserByRole("server:member", domain) 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) 313 429 if err != nil { 314 - l.Error("failed to get members list", "err", err) 430 + l.Error("failed to authorize client", "err", err) 315 431 fail() 316 432 return 317 433 } 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 + } 318 456 } 319 457 320 - repos, err := db.GetRepos( 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( 321 472 k.Db, 322 - 0, 323 - db.FilterEq("knot", domain), 324 - db.FilterIn("did", members), 473 + db.FilterEq("did", user.Did), 474 + db.FilterEq("domain", domain), 325 475 ) 326 476 if err != nil { 327 - l.Error("failed to get repos list", "err", err) 477 + l.Error("failed to get registration", "err", err) 328 478 fail() 329 479 return 330 480 } 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) 335 - } 336 - 337 - k.Pages.Knot(w, pages.KnotParams{ 338 - LoggedInUser: user, 339 - Registration: reg, 340 - Members: members, 341 - Repos: repoByMember, 342 - IsOwner: true, 343 - }) 344 - } 345 - 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") 349 - 350 - domain := chi.URLParam(r, "domain") 351 - if domain == "" { 352 - http.Error(w, "malformed url", http.StatusBadRequest) 481 + if len(registrations) != 1 { 482 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 483 + fail() 353 484 return 354 485 } 355 - l = l.With("domain", domain) 486 + updatedRegistration := registrations[0] 356 487 357 - // list all members for this domain 358 - memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 359 - if err != nil { 360 - w.Write([]byte("failed to fetch member list")) 361 - return 362 - } 488 + log.Println(updatedRegistration) 363 489 364 - w.Write([]byte(strings.Join(memberDids, "\n"))) 490 + w.Header().Set("HX-Reswap", "outerHTML") 491 + k.Pages.KnotListing(w, pages.KnotListingParams{ 492 + Registration: &updatedRegistration, 493 + }) 365 494 } 366 495 367 - // add member to domain, requires auth and requires invite access 368 496 func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 369 - l := k.Logger.With("handler", "members") 497 + user := k.OAuth.GetUser(r) 498 + l := k.Logger.With("handler", "addMember") 370 499 371 500 domain := chi.URLParam(r, "domain") 372 501 if domain == "" { 373 - http.Error(w, "malformed url", http.StatusBadRequest) 502 + l.Error("empty domain") 503 + http.Error(w, "Not found", http.StatusNotFound) 374 504 return 375 505 } 376 506 l = l.With("domain", domain) 507 + l = l.With("user", user.Did) 377 508 378 - reg, err := db.RegistrationByDomain(k.Db, domain) 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 + ) 379 515 if err != nil { 380 - l.Error("failed to get registration by domain", "err", err) 381 - http.Error(w, "malformed url", http.StatusBadRequest) 516 + l.Error("failed to get registration", "err", err) 517 + return 518 + } 519 + if len(registrations) != 1 { 520 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 382 521 return 383 522 } 523 + registration := registrations[0] 384 524 385 - noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 386 - l = l.With("notice-id", noticeId) 525 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 387 526 defaultErr := "Failed to add member. Try again later." 388 527 fail := func() { 389 528 k.Pages.Notice(w, noticeId, defaultErr) 390 529 } 391 530 392 - subjectIdentifier := r.FormValue("subject") 393 - if subjectIdentifier == "" { 394 - http.Error(w, "malformed form", http.StatusBadRequest) 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.") 395 535 return 396 536 } 397 - l = l.With("subjectIdentifier", subjectIdentifier) 537 + l = l.With("member", member) 398 538 399 - subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 539 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 400 540 if err != nil { 401 - l.Error("failed to resolve identity", "err", err) 541 + l.Error("failed to resolve member identity to handle", "err", err) 402 542 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 403 543 return 404 544 } 405 - l = l.With("subjectDid", subjectIdentity.DID) 406 - 407 - l.Info("adding member to knot") 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 + } 408 550 409 - // announce this relation into the firehose, store into owners' pds 551 + // write to pds 410 552 client, err := k.OAuth.AuthorizedClient(r) 411 553 if err != nil { 412 - l.Error("failed to create client", "err", err) 554 + l.Error("failed to authorize client", "err", err) 413 555 fail() 414 556 return 415 557 } 416 558 417 - currentUser := k.OAuth.GetUser(r) 418 - createdAt := time.Now().Format(time.RFC3339) 419 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 + rkey := tid.TID() 560 + 561 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 420 562 Collection: tangled.KnotMemberNSID, 421 - Repo: currentUser.Did, 422 - Rkey: tid.TID(), 563 + Repo: user.Did, 564 + Rkey: rkey, 423 565 Record: &lexutil.LexiconTypeDecoder{ 424 566 Val: &tangled.KnotMember{ 425 - Subject: subjectIdentity.DID.String(), 567 + CreatedAt: time.Now().Format(time.RFC3339), 426 568 Domain: domain, 427 - CreatedAt: createdAt, 428 - }}, 569 + Subject: memberId.DID.String(), 570 + }, 571 + }, 429 572 }) 430 - // invalid record 573 + 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.") 576 + return 577 + } 578 + 579 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 431 580 if err != nil { 432 - l.Error("failed to write to PDS", "err", err) 581 + l.Error("failed to add member to ACLs", "err", err) 433 582 fail() 434 583 return 435 584 } 436 - l = l.With("at-uri", resp.Uri) 437 - l.Info("wrote record to PDS") 438 585 439 - secret, err := db.GetRegistrationKey(k.Db, domain) 586 + err = k.Enforcer.E.SavePolicy() 440 587 if err != nil { 441 - l.Error("failed to get registration key", "err", err) 588 + l.Error("failed to save ACL policy", "err", err) 589 + fail() 590 + return 591 + } 592 + 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") 442 610 fail() 443 611 return 444 612 } 613 + l = l.With("domain", domain) 614 + l = l.With("user", user.Did) 445 615 446 - ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 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 + ) 447 622 if err != nil { 448 - l.Error("failed to create client", "err", err) 449 - fail() 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) 628 + return 629 + } 630 + 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.") 450 635 return 451 636 } 637 + l = l.With("member", member) 452 638 453 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 639 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 454 640 if err != nil { 455 - l.Error("failed to reach knotserver", "err", err) 456 - k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 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.") 457 648 return 458 649 } 459 650 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)) 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() 463 656 return 464 657 } 465 658 466 - err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 659 + client, err := k.OAuth.AuthorizedClient(r) 467 660 if err != nil { 468 - l.Error("failed to add member to enforcer", "err", err) 661 + l.Error("failed to authorize client", "err", err) 469 662 fail() 470 663 return 471 664 } 472 665 473 - // success 474 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 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() 672 + if err != nil { 673 + l.Error("failed to save ACLs", "err", err) 674 + fail() 675 + return 676 + } 677 + 678 + // ok 679 + k.Pages.HxRefresh(w) 475 680 } 476 681 477 - func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 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 + }) 478 705 }
+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.Error404(w) 220 + mw.pages.ErrorKnot404(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 - http.Error(w, "invalid repo url", http.StatusNotFound) 237 + mw.pages.ErrorKnot404(w) 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 - http.Error(w, "invalid repo url", http.StatusNotFound) 286 + mw.pages.ErrorKnot404(w) 287 287 return 288 288 } 289 289
+98 -82
appview/oauth/handler/handler.go
··· 8 8 "log" 9 9 "net/http" 10 10 "net/url" 11 + "slices" 11 12 "strings" 12 13 "time" 13 14 ··· 25 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 26 27 "tangled.sh/tangled.sh/core/appview/pages" 27 28 "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 + 356 363 func (o *OAuthHandler) addToDefaultSpindle(did string) { 357 364 // use the tangled.sh app password to get an accessJwt 358 365 // and create an sh.tangled.spindle.member record with that 359 - 360 - defaultSpindle := "spindle.tangled.sh" 361 - appPassword := o.config.Core.AppPassword 362 - 363 366 spindleMembers, err := db.GetSpindleMembers( 364 367 o.db, 365 368 db.FilterEq("instance", "spindle.tangled.sh"), ··· 375 378 return 376 379 } 377 380 378 - // TODO: hardcoded tangled handle and did for now 379 - tangledHandle := "tangled.sh" 380 - tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 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 + } 387 + 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) 397 + return 398 + } 399 + 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 406 + 407 + allKnots, err := o.enforcer.GetKnotsForUser(did) 408 + if err != nil { 409 + log.Printf("failed to get knot members for did %s: %v", did, err) 410 + return 411 + } 412 + 413 + if slices.Contains(allKnots, defaultKnot) { 414 + log.Printf("did %s is already a member of the default knot", did) 415 + return 416 + } 381 417 382 - if appPassword == "" { 383 - log.Println("no app password configured, skipping spindle member addition") 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) 384 422 return 385 423 } 386 424 387 - log.Printf("adding %s to default spindle", did) 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 + } 388 451 389 452 resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 390 453 if err != nil { 391 - log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 392 - return 454 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 393 455 } 394 456 395 457 pdsEndpoint := resolved.PDSEndpoint() 396 458 if pdsEndpoint == "" { 397 - log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 398 - return 459 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 399 460 } 400 461 401 462 sessionPayload := map[string]string{ ··· 404 465 } 405 466 sessionBytes, err := json.Marshal(sessionPayload) 406 467 if err != nil { 407 - log.Printf("failed to marshal session payload: %v", err) 408 - return 468 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 409 469 } 410 470 411 471 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 412 472 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 413 473 if err != nil { 414 - log.Printf("failed to create session request: %v", err) 415 - return 474 + return nil, fmt.Errorf("failed to create session request: %v", err) 416 475 } 417 476 sessionReq.Header.Set("Content-Type", "application/json") 418 477 419 478 client := &http.Client{Timeout: 30 * time.Second} 420 479 sessionResp, err := client.Do(sessionReq) 421 480 if err != nil { 422 - log.Printf("failed to create session: %v", err) 423 - return 481 + return nil, fmt.Errorf("failed to create session: %v", err) 424 482 } 425 483 defer sessionResp.Body.Close() 426 484 427 485 if sessionResp.StatusCode != http.StatusOK { 428 - log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 429 - return 486 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 430 487 } 431 488 432 - var session struct { 433 - AccessJwt string `json:"accessJwt"` 434 - } 489 + var session session 435 490 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 436 - log.Printf("failed to decode session response: %v", err) 437 - return 491 + return nil, fmt.Errorf("failed to decode session response: %v", err) 438 492 } 439 493 440 - record := tangled.SpindleMember{ 441 - LexiconTypeID: "sh.tangled.spindle.member", 442 - Subject: did, 443 - Instance: defaultSpindle, 444 - CreatedAt: time.Now().Format(time.RFC3339), 445 - } 494 + session.PdsEndpoint = pdsEndpoint 495 + 496 + return &session, nil 497 + } 446 498 499 + func (s *session) putRecord(record any) error { 447 500 recordBytes, err := json.Marshal(record) 448 501 if err != nil { 449 - log.Printf("failed to marshal spindle member record: %v", err) 450 - return 502 + return fmt.Errorf("failed to marshal knot member record: %w", err) 451 503 } 452 504 453 - payload := map[string]interface{}{ 505 + payload := map[string]any{ 454 506 "repo": tangledDid, 455 - "collection": tangled.SpindleMemberNSID, 507 + "collection": tangled.KnotMemberNSID, 456 508 "rkey": tid.TID(), 457 509 "record": json.RawMessage(recordBytes), 458 510 } 459 511 460 512 payloadBytes, err := json.Marshal(payload) 461 513 if err != nil { 462 - log.Printf("failed to marshal request payload: %v", err) 463 - return 514 + return fmt.Errorf("failed to marshal request payload: %w", err) 464 515 } 465 516 466 - url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 517 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 467 518 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 468 519 if err != nil { 469 - log.Printf("failed to create HTTP request: %v", err) 470 - return 520 + return fmt.Errorf("failed to create HTTP request: %w", err) 471 521 } 472 522 473 523 req.Header.Set("Content-Type", "application/json") 474 - req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 524 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 475 525 526 + client := &http.Client{Timeout: 30 * time.Second} 476 527 resp, err := client.Do(req) 477 528 if err != nil { 478 - log.Printf("failed to add user to default spindle: %v", err) 479 - return 529 + return fmt.Errorf("failed to add user to default Knot: %w", err) 480 530 } 481 531 defer resp.Body.Close() 482 532 483 533 if resp.StatusCode != http.StatusOK { 484 - log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 485 - return 486 - } 487 - 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 534 + return fmt.Errorf("failed to add user to default Knot: HTTP %d", resp.StatusCode) 516 535 } 517 536 518 - if resp.StatusCode != http.StatusNoContent { 519 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 520 - return 521 - } 537 + return nil 522 538 }
+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 + }, 289 292 }, nil 290 293 } 291 294
+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" 24 25 ) 25 26 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 276 277 }, 277 278 "layoutCenter": func() string { 278 279 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 279 292 }, 280 293 } 281 294 }
+81 -35
appview/pages/pages.go
··· 306 306 return p.execute("timeline/timeline", w, params) 307 307 } 308 308 309 - type SettingsParams struct { 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 { 310 320 LoggedInUser *oauth.User 311 321 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 312 332 Emails []db.Email 333 + Tabs []map[string]any 334 + Tab string 313 335 } 314 336 315 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 316 - return p.execute("settings", w, params) 337 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 338 + return p.execute("user/settings/emails", w, params) 339 + } 340 + 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) 317 347 } 318 348 319 349 type KnotsParams struct { ··· 338 368 } 339 369 340 370 type KnotListingParams struct { 341 - db.Registration 371 + *db.Registration 342 372 } 343 373 344 374 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 345 375 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) 362 376 } 363 377 364 378 type SpindlesParams struct { ··· 408 422 return p.execute("repo/fork", w, params) 409 423 } 410 424 411 - type ProfilePageParams struct { 425 + type ProfileHomePageParams struct { 412 426 LoggedInUser *oauth.User 413 427 Repos []db.Repo 414 428 CollaboratingRepos []db.Repo ··· 418 432 } 419 433 420 434 type ProfileCard struct { 421 - UserDid string 422 - UserHandle string 423 - FollowStatus db.FollowStatus 424 - Followers int 425 - Following int 435 + UserDid string 436 + UserHandle string 437 + FollowStatus db.FollowStatus 438 + FollowersCount int 439 + FollowingCount int 426 440 427 441 Profile *db.Profile 428 442 } 429 443 430 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 444 + func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 431 445 return p.execute("user/profile", w, params) 432 446 } 433 447 ··· 439 453 440 454 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 441 455 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) 442 484 } 443 485 444 486 type FollowFragmentParams struct { ··· 497 539 } 498 540 499 541 type RepoIndexParams struct { 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 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 508 550 HTMLReadme template.HTML 509 551 Raw bool 510 552 EmailToDidOrHandle map[string]string ··· 1270 1312 1271 1313 func (p *Pages) Error404(w io.Writer) error { 1272 1314 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) 1273 1319 } 1274 1320 1275 1321 func (p *Pages) Error503(w io.Writer) error {
+24 -4
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 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> 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> 8 28 {{ end }}
+36 -3
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</p> 6 - {{ end }} 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 }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 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> 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> 9 32 {{ 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 }}
+93 -28
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 2 3 3 {{ define "content" }} 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 }} 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 }} 13 + {{ template "knots/fragments/addMemberModal" .Registration }} 14 + {{ 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 12 18 </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> 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> 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 }} 21 26 {{ end }} 22 - </div> 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 23 32 </div> 24 - <div id="operation-error" class="dark:text-red-400"></div> 25 33 </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 26 36 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 }} 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> 43 + {{ end }} 34 44 {{ end }} 35 45 36 - {{ define "knotMember" }} 46 + 47 + {{ define "member" }} 37 48 {{ range .Members }} 38 49 <div> 39 50 <div class="flex justify-between items-center"> ··· 41 52 {{ template "user/fragments/picHandleLink" . }} 42 53 <span class="ml-2 font-mono text-gray-500">{{.}}</span> 43 54 </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 44 58 </div> 45 59 <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 46 60 {{ $repos := index $.Repos . }} ··· 53 67 </div> 54 68 {{ else }} 55 69 <div class="text-gray-500 dark:text-gray-400"> 56 - No repositories created yet. 70 + No repositories configured yet. 57 71 </div> 58 72 {{ end }} 59 73 </div> 60 74 </div> 61 75 {{ end }} 62 76 {{ 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 +
+6 -7
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 spindle" 4 + title="Add member to this knot" 5 5 popovertarget="add-member-{{ .Id }}" 6 6 popovertargetaction="toggle" 7 7 > ··· 20 20 21 21 {{ define "addKnotMemberPopover" }} 22 22 <form 23 - hx-put="/knots/{{ .Domain }}/member" 23 + hx-post="/knots/{{ .Domain }}/add" 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 on this knot.</p> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 32 <input 33 33 type="text" 34 34 id="member-did-{{ .Id }}" 35 - name="subject" 35 + name="member" 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 }} 58 - 57 + {{ end }}
+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/tree/master/docs/migrations">Click to read the upgrade guide</a>. 7 + </div> 8 + {{ end }} 9 +
+57 -25
appview/pages/templates/knots/fragments/knotListing.html
··· 1 1 {{ define "knots/fragments/knotListing" }} 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 }} 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 }} 8 5 </div> 9 6 {{ end }} 10 7 11 - {{ define "listLeftSide" }} 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 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 12 20 <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 21 {{ i "hard-drive" "w-4 h-4" }} 14 - {{ if .Registered }} 15 - <a href="/knots/{{ .Domain }}"> 16 - {{ .Domain }} 17 - </a> 18 - {{ else }} 19 - {{ .Domain }} 20 - {{ end }} 22 + {{ .Domain }} 21 23 <span class="text-gray-500"> 22 24 {{ template "repo/fragments/shortTimeAgo" .Created }} 23 25 </span> 24 26 </div> 27 + {{ end }} 25 28 {{ end }} 26 29 27 - {{ define "listRightSide" }} 30 + {{ define "knotRightSide" }} 28 31 <div id="right-side" class="flex gap-2"> 29 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 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> 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> 32 37 {{ 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 }} 33 45 {{ else }} 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 }} 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 }} 36 51 {{ end }} 37 52 </div> 38 53 {{ end }} 39 54 40 - {{ define "initializeButton" }} 55 + {{ define "knotDeleteButton" }} 56 + <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 }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 41 72 <button 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" 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 44 76 hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 45 78 > 46 - {{ i "square-play" "w-5 h-5" }} 47 - <span class="hidden md:inline">initialize</span> 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 48 81 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 82 </button> 50 83 {{ end }} 51 -
-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 }}
+23 -8
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 - {{ template "knots/fragments/knotListingFull" . }} 11 + {{ block "list" . }} {{ end }} 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 + 30 46 {{ define "register" }} 31 - <section class="rounded max-w-2xl flex flex-col gap-2"> 47 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 32 48 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 33 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 49 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 34 50 <form 35 - hx-post="/knots/key" 36 - class="space-y-4" 51 + hx-post="/knots/register" 52 + class="max-w-2xl mb-2 space-y-4" 37 53 hx-indicator="#register-button" 38 54 hx-swap="none" 39 55 > ··· 53 69 > 54 70 <span class="inline-flex items-center gap-2"> 55 71 {{ i "plus" "w-4 h-4" }} 56 - generate 72 + register 57 73 </span> 58 74 <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 75 {{ i "loader-circle" "w-4 h-4 animate-spin" }} ··· 61 77 </button> 62 78 </div> 63 79 64 - <div id="registration-error" class="error dark:text-red-400"></div> 80 + <div id="register-error" class="error dark:text-red-400"></div> 65 81 </form> 66 82 67 - <div id="secret"></div> 68 83 </section> 69 84 {{ 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 }} 24 31 {{ end }} 25 32 26 33 {{ define "newButton" }}
+8 -2
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"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 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">fork repo</button> 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> 34 40 <div id="repo" class="error"></div> 35 41 </div> 36 42 </form>
+22 -47
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 }} 120 87 <a 121 88 href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 122 89 class="btn flex items-center gap-2 no-underline hover:no-underline" ··· 356 323 357 324 {{ define "repoAfter" }} 358 325 {{- if or .HTMLReadme .Readme -}} 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> 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> 373 348 {{- end -}} 374 349 {{ 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="create-pull-spinner" class="group"> 66 + <span id="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>
+3 -1
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> 11 12 </div> 12 13 </section> 13 14 {{ end }} ··· 22 23 unless you specify a different branch. 23 24 </p> 24 25 </div> 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"> 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"> 26 27 <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"> 27 28 <option value="" disabled selected > 28 29 Choose a default branch ··· 54 55 <button 55 56 class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 56 57 type="button" 58 + hx-swap="none" 57 59 hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 58 60 hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 59 61 {{ 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 "addMemberPopover" . }} {{ end }} 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }} 20 20 21 - {{ define "addMemberPopover" }} 21 + {{ define "addSpindleMemberPopover" }} 22 22 <form 23 23 hx-post="/spindles/{{ .Instance }}/add" 24 24 hx-indicator="#spinner"
+11 -9
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 "leftSide" . }} {{ end }} 4 - {{ block "rightSide" . }} {{ end }} 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 5 </div> 6 6 {{ end }} 7 7 8 - {{ define "leftSide" }} 8 + {{ define "spindleLeftSide" }} 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 - {{ .Instance }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 13 15 <span class="text-gray-500"> 14 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 17 </span> ··· 25 27 {{ end }} 26 28 {{ end }} 27 29 28 - {{ define "rightSide" }} 30 + {{ define "spindleRightSide" }} 29 31 <div id="right-side" class="flex gap-2"> 30 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 31 33 {{ if .Verified }} ··· 33 35 {{ template "spindles/fragments/addMemberModal" . }} 34 36 {{ else }} 35 37 <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> 36 - {{ block "retryButton" . }} {{ end }} 38 + {{ block "spindleRetryButton" . }} {{ end }} 37 39 {{ end }} 38 - {{ block "deleteButton" . }} {{ end }} 40 + {{ block "spindleDeleteButton" . }} {{ end }} 39 41 </div> 40 42 {{ end }} 41 43 42 - {{ define "deleteButton" }} 44 + {{ define "spindleDeleteButton" }} 43 45 <button 44 46 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 45 47 title="Delete spindle" ··· 55 57 {{ end }} 56 58 57 59 58 - {{ define "retryButton" }} 60 + {{ define "spindleRetryButton" }} 59 61 <button 60 62 class="btn gap-2 group" 61 63 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 text-sm"> 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 175 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers">{{ .Followers }} followers</span> 176 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 177 <span class="select-none after:content-['ยท']"></span> 178 - <span id="following">{{ .Following }} following</span> 178 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></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="followBtn" 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 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="#followBtn" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 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 }}
+17 -14
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 2 3 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 4 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 5 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 8 9 </div> 9 10 <div class="col-span-2"> 10 11 <div class="flex items-center flex-row flex-nowrap gap-2"> 11 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 12 + <p title="{{ $userIdent }}" 12 13 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 - {{ didOrHandle .UserDid .UserHandle }} 14 + {{ $userIdent }} 14 15 </p> 15 - <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 17 </div> 17 18 18 19 <div class="md:hidden"> 19 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 20 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 20 21 </div> 21 22 </div> 22 23 <div class="col-span-3 md:col-span-full"> ··· 29 30 {{ end }} 30 31 31 32 <div class="hidden md:block"> 32 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 33 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 33 34 </div> 34 35 35 36 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 42 43 {{ if .IncludeBluesky }} 43 44 <div class="flex items-center gap-2"> 44 45 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 45 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 46 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 46 47 </div> 47 48 {{ end }} 48 49 {{ range $link := .Links }} ··· 88 89 {{ end }} 89 90 90 91 {{ define "followerFollowing" }} 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> 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 }} 99 102 {{ end }} 100 103
+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 }}/repos" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=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 + {{ if .LoggedInUser.Handle }} 34 + <span class="font-bold"> 35 + @{{ .LoggedInUser.Handle }} 36 + </span> 37 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 38 + <span>Handle</span> 39 + </div> 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 + <span class="font-mono text-xs"> 46 + {{ .LoggedInUser.Did }} 47 + </span> 48 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 49 + <span>Decentralized Identifier (DID)</span> 50 + </div> 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 + <span class="font-bold"> 56 + {{ .LoggedInUser.Pds }} 57 + </span> 58 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 59 + <span>Personal Data Server (PDS)</span> 60 + </div> 61 + </div> 62 + </div> 63 + </div> 64 + {{ end }}
+113 -92
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "log" 10 8 "net/http" 11 9 "sort" ··· 21 19 "tangled.sh/tangled.sh/core/appview/pages" 22 20 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 23 "tangled.sh/tangled.sh/core/idresolver" 25 24 "tangled.sh/tangled.sh/core/knotclient" 26 25 "tangled.sh/tangled.sh/core/patchutil" ··· 30 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 31 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 32 31 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(f, pull, stack) 99 + mergeCheckResponse := s.mergeCheck(r, 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(f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(r, 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(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 218 + func (s *Pulls) mergeCheck(r *http.Request, 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 - 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 - } 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 229 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 230 228 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 - } 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 237 231 } 238 232 239 233 patch := pull.LatestPatch() ··· 246 240 patch = mergeable.CombinedPatch() 247 241 } 248 242 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) 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) 252 255 return types.MergeCheckResponse{ 253 - Error: "failed to check merge status", 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 254 257 } 255 258 } 256 - switch resp.StatusCode { 257 - case 404: 258 - return types.MergeCheckResponse{ 259 - Error: "failed to check merge status: this knot does not support PRs", 260 - } 261 - case 400: 262 - return types.MergeCheckResponse{ 263 - Error: "failed to check merge status: does this knot support PRs?", 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, 264 266 } 265 267 } 266 268 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 - } 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 272 + } 273 + 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 273 276 } 274 - defer resp.Body.Close() 275 277 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 - } 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 283 280 } 284 281 285 - return mergeCheckResponse 282 + return result 286 283 } 287 284 288 285 func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { ··· 867 864 return 868 865 } 869 866 870 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 871 - if err != nil { 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) 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 + ) 878 873 if err != nil { 879 - log.Println("failed to create signed client:", err) 874 + log.Printf("failed to connect to knot server: %v", err) 880 875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 881 876 return 882 877 } ··· 888 883 return 889 884 } 890 885 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.") 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()) 895 897 return 896 898 } 897 899 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.") 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) 902 906 return 903 907 } 904 908 ··· 1464 1468 return 1465 1469 } 1466 1470 1467 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 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 + ) 1468 1478 if err != nil { 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.") 1479 + log.Printf("failed to connect to knot server: %v", err) 1471 1480 return 1472 1481 } 1473 1482 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.") 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()) 1479 1494 return 1480 1495 } 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.") 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.") 1486 1499 return 1487 1500 } 1488 1501 ··· 1908 1921 1909 1922 patch := pullsToMerge.CombinedPatch() 1910 1923 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 - 1918 1924 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1919 1925 if err != nil { 1920 1926 log.Printf("resolving identity: %s", err) ··· 1927 1933 log.Printf("failed to get primary email: %s", err) 1928 1934 } 1929 1935 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 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, 1935 1944 } 1936 1945 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) 1946 + if pull.Body != "" { 1947 + mergeInput.CommitBody = &pull.Body 1948 + } 1949 + 1950 + if email.Address != "" { 1951 + mergeInput.AuthorEmail = &email.Address 1952 + } 1953 + 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 + ) 1939 1960 if err != nil { 1940 - log.Printf("failed to merge pull request: %s", err) 1961 + log.Printf("failed to connect to knot server: %v", err) 1941 1962 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1942 1963 return 1943 1964 } 1944 1965 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.") 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()) 1948 1969 return 1949 1970 } 1950 1971
+10 -100
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "encoding/json" 5 - "fmt" 6 4 "log" 7 5 "net/http" 8 6 "slices" ··· 11 9 12 10 "tangled.sh/tangled.sh/core/appview/commitverify" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 12 "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 18 14 "tangled.sh/tangled.sh/core/knotclient" 19 15 "tangled.sh/tangled.sh/core/types" ··· 105 101 user := rp.oauth.GetUser(r) 106 102 repoInfo := f.RepoInfo(user) 107 103 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 - 129 104 // TODO: a bit dirty 130 - languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "") 105 + languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 131 106 if err != nil { 132 107 log.Printf("failed to compute language percentages: %s", err) 133 108 // non-fatal ··· 144 119 } 145 120 146 121 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 147 - LoggedInUser: user, 148 - RepoInfo: repoInfo, 149 - TagMap: tagMap, 150 - RepoIndexResponse: *result, 151 - CommitsTrunc: commitsTrunc, 152 - TagsTrunc: tagsTrunc, 153 - ForkInfo: forkInfo, 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 154 129 BranchesTrunc: branchesTrunc, 155 130 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 156 131 VerifiedCommits: vc, ··· 161 136 162 137 func (rp *Repo) getLanguageInfo( 163 138 f *reporesolver.ResolvedRepo, 164 - signedClient *knotclient.SignedClient, 139 + us *knotclient.UnsignedClient, 165 140 currentRef string, 166 141 isDefaultRef bool, 167 142 ) ([]types.RepoLanguageDetails, error) { ··· 174 149 175 150 if err != nil || langs == nil { 176 151 // non-fatal, fetch langs from ks 177 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 152 + ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 178 153 if err != nil { 179 154 return nil, err 180 155 } ··· 231 206 232 207 return languageStats, nil 233 208 } 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 - }
+221 -203
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" 20 22 "tangled.sh/tangled.sh/core/api/tangled" 21 23 "tangled.sh/tangled.sh/core/appview/commitverify" 22 24 "tangled.sh/tangled.sh/core/appview/config" ··· 26 28 "tangled.sh/tangled.sh/core/appview/pages" 27 29 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 32 "tangled.sh/tangled.sh/core/eventconsumer" 30 33 "tangled.sh/tangled.sh/core/idresolver" 31 34 "tangled.sh/tangled.sh/core/knotclient" ··· 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 35 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 36 40 37 41 securejoin "github.com/cyphar/filepath-securejoin" 38 42 "github.com/go-chi/chi/v5" 39 43 "github.com/go-git/go-git/v5/plumbing" 40 44 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 45 "github.com/bluesky-social/indigo/atproto/syntax" 43 - lexutil "github.com/bluesky-social/indigo/lex/util" 44 46 ) 45 47 46 48 type Repo struct { ··· 54 56 enforcer *rbac.Enforcer 55 57 notifier notify.Notifier 56 58 logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 57 60 } 58 61 59 62 func New( ··· 125 128 126 129 repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 127 130 if err != nil { 131 + rp.pages.Error503(w) 128 132 log.Println("failed to reach knotserver", err) 129 133 return 130 134 } 131 135 132 136 tagResult, err := us.Tags(f.OwnerDid(), f.Name) 133 137 if err != nil { 138 + rp.pages.Error503(w) 134 139 log.Println("failed to reach knotserver", err) 135 140 return 136 141 } ··· 146 151 147 152 branchResult, err := us.Branches(f.OwnerDid(), f.Name) 148 153 if err != nil { 154 + rp.pages.Error503(w) 149 155 log.Println("failed to reach knotserver", err) 150 156 return 151 157 } ··· 312 318 313 319 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 314 320 if err != nil { 321 + rp.pages.Error503(w) 315 322 log.Println("failed to reach knotserver", err) 316 323 return 317 324 } ··· 375 382 if !rp.config.Core.Dev { 376 383 protocol = "https" 377 384 } 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 + 378 390 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 379 391 if err != nil { 392 + rp.pages.Error503(w) 380 393 log.Println("failed to reach knotserver", err) 381 394 return 382 395 } 383 396 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 + 384 405 body, err := io.ReadAll(resp.Body) 385 406 if err != nil { 386 407 log.Printf("Error reading response body: %v", err) ··· 438 459 439 460 result, err := us.Tags(f.OwnerDid(), f.Name) 440 461 if err != nil { 462 + rp.pages.Error503(w) 441 463 log.Println("failed to reach knotserver", err) 442 464 return 443 465 } ··· 495 517 496 518 result, err := us.Branches(f.OwnerDid(), f.Name) 497 519 if err != nil { 520 + rp.pages.Error503(w) 498 521 log.Println("failed to reach knotserver", err) 499 522 return 500 523 } ··· 524 547 } 525 548 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 526 549 if err != nil { 550 + rp.pages.Error503(w) 527 551 log.Println("failed to reach knotserver", err) 552 + return 553 + } 554 + 555 + if resp.StatusCode == http.StatusNotFound { 556 + rp.pages.Error404(w) 528 557 return 529 558 } 530 559 ··· 834 863 fail("Failed to write record to PDS.", err) 835 864 return 836 865 } 837 - l = l.With("at-uri", resp.Uri) 838 - l.Info("wrote record to PDS") 839 866 840 - l.Info("adding to knot") 841 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 842 - if err != nil { 843 - fail("Failed to add to knot.", err) 844 - return 845 - } 867 + aturi := resp.Uri 868 + l = l.With("at-uri", aturi) 869 + l.Info("wrote record to PDS") 846 870 847 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 871 + tx, err := rp.db.BeginTx(r.Context(), nil) 848 872 if err != nil { 849 - fail("Failed to add to knot.", err) 873 + fail("Failed to add collaborator.", err) 850 874 return 851 875 } 852 876 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 - } 877 + rollback := func() { 878 + err1 := tx.Rollback() 879 + err2 := rp.enforcer.E.LoadPolicy() 880 + err3 := rollbackRecord(context.Background(), aturi, client) 858 881 859 - if ksResp.StatusCode != http.StatusNoContent { 860 - fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil) 861 - return 862 - } 882 + // ignore txn complete errors, this is okay 883 + if errors.Is(err1, sql.ErrTxDone) { 884 + err1 = nil 885 + } 863 886 864 - tx, err := rp.db.BeginTx(r.Context(), nil) 865 - if err != nil { 866 - fail("Failed to add collaborator.", err) 867 - return 887 + if errs := errors.Join(err1, err2, err3); errs != nil { 888 + l.Error("failed to rollback changes", "errs", errs) 889 + return 890 + } 868 891 } 869 - defer func() { 870 - tx.Rollback() 871 - err = rp.enforcer.E.LoadPolicy() 872 - if err != nil { 873 - fail("Failed to add collaborator.", err) 874 - } 875 - }() 892 + defer rollback() 876 893 877 894 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 878 895 if err != nil { ··· 904 921 return 905 922 } 906 923 924 + // clear aturi to when everything is successful 925 + aturi = "" 926 + 907 927 rp.pages.HxRefresh(w) 908 928 } 909 929 910 930 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 911 931 user := rp.oauth.GetUser(r) 912 932 933 + noticeId := "operation-error" 913 934 f, err := rp.repoResolver.Resolve(r) 914 935 if err != nil { 915 936 log.Println("failed to get repo and knot", err) ··· 929 950 }) 930 951 if err != nil { 931 952 log.Printf("failed to delete record: %s", err) 932 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 953 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 933 954 return 934 955 } 935 956 log.Println("removed repo record ", f.RepoAt().String()) 936 957 937 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 938 - if err != nil { 939 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 940 - return 941 - } 942 - 943 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 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 + ) 944 964 if err != nil { 945 - log.Println("failed to create client to ", f.Knot) 965 + log.Println("failed to connect to knot server:", err) 946 966 return 947 967 } 948 968 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) 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()) 952 980 return 953 981 } 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 - } 982 + log.Println("deleted repo from knot") 960 983 961 984 tx, err := rp.db.BeginTx(r.Context(), nil) 962 985 if err != nil { ··· 975 998 // remove collaborator RBAC 976 999 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 977 1000 if err != nil { 978 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1001 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 979 1002 return 980 1003 } 981 1004 for _, c := range repoCollaborators { ··· 987 1010 // remove repo RBAC 988 1011 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 989 1012 if err != nil { 990 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1013 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 991 1014 return 992 1015 } 993 1016 994 1017 // remove repo from db 995 1018 err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 996 1019 if err != nil { 997 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1020 + rp.pages.Notice(w, noticeId, "Failed to update appview") 998 1021 return 999 1022 } 1000 1023 log.Println("removed repo from db") ··· 1023 1046 return 1024 1047 } 1025 1048 1049 + noticeId := "operation-error" 1026 1050 branch := r.FormValue("branch") 1027 1051 if branch == "" { 1028 1052 http.Error(w, "malformed form", http.StatusBadRequest) 1029 1053 return 1030 1054 } 1031 1055 1032 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1033 - if err != nil { 1034 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1035 - return 1036 - } 1037 - 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) 1041 - return 1042 - } 1043 - 1044 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch) 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 + ) 1045 1062 if err != nil { 1046 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1063 + log.Println("failed to connect to knot server:", err) 1064 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1047 1065 return 1048 1066 } 1049 1067 1050 - if ksResp.StatusCode != http.StatusNoContent { 1051 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 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()) 1052 1079 return 1053 1080 } 1054 1081 1055 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 1082 + rp.pages.HxRefresh(w) 1056 1083 } 1057 1084 1058 1085 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { ··· 1168 1195 case "pipelines": 1169 1196 rp.pipelineSettings(w, r) 1170 1197 } 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 - // }) 1231 1198 } 1232 1199 1233 1200 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { ··· 1242 1209 1243 1210 result, err := us.Branches(f.OwnerDid(), f.Name) 1244 1211 if err != nil { 1212 + rp.pages.Error503(w) 1245 1213 log.Println("failed to reach knotserver", err) 1246 1214 return 1247 1215 } ··· 1346 1314 1347 1315 switch r.Method { 1348 1316 case http.MethodPost: 1349 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 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 + ) 1350 1323 if err != nil { 1351 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1324 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1352 1325 return 1353 1326 } 1354 1327 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.") 1328 + repoInfo := f.RepoInfo(user) 1329 + if repoInfo.Source == nil { 1330 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 1358 1331 return 1359 1332 } 1360 1333 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.") 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()) 1373 1346 return 1374 1347 } 1375 1348 ··· 1402 1375 }) 1403 1376 1404 1377 case http.MethodPost: 1378 + l := rp.logger.With("handler", "ForkRepo") 1405 1379 1406 - knot := r.FormValue("knot") 1407 - if knot == "" { 1380 + targetKnot := r.FormValue("knot") 1381 + if targetKnot == "" { 1408 1382 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1409 1383 return 1410 1384 } 1385 + l = l.With("targetKnot", targetKnot) 1411 1386 1412 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1387 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1413 1388 if err != nil || !ok { 1414 1389 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1415 1390 return 1416 1391 } 1417 1392 1418 - forkName := fmt.Sprintf("%s", f.Name) 1419 - 1393 + // choose a name for a fork 1394 + forkName := f.Name 1420 1395 // this check is *only* to see if the forked repo name already exists 1421 1396 // in the user's account. 1422 1397 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) ··· 1432 1407 // repo with this name already exists, append random string 1433 1408 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1434 1409 } 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 - } 1410 + l = l.With("forkName", forkName) 1440 1411 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 - } 1446 - 1447 - var uri string 1412 + uri := "https" 1448 1413 if rp.config.Core.Dev { 1449 1414 uri = "http" 1450 - } else { 1451 - uri = "https" 1452 1415 } 1416 + 1453 1417 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1418 + l = l.With("cloneUrl", forkSourceUrl) 1419 + 1454 1420 sourceAt := f.RepoAt().String() 1455 1421 1422 + // create an atproto record for this fork 1456 1423 rkey := tid.TID() 1457 1424 repo := &db.Repo{ 1458 1425 Did: user.Did, 1459 1426 Name: forkName, 1460 - Knot: knot, 1427 + Knot: targetKnot, 1461 1428 Rkey: rkey, 1462 1429 Source: sourceAt, 1463 1430 } 1464 1431 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 - 1495 1432 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1496 1433 if err != nil { 1497 - log.Println("failed to get authorized client", err) 1498 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1434 + l.Error("failed to create xrpcclient", "err", err) 1435 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1499 1436 return 1500 1437 } 1501 1438 ··· 1514 1451 }}, 1515 1452 }) 1516 1453 if err != nil { 1517 - log.Printf("failed to create record: %s", err) 1454 + l.Error("failed to write to PDS", "err", err) 1518 1455 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1519 1456 return 1520 1457 } 1521 - log.Println("created repo record: ", atresp.Uri) 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 + } 1522 1515 1523 1516 err = db.AddRepo(tx, repo) 1524 1517 if err != nil { ··· 1529 1522 1530 1523 // acls 1531 1524 p, _ := securejoin.SecureJoin(user.Did, forkName) 1532 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1525 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1533 1526 if err != nil { 1534 1527 log.Println(err) 1535 1528 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1550 1543 return 1551 1544 } 1552 1545 1546 + // reset the ATURI because the transaction completed successfully 1547 + aturi = "" 1548 + 1549 + rp.notifier.NewRepo(r.Context(), repo) 1553 1550 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1554 - return 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 1555 1560 } 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 1556 1574 } 1557 1575 1558 1576 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 + }
+44 -9
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 + 36 46 func (s *Settings) Router() http.Handler { 37 47 r := chi.NewRouter() 38 48 39 49 r.Use(middleware.AuthMiddleware(s.OAuth)) 40 50 41 - r.Get("/", s.settings) 51 + // settings pages 52 + r.Get("/", s.profileSettings) 53 + r.Get("/profile", s.profileSettings) 42 54 43 55 r.Route("/keys", func(r chi.Router) { 56 + r.Get("/", s.keysSettings) 44 57 r.Put("/", s.keys) 45 58 r.Delete("/", s.keys) 46 59 }) 47 60 48 61 r.Route("/emails", func(r chi.Router) { 62 + r.Get("/", s.emailsSettings) 49 63 r.Put("/", s.emails) 50 64 r.Delete("/", s.emails) 51 65 r.Get("/verify", s.emailsVerify) ··· 56 70 return r 57 71 } 58 72 59 - func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 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) { 60 84 user := s.OAuth.GetUser(r) 61 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 62 86 if err != nil { 63 87 log.Println(err) 64 88 } 65 89 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) 66 100 emails, err := db.GetAllEmails(s.Db, user.Did) 67 101 if err != nil { 68 102 log.Println(err) 69 103 } 70 104 71 - s.Pages.Settings(w, pages.SettingsParams{ 105 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 72 106 LoggedInUser: user, 73 - PubKeys: pubKeys, 74 107 Emails: emails, 108 + Tabs: settingsTabs, 109 + Tab: "emails", 75 110 }) 76 111 } 77 112 ··· 201 236 return 202 237 } 203 238 204 - s.Pages.HxLocation(w, "/settings") 239 + s.Pages.HxLocation(w, "/settings/emails") 205 240 return 206 241 } 207 242 } ··· 244 279 return 245 280 } 246 281 247 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 282 + http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 248 283 } 249 284 250 285 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 339 374 return 340 375 } 341 376 342 - s.Pages.HxLocation(w, "/settings") 377 + s.Pages.HxLocation(w, "/settings/emails") 343 378 } 344 379 345 380 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 410 445 return 411 446 } 412 447 413 - s.Pages.HxLocation(w, "/settings") 448 + s.Pages.HxLocation(w, "/settings/keys") 414 449 return 415 450 416 451 case http.MethodDelete: ··· 455 490 } 456 491 log.Println("deleted successfully") 457 492 458 - s.Pages.HxLocation(w, "/settings") 493 + s.Pages.HxLocation(w, "/settings/keys") 459 494 return 460 495 } 461 496 }
+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 - verify "tangled.sh/tangled.sh/core/appview/spindleverify" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 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 = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 230 + err = serververify.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 = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 237 + _, err = serververify.MarkSpindleVerified(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 = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 403 + err = serververify.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, verify.FetchError) { 408 - s.Pages.Notice(w, noticeId, err.Error()) 407 + if errors.Is(err, serververify.FetchError) { 408 + s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 409 409 return 410 410 } 411 411 412 - if e, ok := err.(*verify.OwnerMismatch); ok { 412 + if e, ok := err.(*serververify.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 := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did) 421 + rowId, err := serververify.MarkSpindleVerified(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 - }
+5 -2
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.GetCompletedRegistrations(d) 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 28 31 if err != nil { 29 32 return nil, err 30 33 } 31 34 32 35 srcs := make(map[ec.Source]struct{}) 33 36 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 37 + s := ec.NewKnotSource(k.Domain) 35 38 srcs[s] = struct{}{} 36 39 } 37 40
+212 -62
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" 20 21 "tangled.sh/tangled.sh/core/appview/pages" 21 22 ) 22 23 ··· 24 25 tabVal := r.URL.Query().Get("tab") 25 26 switch tabVal { 26 27 case "": 27 - s.profilePage(w, r) 28 + s.profileHomePage(w, r) 28 29 case "repos": 29 30 s.reposPage(w, r) 31 + case "followers": 32 + s.followersPage(w, r) 33 + case "following": 34 + s.followingPage(w, r) 30 35 } 31 36 } 32 37 33 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 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 { 34 45 didOrHandle := chi.URLParam(r, "user") 35 46 if didOrHandle == "" { 36 - http.Error(w, "Bad request", http.StatusBadRequest) 37 - return 47 + http.Error(w, "bad request", http.StatusBadRequest) 48 + return nil 38 49 } 39 50 40 51 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 41 52 if !ok { 42 - s.pages.Error404(w) 43 - return 53 + log.Printf("malformed middleware") 54 + w.WriteHeader(http.StatusInternalServerError) 55 + return nil 56 + } 57 + did := ident.DID.String() 58 + 59 + profile, err := db.GetProfile(s.db, did) 60 + if err != nil { 61 + log.Printf("getting profile data for %s: %s", did, err) 62 + s.pages.Error500(w) 63 + return nil 44 64 } 45 65 46 - profile, err := db.GetProfile(s.db, ident.DID.String()) 66 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 47 67 if err != nil { 48 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 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) 49 75 } 50 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 51 98 repos, err := db.GetRepos( 52 99 s.db, 53 100 0, 54 - db.FilterEq("did", ident.DID.String()), 101 + db.FilterEq("did", id.DID), 55 102 ) 56 103 if err != nil { 57 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 104 + log.Printf("getting repos for %s: %s", id.DID, err) 58 105 } 59 106 107 + profile := pageWithProfile.Card.Profile 60 108 // filter out ones that are pinned 61 109 pinnedRepos := []db.Repo{} 62 110 for i, r := range repos { ··· 71 119 } 72 120 } 73 121 74 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 122 + collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 75 123 if err != nil { 76 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 124 + log.Printf("getting collaborating repos for %s: %s", id.DID, err) 77 125 } 78 126 79 127 pinnedCollaboratingRepos := []db.Repo{} ··· 84 132 } 85 133 } 86 134 87 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 135 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 88 136 if err != nil { 89 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 137 + log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 90 138 } 91 139 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) 140 + var didsToResolve []string 141 + for _, r := range collaboratingRepos { 142 + didsToResolve = append(didsToResolve, r.Did) 95 143 } 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()) 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 + } 101 157 } 102 158 103 159 now := time.Now() 104 160 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 105 161 punchcard, err := db.MakePunchcard( 106 162 s.db, 107 - db.FilterEq("did", ident.DID.String()), 163 + db.FilterEq("did", id.DID), 108 164 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 109 165 db.FilterLte("date", now.Format(time.DateOnly)), 110 166 ) 111 167 if err != nil { 112 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 168 + log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 113 169 } 114 170 115 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 116 - LoggedInUser: loggedInUser, 171 + s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 + LoggedInUser: pageWithProfile.LoggedInUser, 117 173 Repos: pinnedRepos, 118 174 CollaboratingRepos: pinnedCollaboratingRepos, 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, 175 + Card: pageWithProfile.Card, 176 + Punchcard: punchcard, 177 + ProfileTimeline: timeline, 129 178 }) 130 179 } 131 180 132 181 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 133 - ident, ok := r.Context().Value("resolvedId").(identity.Identity) 134 - if !ok { 135 - s.pages.Error404(w) 182 + pageWithProfile := s.profilePage(w, r) 183 + if pageWithProfile == nil { 136 184 return 137 185 } 138 186 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 - 187 + id := pageWithProfile.Id 144 188 repos, err := db.GetRepos( 145 189 s.db, 146 190 0, 147 - db.FilterEq("did", ident.DID.String()), 191 + db.FilterEq("did", id.DID), 148 192 ) 149 193 if err != nil { 150 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 194 + log.Printf("getting repos for %s: %s", id.DID, err) 151 195 } 152 196 153 - loggedInUser := s.oauth.GetUser(r) 154 - followStatus := db.IsNotFollowing 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 214 + } 215 + 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 223 + } 224 + 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)) 239 + 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{} 155 250 if loggedInUser != nil { 156 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 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 + } 261 + } 262 + 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 + }) 157 291 } 158 292 159 - followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 293 + return FollowsPageParams{ 294 + 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 }) 160 302 if err != nil { 161 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 303 + s.pages.Notice(w, "all-followers", "Failed to load followers") 304 + return 162 305 } 163 306 164 - s.pages.ReposPage(w, pages.ReposPageParams{ 165 - LoggedInUser: loggedInUser, 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 - }, 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, 175 325 }) 176 326 } 177 327
+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(mw)) 150 + r.Mount("/knots", s.KnotsRouter()) 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(mw *middleware.Middleware) http.Handler { 198 + func (s *State) KnotsRouter() 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(mw) 212 + return knots.Router() 213 213 } 214 214 215 215 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
+95 -41
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 6 + "errors" 5 7 "fmt" 6 8 "log" 7 9 "log/slog" ··· 10 12 "time" 11 13 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 13 16 lexutil "github.com/bluesky-social/indigo/lex/util" 14 17 securejoin "github.com/cyphar/filepath-securejoin" 15 18 "github.com/go-chi/chi/v5" ··· 25 28 "tangled.sh/tangled.sh/core/appview/pages" 26 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 28 32 "tangled.sh/tangled.sh/core/eventconsumer" 29 33 "tangled.sh/tangled.sh/core/idresolver" 30 34 "tangled.sh/tangled.sh/core/jetstream" 31 - "tangled.sh/tangled.sh/core/knotclient" 32 35 tlog "tangled.sh/tangled.sh/core/log" 33 36 "tangled.sh/tangled.sh/core/rbac" 34 37 "tangled.sh/tangled.sh/core/tid" 38 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 35 39 ) 36 40 37 41 type State struct { ··· 48 52 repoResolver *reporesolver.RepoResolver 49 53 knotstream *eventconsumer.Consumer 50 54 spindlestream *eventconsumer.Consumer 55 + logger *slog.Logger 51 56 } 52 57 53 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 94 99 tangled.SpindleMemberNSID, 95 100 tangled.SpindleNSID, 96 101 tangled.StringNSID, 97 - tangled.RepoIssueNSID, 98 - tangled.RepoIssueCommentNSID, 99 102 }, 100 103 nil, 101 104 slog.Default(), ··· 154 157 repoResolver, 155 158 knotstream, 156 159 spindlestream, 160 + slog.Default(), 157 161 } 158 162 159 163 return state, nil ··· 293 297 }) 294 298 295 299 case http.MethodPost: 300 + l := s.logger.With("handler", "NewRepo") 301 + 296 302 user := s.oauth.GetUser(r) 303 + l = l.With("did", user.Did) 304 + l = l.With("handle", user.Handle) 297 305 306 + // form validation 298 307 domain := r.FormValue("domain") 299 308 if domain == "" { 300 309 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 301 310 return 302 311 } 312 + l = l.With("knot", domain) 303 313 304 314 repoName := r.FormValue("name") 305 315 if repoName == "" { ··· 311 321 s.pages.Notice(w, "repo", err.Error()) 312 322 return 313 323 } 314 - 315 324 repoName = stripGitExt(repoName) 325 + l = l.With("repoName", repoName) 316 326 317 327 defaultBranch := r.FormValue("branch") 318 328 if defaultBranch == "" { 319 329 defaultBranch = "main" 320 330 } 331 + l = l.With("defaultBranch", defaultBranch) 321 332 322 333 description := r.FormValue("description") 323 334 335 + // ACL validation 324 336 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 325 337 if err != nil || !ok { 338 + l.Info("unauthorized") 326 339 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 327 340 return 328 341 } 329 342 343 + // Check for existing repos 330 344 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 331 345 if err == nil && existingRepo != nil { 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)) 339 - return 340 - } 341 - 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.") 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)) 345 348 return 346 349 } 347 350 351 + // create atproto record for this repo 348 352 rkey := tid.TID() 349 353 repo := &db.Repo{ 350 354 Did: user.Did, ··· 356 360 357 361 xrpcClient, err := s.oauth.AuthorizedClient(r) 358 362 if err != nil { 363 + l.Info("PDS write failed", "err", err) 359 364 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 360 365 return 361 366 } ··· 374 379 }}, 375 380 }) 376 381 if err != nil { 377 - log.Printf("failed to create record: %s", err) 382 + l.Info("PDS write failed", "err", err) 378 383 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 379 384 return 380 385 } 381 - log.Println("created repo record: ", atresp.Uri) 386 + 387 + aturi := atresp.Uri 388 + l = l.With("aturi", aturi) 389 + l.Info("wrote to PDS") 382 390 383 391 tx, err := s.db.BeginTx(r.Context(), nil) 384 392 if err != nil { 385 - log.Println(err) 393 + l.Info("txn failed", "err", err) 386 394 s.pages.Notice(w, "repo", "Failed to save repository information.") 387 395 return 388 396 } 389 - defer func() { 390 - tx.Rollback() 391 - err = s.enforcer.E.LoadPolicy() 392 - if err != nil { 393 - log.Println("failed to rollback policies") 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 394 410 } 395 - }() 396 411 397 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 412 + if errs := errors.Join(err1, err2, err3); errs != nil { 413 + l.Error("failed to rollback changes", "errs", errs) 414 + return 415 + } 416 + } 417 + defer rollback() 418 + 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 + ) 398 425 if err != nil { 399 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 426 + l.Error("service auth failed", "err", err) 427 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 400 428 return 401 429 } 402 430 403 - switch resp.StatusCode { 404 - case http.StatusConflict: 405 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 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()) 406 441 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 411 442 } 412 443 413 444 err = db.AddRepo(tx, repo) 414 445 if err != nil { 415 - log.Println(err) 446 + l.Error("db write failed", "err", err) 416 447 s.pages.Notice(w, "repo", "Failed to save repository information.") 417 448 return 418 449 } ··· 421 452 p, _ := securejoin.SecureJoin(user.Did, repoName) 422 453 err = s.enforcer.AddRepo(user.Did, domain, p) 423 454 if err != nil { 424 - log.Println(err) 455 + l.Error("acl setup failed", "err", err) 425 456 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 426 457 return 427 458 } 428 459 429 460 err = tx.Commit() 430 461 if err != nil { 431 - log.Println("failed to commit changes", err) 462 + l.Error("txn commit failed", "err", err) 432 463 http.Error(w, err.Error(), http.StatusInternalServerError) 433 464 return 434 465 } 435 466 436 467 err = s.enforcer.E.SavePolicy() 437 468 if err != nil { 438 - log.Println("failed to update ACLs", err) 469 + l.Error("acl save failed", "err", err) 439 470 http.Error(w, err.Error(), http.StatusInternalServerError) 440 471 return 441 472 } 442 473 443 - s.notifier.NewRepo(r.Context(), repo) 474 + // reset the ATURI because the transaction completed successfully 475 + aturi = "" 444 476 477 + s.notifier.NewRepo(r.Context(), repo) 445 478 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 446 - return 447 479 } 448 480 } 481 + 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 488 + } 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 + }
+7 -7
appview/strings/strings.go
··· 202 202 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 203 } 204 204 205 - followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 205 + followStats, 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 - Followers: followers, 218 - Following: following, 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + FollowersCount: followStats.Followers, 218 + FollowingCount: followStats.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" 6 8 "io" 9 + "net/http" 7 10 8 11 "github.com/bluesky-social/indigo/api/atproto" 9 12 "github.com/bluesky-social/indigo/xrpc" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 15 ) 12 16 ··· 102 106 103 107 return &out, nil 104 108 } 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{}, 27 28 tangled.KnotMember{}, 28 29 tangled.Pipeline{}, 29 30 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, 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. 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. 63 61 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. 62 + If you don't want to [set up a spindle](#running-a-spindle), 63 + you can use any placeholder value. 68 64 69 65 You can now start a lightweight NixOS VM like so: 70 66 ··· 75 71 ``` 76 72 77 73 This starts a knot on port 6000, a spindle on port 6555 78 - with `ssh` exposed on port 2222. You can push repositories 79 - to this VM with this ssh config block on your main machine: 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: 80 84 81 85 ```bash 82 86 Host nixos-shell ··· 95 99 96 100 ## running a spindle 97 101 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. 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. 106 106 107 107 Of interest when debugging spindles: 108 108
+7 -5
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_SECRET` can be obtained from the 77 - [/knots](https://tangled.sh/knots) page on Tangled. 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. 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_SECRET=secret 83 + KNOT_SERVER_OWNER=did:plc:foobar 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 your registration by hitting the 132 - `initialize` button on the [/knots](https://tangled.sh/knots) page. 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. 133 135 134 136 ### custom paths 135 137
+39
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 between services to knots are managed 6 + via [Service 7 + Auth](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 8 + Knots will be read-only until upgraded. 9 + 10 + Upgrading is quite easy, in essence: 11 + 12 + - `KNOT_SERVER_SECRET` is no more, you can remove this 13 + environment variable entirely 14 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 15 + your DID. You can find your DID in the 16 + [settings](https://tangled.sh/settings) page. 17 + - Restart your knot once you have replace the environment 18 + variable 19 + - Head to the [knot dashboard](https://tangled.sh/knots) and 20 + hit the "retry" button to verify your knot. This simply 21 + writes a `sh.tangled.knot` record to your PDS. 22 + 23 + ## Nix 24 + 25 + If you use the nix module, simply bump the flake to the 26 + latest revision, and change your config block like so: 27 + 28 + ```diff 29 + services.tangled-knot = { 30 + enable = true; 31 + server = { 32 + - secretFile = /path/to/secret; 33 + + owner = "did:plc:foo"; 34 + . 35 + . 36 + . 37 + }; 38 + }; 39 + ```
+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 api/tangled/* 255 + rm -f 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"` 21 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 22 21 Hostname string `env:"HOSTNAME, required"` 23 22 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.
+1008 -150
knotserver/handler.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 + "compress/gzip" 4 5 "context" 6 + "crypto/sha256" 7 + "encoding/json" 8 + "errors" 5 9 "fmt" 6 - "log/slog" 10 + "log" 7 11 "net/http" 8 - "runtime/debug" 12 + "net/url" 13 + "path/filepath" 14 + "strconv" 15 + "strings" 16 + "sync" 17 + "time" 9 18 19 + securejoin "github.com/cyphar/filepath-securejoin" 20 + "github.com/gliderlabs/ssh" 10 21 "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" 22 + "github.com/go-git/go-git/v5/plumbing" 23 + "github.com/go-git/go-git/v5/plumbing/object" 14 24 "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" 18 - "tangled.sh/tangled.sh/core/rbac" 25 + "tangled.sh/tangled.sh/core/knotserver/git" 26 + "tangled.sh/tangled.sh/core/types" 19 27 ) 20 28 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 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 + } 29 45 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 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) 34 53 } 35 54 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() 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() 38 70 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{}), 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 + } 48 85 } 49 86 50 - err := e.AddKnot(rbac.ThisServer) 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 + }() 152 + 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) 219 + } 220 + 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) 225 + 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) 51 230 if err != nil { 52 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 231 + notFound(w) 232 + return 53 233 } 54 234 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() 235 + files, err := gr.FileTree(r.Context(), treePath) 59 236 if err != nil { 60 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 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, 248 + } 249 + 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) 262 + if err != nil { 263 + notFound(w) 264 + return 265 + } 266 + 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" 61 279 } 62 280 63 - if len(dids) > 0 { 64 - h.knotInitialized = true 65 - close(h.init) 66 - for _, d := range dids { 67 - h.jc.AddDid(d) 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 68 290 } 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 69 300 } 70 301 71 - err = h.jc.StartJetstream(ctx, h.processMessages) 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) 315 + if err != nil { 316 + notFound(w) 317 + return 318 + } 319 + 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 + } 331 + 332 + bytes := []byte(contents) 333 + // safe := string(sanitize(bytes)) 334 + sizeHint := len(bytes) 335 + 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) 352 + 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 + } 358 + 359 + ref := strings.TrimSuffix(file, ".tar.gz") 360 + 361 + unescapedRef, err := url.PathUnescape(ref) 72 362 if err != nil { 73 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 363 + notFound(w) 364 + return 74 365 } 75 366 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 - }) 367 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 86 368 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 - }) 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) 92 374 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 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 + } 98 381 99 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 382 + gw := gzip.NewWriter(w) 383 + defer gw.Close() 100 384 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 - }) 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 + } 106 402 107 - r.Route("/tree/{ref}", func(r chi.Router) { 108 - r.Get("/", h.RepoIndex) 109 - r.Get("/*", h.RepoTree) 110 - }) 403 + func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 + ref := chi.URLParam(r, "ref") 405 + ref, _ = url.PathUnescape(ref) 111 406 112 - r.Route("/blob/{ref}", func(r chi.Router) { 113 - r.Get("/*", h.Blob) 114 - }) 407 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 115 408 116 - r.Route("/raw/{ref}", func(r chi.Router) { 117 - r.Get("/*", h.BlobRaw) 118 - }) 409 + l := h.l.With("handler", "Log", "ref", ref, "path", path) 119 410 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 - }) 411 + gr, err := git.Open(path, ref) 412 + if err != nil { 413 + notFound(w) 414 + return 415 + } 134 416 135 - // xrpc apis 136 - r.Mount("/xrpc", h.XrpcRouter()) 417 + // Get page parameters 418 + page := 1 419 + pageSize := 30 137 420 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 - }) 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 + } 149 426 150 - r.Route("/member", func(r chi.Router) { 151 - r.Use(h.VerifySignature) 152 - r.Put("/add", h.AddMember) 153 - }) 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 + } 154 432 155 - // Socket that streams git oplogs 156 - r.Get("/events", h.Events) 433 + // convert to offset/limit 434 + offset := (page - 1) * pageSize 435 + limit := pageSize 157 436 158 - // Initialize the knot with an owner and public key. 159 - r.With(h.VerifySignature).Post("/init", h.Init) 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 + } 160 443 161 - // Health check. Used for two-way verification with appview. 162 - r.With(h.VerifySignature).Get("/health", h.Health) 444 + total := len(commits) 163 445 164 - // All public keys on the knot. 165 - r.Get("/keys", h.Keys) 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 + } 166 455 167 - return r, nil 456 + writeJSON(w, resp) 168 457 } 169 458 170 - func (h *Handle) XrpcRouter() http.Handler { 171 - logger := tlog.New("knots") 459 + func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 + ref := chi.URLParam(r, "ref") 461 + ref, _ = url.PathUnescape(ref) 172 462 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, 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 181 470 } 182 - return xrpc.Router() 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) 183 485 } 184 486 185 - // version is set during build time. 186 - var version string 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") 187 490 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) 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 + } 521 + 522 + rtags = append(rtags, &tr) 523 + } 524 + 525 + resp := types.RepoTagsResponse{ 526 + Tags: rtags, 527 + } 528 + 529 + writeJSON(w, resp) 530 + } 531 + 532 + func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 + 535 + gr, err := git.PlainOpen(path) 536 + if err != nil { 537 + notFound(w) 538 + return 539 + } 540 + 541 + branches, _ := gr.Branches() 542 + 543 + resp := types.RepoBranchesResponse{ 544 + Branches: branches, 545 + } 546 + 547 + writeJSON(w, resp) 548 + } 549 + 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 + } 562 + 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 568 + } 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) 598 + } 599 + 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()) 193 609 return 194 610 } 195 611 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 - } 612 + data := make([]map[string]any, 0) 613 + for _, key := range keys { 614 + j := key.JSON() 615 + data = append(data, j) 202 616 } 617 + writeJSON(w, data) 618 + return 203 619 204 - if modVer == "" { 205 - version = "unknown" 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) 624 + return 206 625 } 626 + 627 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 + if err != nil { 629 + writeError(w, "invalid pubkey", http.StatusBadRequest) 630 + } 631 + 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 636 + } 637 + 638 + w.WriteHeader(http.StatusNoContent) 639 + return 640 + } 641 + } 642 + 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 207 732 } 208 733 209 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 210 - fmt.Fprintf(w, "knotserver/%s", version) 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 + }) 211 1069 }
-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 - }
+20 -35
knotserver/ingester.go
··· 8 8 "net/http" 9 9 "net/url" 10 10 "path/filepath" 11 - "slices" 12 11 "strings" 13 12 14 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 74 73 } 75 74 l.Info("added member from firehose", "member", record.Subject) 76 75 77 - if err := h.db.AddDid(did); err != nil { 76 + if err := h.db.AddDid(record.Subject); err != nil { 78 77 l.Error("failed to add did", "error", err) 79 78 return fmt.Errorf("failed to add did: %w", err) 80 79 } 81 - h.jc.AddDid(did) 80 + h.jc.AddDid(record.Subject) 82 81 83 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 84 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 85 84 } 86 85 ··· 103 102 l = l.With("target_branch", record.TargetBranch) 104 103 105 104 if record.Source == nil { 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) 105 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 109 106 } 110 107 111 108 if record.Source.Repo != nil { 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) 109 + return fmt.Errorf("ignoring pull record: fork based pull") 127 110 } 128 111 129 112 repoAt, err := syntax.ParseATURI(record.TargetRepo) 130 113 if err != nil { 131 - return err 114 + return fmt.Errorf("failed to parse ATURI: %w", err) 132 115 } 133 116 134 117 // resolve this aturi to extract the repo record ··· 144 127 145 128 resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 146 129 if err != nil { 147 - return err 130 + return fmt.Errorf("failed to resolver repo: %w", err) 148 131 } 149 132 150 133 repo := resp.Value.Val.(*tangled.Repo) 151 134 152 135 if 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) 136 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 156 137 } 157 138 158 139 didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 159 140 if err != nil { 160 - return err 141 + return fmt.Errorf("failed to construct relative repo path: %w", err) 161 142 } 162 143 163 144 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 164 145 if err != nil { 165 - return err 146 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 166 147 } 167 148 168 149 gr, err := git.Open(repoPath, record.Source.Branch) 169 150 if err != nil { 170 - return err 151 + return fmt.Errorf("failed to open git repository: %w", err) 171 152 } 172 153 173 154 workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 174 155 if err != nil { 175 - return err 156 + return fmt.Errorf("failed to open workflow directory: %w", err) 176 157 } 177 158 178 159 var pipeline workflow.RawPipeline ··· 215 196 cp := compiler.Compile(compiler.Parse(pipeline)) 216 197 eventJson, err := json.Marshal(cp) 217 198 if err != nil { 218 - return err 199 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 219 200 } 220 201 221 202 // do not run empty pipelines ··· 274 255 didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 275 256 276 257 // check perms for this user 277 - if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil { 278 - return fmt.Errorf("insufficient permissions: %w", err) 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) 279 264 } 280 265 281 266 if err := h.db.AddDid(subjectId.DID.String()); err != nil { ··· 317 302 return fmt.Errorf("error reading response body: %w", err) 318 303 } 319 304 320 - for _, key := range strings.Split(string(plaintext), "\n") { 305 + for key := range strings.SplitSeq(string(plaintext), "\n") { 321 306 if key == "" { 322 307 continue 323 308 }
-2
knotserver/internal.go
··· 47 47 } 48 48 49 49 w.WriteHeader(http.StatusNoContent) 50 - return 51 50 } 52 51 53 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 63 62 data = append(data, j) 64 63 } 65 64 writeJSON(w, data) 66 - return 67 65 } 68 66 69 67 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 - }
+138 -1292
knotserver/routes.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "compress/gzip" 5 4 "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 5 "fmt" 12 - "log" 6 + "log/slog" 13 7 "net/http" 14 - "net/url" 15 - "os" 16 - "path/filepath" 17 - "strconv" 18 - "strings" 19 - "sync" 20 - "time" 8 + "runtime/debug" 21 9 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 10 "github.com/go-chi/chi/v5" 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" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 29 14 "tangled.sh/tangled.sh/core/knotserver/db" 30 - "tangled.sh/tangled.sh/core/knotserver/git" 31 - "tangled.sh/tangled.sh/core/patchutil" 15 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 + tlog "tangled.sh/tangled.sh/core/log" 17 + "tangled.sh/tangled.sh/core/notifier" 32 18 "tangled.sh/tangled.sh/core/rbac" 33 - "tangled.sh/tangled.sh/core/types" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 34 20 ) 35 21 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")) 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 38 30 } 39 31 40 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 - w.Header().Set("Content-Type", "application/json") 42 - 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 - } 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() 51 34 52 - jsonData, err := json.Marshal(capabilities) 53 - if err != nil { 54 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 - return 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(), 56 43 } 57 44 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) 45 + err := e.AddKnot(rbac.ThisServer) 68 46 if err != nil { 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) 47 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 189 48 } 190 49 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, 50 + // configure owner 51 + if err = h.configureOwner(); err != nil { 52 + return nil, err 222 53 } 54 + h.l.Info("owner set", "did", h.c.Server.Owner) 55 + h.jc.AddDid(h.c.Server.Owner) 223 56 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) 57 + // configure known-dids in jetstream consumer 58 + dids, err := h.db.GetAllDids() 237 59 if err != nil { 238 - notFound(w) 239 - return 60 + return nil, fmt.Errorf("failed to get all dids: %w", err) 240 61 } 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 62 + for _, d := range dids { 63 + jc.AddDid(d) 247 64 } 248 65 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) 66 + err = h.jc.StartJetstream(ctx, h.processMessages) 270 67 if err != nil { 271 - notFound(w) 272 - return 68 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 273 69 } 274 70 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 - } 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) { 281 80 282 - mimeType := http.DetectContentType(contents) 81 + r.Route("/languages", func(r chi.Router) { 82 + r.Get("/", h.RepoLanguages) 83 + r.Get("/{ref}", h.RepoLanguages) 84 + }) 283 85 284 - // exception for svg 285 - if filepath.Ext(treePath) == ".svg" { 286 - mimeType = "image/svg+xml" 287 - } 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 288 91 289 - contentHash := sha256.Sum256(contents) 290 - eTag := fmt.Sprintf("\"%x\"", contentHash) 92 + r.Route("/tree/{ref}", func(r chi.Router) { 93 + r.Get("/", h.RepoIndex) 94 + r.Get("/*", h.RepoTree) 95 + }) 291 96 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) 97 + r.Route("/blob/{ref}", func(r chi.Router) { 98 + r.Get("/*", h.Blob) 99 + }) 300 100 301 - case strings.HasPrefix(mimeType, "text/plain"): 302 - w.Header().Set("Cache-Control", "public, no-cache") 101 + r.Route("/raw/{ref}", func(r chi.Router) { 102 + r.Get("/*", h.BlobRaw) 103 + }) 303 104 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 - } 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 + }) 309 116 310 - w.Header().Set("Content-Type", mimeType) 311 - w.Write(contents) 312 - } 117 + // xrpc apis 118 + r.Mount("/xrpc", h.XrpcRouter()) 313 119 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) 120 + // Socket that streams git oplogs 121 + r.Get("/events", h.Events) 318 122 319 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 123 + // All public keys on the knot. 124 + r.Get("/keys", h.Keys) 320 125 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), 350 - } 351 - 352 - h.showFile(resp, w, l) 126 + return r, nil 353 127 } 354 128 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 365 - } 366 - 367 - ref := strings.TrimSuffix(file, ".tar.gz") 368 - 369 - unescapedRef, err := url.PathUnescape(ref) 370 - if err != nil { 371 - notFound(w) 372 - return 373 - } 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 388 - } 389 - 390 - gw := gzip.NewWriter(w) 391 - defer gw.Close() 392 - 393 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 394 - err = gr.WriteTar(gw, prefix) 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("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 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) 414 - 415 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 416 - 417 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 418 - 419 - gr, err := git.Open(path, ref) 420 - if err != nil { 421 - notFound(w) 422 - return 423 - } 424 - 425 - // Get page parameters 426 - page := 1 427 - pageSize := 30 428 - 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 - } 434 - 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 - } 440 - 441 - // convert to offset/limit 442 - offset := (page - 1) * pageSize 443 - limit := pageSize 444 - 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 - } 451 - 452 - total := len(commits) 453 - 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 - } 463 - 464 - writeJSON(w, resp) 465 - return 466 - } 467 - 468 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 469 - ref := chi.URLParam(r, "ref") 470 - ref, _ = url.PathUnescape(ref) 471 - 472 - l := h.l.With("handler", "Diff", "ref", ref) 473 - 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, 491 - } 492 - 493 - writeJSON(w, resp) 494 - return 495 - } 496 - 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 - } 512 - 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, 521 - } 522 - 523 - tr.Reference = types.Reference{ 524 - Name: tag.Name, 525 - Hash: tag.Hash.String(), 526 - } 527 - 528 - if tag.Message != "" { 529 - tr.Message = tag.Message 530 - } 531 - 532 - rtags = append(rtags, &tr) 533 - } 534 - 535 - resp := types.RepoTagsResponse{ 536 - Tags: rtags, 537 - } 538 - 539 - writeJSON(w, resp) 540 - return 541 - } 542 - 543 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 544 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 545 - 546 - gr, err := git.PlainOpen(path) 547 - if err != nil { 548 - notFound(w) 549 - return 550 - } 551 - 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) 570 - if err != nil { 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 596 - } 597 - 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 - } 612 - 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 623 - } 624 - 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)) 641 - if err != nil { 642 - writeError(w, "invalid pubkey", http.StatusBadRequest) 643 - } 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 668 - } 669 - 670 - if data.DefaultBranch == "" { 671 - data.DefaultBranch = h.c.Repo.MainBranch 672 - } 129 + func (h *Handle) XrpcRouter() http.Handler { 130 + logger := tlog.New("knots") 673 131 674 - did := data.Did 675 - name := data.Name 676 - defaultBranch := data.DefaultBranch 132 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 677 133 678 - if err := validateRepoName(name); err != nil { 679 - l.Error("creating repo", "error", err.Error()) 680 - writeError(w, err.Error(), http.StatusBadRequest) 681 - return 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, 682 143 } 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) 144 + return xrpc.Router() 715 145 } 716 146 717 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 718 - l := h.l.With("handler", "RepoForkAheadBehind") 147 + // version is set during build time. 148 + var version string 719 149 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) 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) 780 155 return 781 156 } 782 157 783 - if isAncestor { 784 - status = types.FastForwardable 785 - } else { 786 - status = types.Conflict 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 + } 787 164 } 788 - } 789 165 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()) 166 + if modVer == "" { 167 + version = "unknown" 1017 168 } 1018 - return 1019 169 } 1020 170 1021 - w.WriteHeader(http.StatusOK) 171 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 + fmt.Fprintf(w, "knotserver/%s", version) 1022 173 } 1023 174 1024 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1025 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 175 + func (h *Handle) configureOwner() error { 176 + cfgOwner := h.c.Server.Owner 1026 177 1027 - var data struct { 1028 - Patch string `json:"patch"` 1029 - Branch string `json:"branch"` 1030 - } 178 + rbacDomain := "thisserver" 1031 179 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) 180 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 1041 181 if err != nil { 1042 - notFound(w) 1043 - return 182 + return err 1044 183 } 1045 184 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 - } 185 + switch len(existing) { 186 + case 0: 187 + // no owner configured, continue 188 + case 1: 189 + // find existing owner 190 + existingOwner := existing[0] 1054 191 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 - } 192 + // no ownership change, this is okay 193 + if existingOwner == h.c.Server.Owner { 194 + break 1063 195 } 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 196 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") 197 + // remove existing owner 198 + err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 199 + if err != nil { 200 + return nil 1351 201 } 202 + default: 203 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 1352 204 } 1353 205 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 206 + return h.e.AddKnotOwner(rbacDomain, cfgOwner) 1361 207 }
+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 - }
+12 -10
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" 15 17 ) 16 18 17 19 const ActorDid string = "ActorDid" 18 20 19 21 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 20 22 l := x.Logger 21 - fail := func(e XrpcError) { 23 + fail := func(e xrpcerr.XrpcError) { 22 24 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 25 writeError(w, e, http.StatusBadRequest) 24 26 } 25 27 26 28 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 29 if !ok { 28 - fail(MissingActorDidError) 30 + fail(xrpcerr.MissingActorDidError) 29 31 return 30 32 } 31 33 32 34 var data tangled.RepoSetDefaultBranch_Input 33 35 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(GenericError(err)) 36 + fail(xrpcerr.GenericError(err)) 35 37 return 36 38 } 37 39 38 40 // unfortunately we have to resolve repo-at here 39 41 repoAt, err := syntax.ParseATURI(data.Repo) 40 42 if err != nil { 41 - fail(InvalidRepoError(data.Repo)) 43 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 44 return 43 45 } 44 46 45 47 // resolve this aturi to extract the repo record 46 48 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 49 if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 51 return 50 52 } 51 53 52 54 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 55 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 56 if err != nil { 55 - fail(GenericError(err)) 57 + fail(xrpcerr.GenericError(err)) 56 58 return 57 59 } 58 60 59 61 repo := resp.Value.Val.(*tangled.Repo) 60 62 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 61 63 if err != nil { 62 - fail(GenericError(err)) 64 + fail(xrpcerr.GenericError(err)) 63 65 return 64 66 } 65 67 66 68 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 69 l.Error("insufficent permissions", "did", actorDid.String()) 68 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 70 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 71 return 70 72 } 71 73 72 74 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 75 gr, err := git.PlainOpen(path) 74 76 if err != nil { 75 - fail(InvalidRepoError(data.Repo)) 77 + fail(xrpcerr.GenericError(err)) 76 78 return 77 79 } 78 80 79 81 err = gr.SetDefaultBranch(data.DefaultBranch) 80 82 if err != nil { 81 83 l.Error("setting default branch", "error", err.Error()) 82 - writeError(w, GitError(err), http.StatusInternalServerError) 84 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 85 return 84 86 } 85 87
+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 + }
+8 -1
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["issue", "body", "createdAt"], 12 + "required": [ 13 + "issue", 14 + "body", 15 + "createdAt" 16 + ], 13 17 "properties": { 14 18 "issue": { 15 19 "type": "string", ··· 18 22 "repo": { 19 23 "type": "string", 20 24 "format": "at-uri" 25 + }, 26 + "commentId": { 27 + "type": "integer" 21 28 }, 22 29 "owner": { 23 30 "type": "string",
+10 -1
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["repo", "owner", "title", "createdAt"], 12 + "required": [ 13 + "repo", 14 + "issueId", 15 + "owner", 16 + "title", 17 + "createdAt" 18 + ], 13 19 "properties": { 14 20 "repo": { 15 21 "type": "string", 16 22 "format": "at-uri" 23 + }, 24 + "issueId": { 25 + "type": "integer" 17 26 }, 18 27 "owner": { 19 28 "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 - secretFile = mkOption { 97 - type = lib.types.path; 98 - example = "KNOT_SERVER_SECRET=<hash>"; 99 - description = "File containing secret key provided by appview (required)"; 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (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}" 202 203 ]; 203 - EnvironmentFile = cfg.server.secretFile; 204 204 ExecStart = "${cfg.package}/bin/knot server"; 205 205 Restart = "always"; 206 206 };
+2 -1
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"; 73 74 services.getty.autologinUser = "root"; 74 75 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 75 76 services.tangled-knot = { 76 77 enable = true; 77 78 motd = "Welcome to the development knot!\n"; 78 79 server = { 79 - secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 80 81 hostname = "localhost:6000"; 81 82 listenAddr = "0.0.0.0:6000"; 82 83 };
+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 + 103 108 func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) { 104 109 keepFunc := isNotSpindle 105 110 stripFunc := unSpindle ··· 270 275 271 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 272 277 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") 273 286 } 274 287 275 288 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 + }, 204 207 // TODO(winter): investigate whether environment variables passed here 205 208 // get propagated to ContainerExec processes 206 209 }, &container.HostConfig{
+11 -7
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" 28 29 ) 29 30 30 31 //go:embed motd ··· 213 214 func (s *Spindle) XrpcRouter() http.Handler { 214 215 logger := s.l.With("route", "xrpc") 215 216 217 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 + 216 219 x := xrpc.Xrpc{ 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, 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, 224 228 } 225 229 226 230 return x.Router()
+11 -10
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" 16 17 ) 17 18 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 19 20 l := x.Logger 20 - fail := func(e XrpcError) { 21 + fail := func(e xrpcerr.XrpcError) { 21 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 23 writeError(w, e, http.StatusBadRequest) 23 24 } 24 25 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 27 if !ok { 27 - fail(MissingActorDidError) 28 + fail(xrpcerr.MissingActorDidError) 28 29 return 29 30 } 30 31 31 32 var data tangled.RepoAddSecret_Input 32 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(GenericError(err)) 34 + fail(xrpcerr.GenericError(err)) 34 35 return 35 36 } 36 37 37 38 if err := secrets.ValidateKey(data.Key); err != nil { 38 - fail(GenericError(err)) 39 + fail(xrpcerr.GenericError(err)) 39 40 return 40 41 } 41 42 42 43 // unfortunately we have to resolve repo-at here 43 44 repoAt, err := syntax.ParseATURI(data.Repo) 44 45 if err != nil { 45 - fail(InvalidRepoError(data.Repo)) 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 46 47 return 47 48 } 48 49 49 50 // resolve this aturi to extract the repo record 50 51 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 52 if err != nil || ident.Handle.IsInvalidHandle() { 52 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 54 return 54 55 } 55 56 56 57 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 58 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 59 if err != nil { 59 - fail(GenericError(err)) 60 + fail(xrpcerr.GenericError(err)) 60 61 return 61 62 } 62 63 63 64 repo := resp.Value.Val.(*tangled.Repo) 64 65 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 66 if err != nil { 66 - fail(GenericError(err)) 67 + fail(xrpcerr.GenericError(err)) 67 68 return 68 69 } 69 70 70 71 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 71 72 l.Error("insufficent permissions", "did", actorDid.String()) 72 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 73 74 return 74 75 } 75 76 ··· 83 84 err = x.Vault.AddSecret(r.Context(), secret) 84 85 if err != nil { 85 86 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 86 - writeError(w, GenericError(err), http.StatusInternalServerError) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 87 88 return 88 89 } 89 90
+10 -9
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" 16 17 ) 17 18 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 19 20 l := x.Logger 20 - fail := func(e XrpcError) { 21 + fail := func(e xrpcerr.XrpcError) { 21 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 23 writeError(w, e, http.StatusBadRequest) 23 24 } 24 25 25 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 27 if !ok { 27 - fail(MissingActorDidError) 28 + fail(xrpcerr.MissingActorDidError) 28 29 return 29 30 } 30 31 31 32 repoParam := r.URL.Query().Get("repo") 32 33 if repoParam == "" { 33 - fail(GenericError(fmt.Errorf("empty params"))) 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 34 35 return 35 36 } 36 37 37 38 // unfortunately we have to resolve repo-at here 38 39 repoAt, err := syntax.ParseATURI(repoParam) 39 40 if err != nil { 40 - fail(InvalidRepoError(repoParam)) 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 41 42 return 42 43 } 43 44 44 45 // resolve this aturi to extract the repo record 45 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 47 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 49 return 49 50 } 50 51 51 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 53 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 54 if err != nil { 54 - fail(GenericError(err)) 55 + fail(xrpcerr.GenericError(err)) 55 56 return 56 57 } 57 58 58 59 repo := resp.Value.Val.(*tangled.Repo) 59 60 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 61 if err != nil { 61 - fail(GenericError(err)) 62 + fail(xrpcerr.GenericError(err)) 62 63 return 63 64 } 64 65 65 66 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 67 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 69 return 69 70 } 70 71 71 72 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 72 73 if err != nil { 73 74 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 74 - writeError(w, GenericError(err), http.StatusInternalServerError) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 75 76 return 76 77 } 77 78
+10 -9
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" 15 16 ) 16 17 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 18 19 l := x.Logger 19 - fail := func(e XrpcError) { 20 + fail := func(e xrpcerr.XrpcError) { 20 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 21 22 writeError(w, e, http.StatusBadRequest) 22 23 } 23 24 24 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 26 if !ok { 26 - fail(MissingActorDidError) 27 + fail(xrpcerr.MissingActorDidError) 27 28 return 28 29 } 29 30 30 31 var data tangled.RepoRemoveSecret_Input 31 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 - fail(GenericError(err)) 33 + fail(xrpcerr.GenericError(err)) 33 34 return 34 35 } 35 36 36 37 // unfortunately we have to resolve repo-at here 37 38 repoAt, err := syntax.ParseATURI(data.Repo) 38 39 if err != nil { 39 - fail(InvalidRepoError(data.Repo)) 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 40 41 return 41 42 } 42 43 43 44 // resolve this aturi to extract the repo record 44 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 45 46 if err != nil || ident.Handle.IsInvalidHandle() { 46 - fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 48 return 48 49 } 49 50 50 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 51 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 52 53 if err != nil { 53 - fail(GenericError(err)) 54 + fail(xrpcerr.GenericError(err)) 54 55 return 55 56 } 56 57 57 58 repo := resp.Value.Val.(*tangled.Repo) 58 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 60 if err != nil { 60 - fail(GenericError(err)) 61 + fail(xrpcerr.GenericError(err)) 61 62 return 62 63 } 63 64 64 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 66 l.Error("insufficent permissions", "did", actorDid.String()) 66 - writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 68 return 68 69 } 69 70 ··· 74 75 err = x.Vault.RemoveSecret(r.Context(), secret) 75 76 if err != nil { 76 77 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 77 - writeError(w, GenericError(err), http.StatusInternalServerError) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 78 79 return 79 80 } 80 81
+14 -109
spindle/xrpc/xrpc.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 - "context" 5 4 _ "embed" 6 5 "encoding/json" 7 - "fmt" 8 6 "log/slog" 9 7 "net/http" 10 - "strings" 11 8 12 - "github.com/bluesky-social/indigo/atproto/auth" 13 9 "github.com/go-chi/chi/v5" 14 10 15 11 "tangled.sh/tangled.sh/core/api/tangled" ··· 19 15 "tangled.sh/tangled.sh/core/spindle/db" 20 16 "tangled.sh/tangled.sh/core/spindle/models" 21 17 "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 22 20 ) 23 21 24 22 const ActorDid string = "ActorDid" 25 23 26 24 type Xrpc struct { 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 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 34 33 } 35 34 36 35 func (x *Xrpc) Router() http.Handler { 37 36 r := chi.NewRouter() 38 37 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) 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) 42 41 43 42 return r 44 43 } 45 44 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 - 140 45 // this is slightly different from http_util::write_error to follow the spec: 141 46 // 142 47 // the json object returned must include an "error" and a "message" 143 - func writeError(w http.ResponseWriter, e XrpcError, status int) { 48 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 144 49 w.Header().Set("Content-Type", "application/json") 145 50 w.WriteHeader(status) 146 51 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 + }