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

Compare changes

Choose any two refs to compare.

+2 -252
api/tangled/cbor_gen.go
··· 2141 2141 2142 2142 return nil 2143 2143 } 2144 - func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 - if t == nil { 2146 - _, err := w.Write(cbg.CborNull) 2147 - return err 2148 - } 2149 - 2150 - cw := cbg.NewCborWriter(w) 2151 - 2152 - if _, err := cw.Write([]byte{162}); err != nil { 2153 - return err 2154 - } 2155 - 2156 - // t.LexiconTypeID (string) (string) 2157 - if len("$type") > 1000000 { 2158 - return xerrors.Errorf("Value in field \"$type\" was too long") 2159 - } 2160 - 2161 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 - return err 2163 - } 2164 - if _, err := cw.WriteString(string("$type")); err != nil { 2165 - return err 2166 - } 2167 - 2168 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 - return err 2170 - } 2171 - if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 - return err 2173 - } 2174 - 2175 - // t.CreatedAt (string) (string) 2176 - if len("createdAt") > 1000000 { 2177 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 - } 2179 - 2180 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 - return err 2182 - } 2183 - if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 - return err 2185 - } 2186 - 2187 - if len(t.CreatedAt) > 1000000 { 2188 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 - } 2190 - 2191 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 - return err 2193 - } 2194 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 - return err 2196 - } 2197 - return nil 2198 - } 2199 - 2200 - func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 - *t = Knot{} 2202 - 2203 - cr := cbg.NewCborReader(r) 2204 - 2205 - maj, extra, err := cr.ReadHeader() 2206 - if err != nil { 2207 - return err 2208 - } 2209 - defer func() { 2210 - if err == io.EOF { 2211 - err = io.ErrUnexpectedEOF 2212 - } 2213 - }() 2214 - 2215 - if maj != cbg.MajMap { 2216 - return fmt.Errorf("cbor input should be of type map") 2217 - } 2218 - 2219 - if extra > cbg.MaxLength { 2220 - return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 - } 2222 - 2223 - n := extra 2224 - 2225 - nameBuf := make([]byte, 9) 2226 - for i := uint64(0); i < n; i++ { 2227 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 - if err != nil { 2229 - return err 2230 - } 2231 - 2232 - if !ok { 2233 - // Field doesn't exist on this type, so ignore it 2234 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 - return err 2236 - } 2237 - continue 2238 - } 2239 - 2240 - switch string(nameBuf[:nameLen]) { 2241 - // t.LexiconTypeID (string) (string) 2242 - case "$type": 2243 - 2244 - { 2245 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 - if err != nil { 2247 - return err 2248 - } 2249 - 2250 - t.LexiconTypeID = string(sval) 2251 - } 2252 - // t.CreatedAt (string) (string) 2253 - case "createdAt": 2254 - 2255 - { 2256 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2257 - if err != nil { 2258 - return err 2259 - } 2260 - 2261 - t.CreatedAt = string(sval) 2262 - } 2263 - 2264 - default: 2265 - // Field doesn't exist on this type, so ignore it 2266 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2267 - return err 2268 - } 2269 - } 2270 - } 2271 - 2272 - return nil 2273 - } 2274 2144 func (t *KnotMember) MarshalCBOR(w io.Writer) error { 2275 2145 if t == nil { 2276 2146 _, err := w.Write(cbg.CborNull) ··· 5642 5512 } 5643 5513 5644 5514 cw := cbg.NewCborWriter(w) 5645 - fieldCount := 7 5515 + fieldCount := 6 5646 5516 5647 5517 if t.Body == nil { 5648 5518 fieldCount-- ··· 5772 5642 return err 5773 5643 } 5774 5644 5775 - // t.IssueId (int64) (int64) 5776 - if len("issueId") > 1000000 { 5777 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5778 - } 5779 - 5780 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 5781 - return err 5782 - } 5783 - if _, err := cw.WriteString(string("issueId")); err != nil { 5784 - return err 5785 - } 5786 - 5787 - if t.IssueId >= 0 { 5788 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 5789 - return err 5790 - } 5791 - } else { 5792 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 5793 - return err 5794 - } 5795 - } 5796 - 5797 5645 // t.CreatedAt (string) (string) 5798 5646 if len("createdAt") > 1000000 { 5799 5647 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5925 5773 5926 5774 t.Title = string(sval) 5927 5775 } 5928 - // t.IssueId (int64) (int64) 5929 - case "issueId": 5930 - { 5931 - maj, extra, err := cr.ReadHeader() 5932 - if err != nil { 5933 - return err 5934 - } 5935 - var extraI int64 5936 - switch maj { 5937 - case cbg.MajUnsignedInt: 5938 - extraI = int64(extra) 5939 - if extraI < 0 { 5940 - return fmt.Errorf("int64 positive overflow") 5941 - } 5942 - case cbg.MajNegativeInt: 5943 - extraI = int64(extra) 5944 - if extraI < 0 { 5945 - return fmt.Errorf("int64 negative overflow") 5946 - } 5947 - extraI = -1 - extraI 5948 - default: 5949 - return fmt.Errorf("wrong type for int64 field: %d", maj) 5950 - } 5951 - 5952 - t.IssueId = int64(extraI) 5953 - } 5954 5776 // t.CreatedAt (string) (string) 5955 5777 case "createdAt": 5956 5778 ··· 5980 5802 } 5981 5803 5982 5804 cw := cbg.NewCborWriter(w) 5983 - fieldCount := 7 5984 - 5985 - if t.CommentId == nil { 5986 - fieldCount-- 5987 - } 5805 + fieldCount := 6 5988 5806 5989 5807 if t.Owner == nil { 5990 5808 fieldCount-- ··· 6127 5945 } 6128 5946 } 6129 5947 6130 - // t.CommentId (int64) (int64) 6131 - if t.CommentId != nil { 6132 - 6133 - if len("commentId") > 1000000 { 6134 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6135 - } 6136 - 6137 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6138 - return err 6139 - } 6140 - if _, err := cw.WriteString(string("commentId")); err != nil { 6141 - return err 6142 - } 6143 - 6144 - if t.CommentId == nil { 6145 - if _, err := cw.Write(cbg.CborNull); err != nil { 6146 - return err 6147 - } 6148 - } else { 6149 - if *t.CommentId >= 0 { 6150 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6151 - return err 6152 - } 6153 - } else { 6154 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6155 - return err 6156 - } 6157 - } 6158 - } 6159 - 6160 - } 6161 - 6162 5948 // t.CreatedAt (string) (string) 6163 5949 if len("createdAt") > 1000000 { 6164 5950 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6298 6084 } 6299 6085 6300 6086 t.Owner = (*string)(&sval) 6301 - } 6302 - } 6303 - // t.CommentId (int64) (int64) 6304 - case "commentId": 6305 - { 6306 - 6307 - b, err := cr.ReadByte() 6308 - if err != nil { 6309 - return err 6310 - } 6311 - if b != cbg.CborNull[0] { 6312 - if err := cr.UnreadByte(); err != nil { 6313 - return err 6314 - } 6315 - maj, extra, err := cr.ReadHeader() 6316 - if err != nil { 6317 - return err 6318 - } 6319 - var extraI int64 6320 - switch maj { 6321 - case cbg.MajUnsignedInt: 6322 - extraI = int64(extra) 6323 - if extraI < 0 { 6324 - return fmt.Errorf("int64 positive overflow") 6325 - } 6326 - case cbg.MajNegativeInt: 6327 - extraI = int64(extra) 6328 - if extraI < 0 { 6329 - return fmt.Errorf("int64 negative overflow") 6330 - } 6331 - extraI = -1 - extraI 6332 - default: 6333 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6334 - } 6335 - 6336 - t.CommentId = (*int64)(&extraI) 6337 6087 } 6338 6088 } 6339 6089 // t.CreatedAt (string) (string)
-1
api/tangled/issuecomment.go
··· 19 19 type RepoIssueComment struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 23 Issue string `json:"issue" cborgen:"issue"` 25 24 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-34
api/tangled/repocreate.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.create 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoCreateNSID = "sh.tangled.repo.create" 15 - ) 16 - 17 - // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 - type RepoCreate_Input struct { 19 - // defaultBranch: Default branch to push to 20 - DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 - // rkey: Rkey of the repository record 22 - Rkey string `json:"rkey" cborgen:"rkey"` 23 - // source: A source URL to clone from, populate this when forking or importing a repository. 24 - Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 - } 26 - 27 - // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 - func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 - return err 31 - } 32 - 33 - return nil 34 - }
-32
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 - } 24 - 25 - // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 26 - func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 27 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 28 - return err 29 - } 30 - 31 - return nil 32 - }
-45
api/tangled/repoforkStatus.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.forkStatus 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 - ) 16 - 17 - // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 - type RepoForkStatus_Input struct { 19 - // branch: Branch to check status for 20 - Branch string `json:"branch" cborgen:"branch"` 21 - // did: DID of the fork owner 22 - Did string `json:"did" cborgen:"did"` 23 - // hiddenRef: Hidden ref to use for comparison 24 - HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 - // name: Name of the forked repository 26 - Name string `json:"name" cborgen:"name"` 27 - // source: Source repository URL 28 - Source string `json:"source" cborgen:"source"` 29 - } 30 - 31 - // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 - type RepoForkStatus_Output struct { 33 - // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 - Status int64 `json:"status" cborgen:"status"` 35 - } 36 - 37 - // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 - func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 - var out RepoForkStatus_Output 40 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 - return nil, err 42 - } 43 - 44 - return &out, nil 45 - }
-36
api/tangled/repoforkSync.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.forkSync 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 - ) 16 - 17 - // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 - type RepoForkSync_Input struct { 19 - // branch: Branch to sync 20 - Branch string `json:"branch" cborgen:"branch"` 21 - // did: DID of the fork owner 22 - Did string `json:"did" cborgen:"did"` 23 - // name: Name of the forked repository 24 - Name string `json:"name" cborgen:"name"` 25 - // source: AT-URI of the source repository 26 - Source string `json:"source" cborgen:"source"` 27 - } 28 - 29 - // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 - func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 - return err 33 - } 34 - 35 - return nil 36 - }
-45
api/tangled/repohiddenRef.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.hiddenRef 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 - ) 16 - 17 - // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 - type RepoHiddenRef_Input struct { 19 - // forkRef: Fork reference name 20 - ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 - // remoteRef: Remote reference name 22 - RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 - // repo: AT-URI of the repository 24 - Repo string `json:"repo" cborgen:"repo"` 25 - } 26 - 27 - // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 - type RepoHiddenRef_Output struct { 29 - // error: Error message if creation failed 30 - Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 - // ref: The created hidden ref name 32 - Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 - // success: Whether the hidden ref was created successfully 34 - Success bool `json:"success" cborgen:"success"` 35 - } 36 - 37 - // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 - func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 - var out RepoHiddenRef_Output 40 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 - return nil, err 42 - } 43 - 44 - return &out, nil 45 - }
-1
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 23 Owner string `json:"owner" cborgen:"owner"` 25 24 Repo string `json:"repo" cborgen:"repo"` 26 25 Title string `json:"title" cborgen:"title"`
-44
api/tangled/repomerge.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.merge 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoMergeNSID = "sh.tangled.repo.merge" 15 - ) 16 - 17 - // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 - type RepoMerge_Input struct { 19 - // authorEmail: Author email for the merge commit 20 - AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 - // authorName: Author name for the merge commit 22 - AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 - // branch: Target branch to merge into 24 - Branch string `json:"branch" cborgen:"branch"` 25 - // commitBody: Additional commit message body 26 - CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 - // commitMessage: Merge commit message 28 - CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 - // did: DID of the repository owner 30 - Did string `json:"did" cborgen:"did"` 31 - // name: Name of the repository 32 - Name string `json:"name" cborgen:"name"` 33 - // patch: Patch content to merge 34 - Patch string `json:"patch" cborgen:"patch"` 35 - } 36 - 37 - // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 - func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 - return err 41 - } 42 - 43 - return nil 44 - }
-57
api/tangled/repomergeCheck.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.repo.mergeCheck 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 - ) 16 - 17 - // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 - type RepoMergeCheck_ConflictInfo struct { 19 - // filename: Name of the conflicted file 20 - Filename string `json:"filename" cborgen:"filename"` 21 - // reason: Reason for the conflict 22 - Reason string `json:"reason" cborgen:"reason"` 23 - } 24 - 25 - // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 - type RepoMergeCheck_Input struct { 27 - // branch: Target branch to merge into 28 - Branch string `json:"branch" cborgen:"branch"` 29 - // did: DID of the repository owner 30 - Did string `json:"did" cborgen:"did"` 31 - // name: Name of the repository 32 - Name string `json:"name" cborgen:"name"` 33 - // patch: Patch or pull request to check for merge conflicts 34 - Patch string `json:"patch" cborgen:"patch"` 35 - } 36 - 37 - // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 - type RepoMergeCheck_Output struct { 39 - // conflicts: List of files with merge conflicts 40 - Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 - // error: Error message if check failed 42 - Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 - // is_conflicted: Whether the merge has conflicts 44 - Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 - // message: Additional message about the merge check 46 - Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 - } 48 - 49 - // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 - func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 - var out RepoMergeCheck_Output 52 - if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 - return nil, err 54 - } 55 - 56 - return &out, nil 57 - }
-22
api/tangled/tangledknot.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.knot 6 - 7 - import ( 8 - "github.com/bluesky-social/indigo/lex/util" 9 - ) 10 - 11 - const ( 12 - KnotNSID = "sh.tangled.knot" 13 - ) 14 - 15 - func init() { 16 - util.RegisterType("sh.tangled.knot", &Knot{}) 17 - } // 18 - // RECORDTYPE: Knot 19 - type Knot struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 - }
+41 -144
appview/db/follow.go
··· 1 1 package db 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 - "strings" 7 5 "time" 8 6 ) 9 7 ··· 55 53 return err 56 54 } 57 55 58 - type FollowStats struct { 59 - Followers int 60 - Following int 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 56 + func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 64 57 followers, following := 0, 0 65 58 err := e.QueryRow( 66 - `SELECT 59 + `SELECT 67 60 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 68 61 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 62 FROM follows;`, did, did).Scan(&followers, &following) 70 63 if err != nil { 71 - return FollowStats{}, err 64 + return 0, 0, err 72 65 } 73 - return FollowStats{ 74 - Followers: followers, 75 - Following: following, 76 - }, nil 66 + return followers, following, nil 77 67 } 78 68 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 - if len(dids) == 0 { 81 - return nil, nil 82 - } 69 + type FollowStatus int 83 70 84 - placeholders := make([]string, len(dids)) 85 - for i := range placeholders { 86 - placeholders[i] = "?" 87 - } 88 - placeholderStr := strings.Join(placeholders, ",") 71 + const ( 72 + IsNotFollowing FollowStatus = iota 73 + IsFollowing 74 + IsSelf 75 + ) 89 76 90 - args := make([]any, len(dids)*2) 91 - for i, did := range dids { 92 - args[i] = did 93 - args[i+len(dids)] = did 77 + func (s FollowStatus) String() string { 78 + switch s { 79 + case IsNotFollowing: 80 + return "IsNotFollowing" 81 + case IsFollowing: 82 + return "IsFollowing" 83 + case IsSelf: 84 + return "IsSelf" 85 + default: 86 + return "IsNotFollowing" 94 87 } 95 - 96 - query := fmt.Sprintf(` 97 - select 98 - coalesce(f.did, g.did) as did, 99 - coalesce(f.followers, 0) as followers, 100 - coalesce(g.following, 0) as following 101 - from ( 102 - select subject_did as did, count(*) as followers 103 - from follows 104 - where subject_did in (%s) 105 - group by subject_did 106 - ) f 107 - full outer join ( 108 - select user_did as did, count(*) as following 109 - from follows 110 - where user_did in (%s) 111 - group by user_did 112 - ) g on f.did = g.did`, 113 - placeholderStr, placeholderStr) 114 - 115 - result := make(map[string]FollowStats) 116 - 117 - rows, err := e.Query(query, args...) 118 - if err != nil { 119 - return nil, err 120 - } 121 - defer rows.Close() 88 + } 122 89 123 - for rows.Next() { 124 - var did string 125 - var followers, following int 126 - if err := rows.Scan(&did, &followers, &following); err != nil { 127 - return nil, err 128 - } 129 - result[did] = FollowStats{ 130 - Followers: followers, 131 - Following: following, 132 - } 90 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 + if userDid == subjectDid { 92 + return IsSelf 93 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 + return IsNotFollowing 95 + } else { 96 + return IsFollowing 133 97 } 134 - 135 - for _, did := range dids { 136 - if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 138 - Followers: 0, 139 - Following: 0, 140 - } 141 - } 142 - } 143 - 144 - return result, nil 145 98 } 146 99 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 100 + func GetAllFollows(e Execer, limit int) ([]Follow, error) { 148 101 var follows []Follow 149 102 150 - var conditions []string 151 - var args []any 152 - for _, filter := range filters { 153 - conditions = append(conditions, filter.Condition()) 154 - args = append(args, filter.Arg()...) 155 - } 156 - 157 - whereClause := "" 158 - if conditions != nil { 159 - whereClause = " where " + strings.Join(conditions, " and ") 160 - } 161 - limitClause := "" 162 - if limit > 0 { 163 - limitClause = " limit ?" 164 - args = append(args, limit) 165 - } 166 - 167 - query := fmt.Sprintf( 168 - `select user_did, subject_did, followed_at, rkey 103 + rows, err := e.Query(` 104 + select user_did, subject_did, followed_at, rkey 169 105 from follows 170 - %s 171 106 order by followed_at desc 172 - %s 173 - `, whereClause, limitClause) 174 - 175 - rows, err := e.Query(query, args...) 107 + limit ?`, limit, 108 + ) 176 109 if err != nil { 177 110 return nil, err 178 111 } 112 + defer rows.Close() 113 + 179 114 for rows.Next() { 180 115 var follow Follow 181 116 var followedAt string 182 - err := rows.Scan( 183 - &follow.UserDid, 184 - &follow.SubjectDid, 185 - &followedAt, 186 - &follow.Rkey, 187 - ) 188 - if err != nil { 117 + if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 189 118 return nil, err 190 119 } 120 + 191 121 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 192 122 if err != nil { 193 123 log.Println("unable to determine followed at time") ··· 195 125 } else { 196 126 follow.FollowedAt = followedAtTime 197 127 } 128 + 198 129 follows = append(follows, follow) 199 130 } 200 - return follows, nil 201 - } 202 - 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 204 - return GetFollows(e, 0, FilterEq("subject_did", did)) 205 - } 206 131 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 208 - return GetFollows(e, 0, FilterEq("user_did", did)) 209 - } 210 - 211 - type FollowStatus int 212 - 213 - const ( 214 - IsNotFollowing FollowStatus = iota 215 - IsFollowing 216 - IsSelf 217 - ) 218 - 219 - func (s FollowStatus) String() string { 220 - switch s { 221 - case IsNotFollowing: 222 - return "IsNotFollowing" 223 - case IsFollowing: 224 - return "IsFollowing" 225 - case IsSelf: 226 - return "IsSelf" 227 - default: 228 - return "IsNotFollowing" 132 + if err := rows.Err(); err != nil { 133 + return nil, err 229 134 } 230 - } 231 135 232 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 - if userDid == subjectDid { 234 - return IsSelf 235 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 - return IsNotFollowing 237 - } else { 238 - return IsFollowing 239 - } 136 + return follows, nil 240 137 }
+105
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + mathrand "math/rand/v2" 6 7 "strings" 7 8 "time" 8 9 ··· 47 48 48 49 func (i *Issue) AtUri() syntax.ATURI { 49 50 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 + } 52 + 53 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 54 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 55 + if err != nil { 56 + created = time.Now() 57 + } 58 + 59 + body := "" 60 + if record.Body != nil { 61 + body = *record.Body 62 + } 63 + 64 + return Issue{ 65 + RepoAt: syntax.ATURI(record.Repo), 66 + OwnerDid: record.Owner, 67 + Rkey: rkey, 68 + Created: created, 69 + Title: record.Title, 70 + Body: body, 71 + Open: true, // new issues are open by default 72 + } 73 + } 74 + 75 + func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 + ownerDid := issueUri.Authority().String() 77 + issueRkey := issueUri.RecordKey().String() 78 + 79 + var repoAt string 80 + var issueId int 81 + 82 + query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 + err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 + if err != nil { 85 + return "", 0, err 86 + } 87 + 88 + return syntax.ATURI(repoAt), issueId, nil 89 + } 90 + 91 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + ownerDid := did 98 + if record.Owner != nil { 99 + ownerDid = *record.Owner 100 + } 101 + 102 + issueUri, err := syntax.ParseATURI(record.Issue) 103 + if err != nil { 104 + return Comment{}, err 105 + } 106 + 107 + repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 + if err != nil { 109 + return Comment{}, err 110 + } 111 + 112 + comment := Comment{ 113 + OwnerDid: ownerDid, 114 + RepoAt: repoAt, 115 + Rkey: rkey, 116 + Body: record.Body, 117 + Issue: issueId, 118 + CommentId: mathrand.IntN(1000000), 119 + Created: &created, 120 + } 121 + 122 + return comment, nil 50 123 } 51 124 52 125 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 550 623 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 551 624 where repo_at = ? and issue_id = ? and comment_id = ? 552 625 `, repoAt, issueId, commentId) 626 + return err 627 + } 628 + 629 + func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 + _, err := e.Exec( 631 + ` 632 + update comments 633 + set body = ?, 634 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 + where owner_did = ? and rkey = ? 636 + `, newBody, ownerDid, rkey) 637 + return err 638 + } 639 + 640 + func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 + _, err := e.Exec( 642 + ` 643 + update comments 644 + set body = "", 645 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 + where owner_did = ? and rkey = ? 647 + `, ownerDid, rkey) 648 + return err 649 + } 650 + 651 + func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 + _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 + return err 654 + } 655 + 656 + func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 + _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 553 658 return err 554 659 } 555 660
+7 -2
appview/db/profile.go
··· 348 348 return tx.Commit() 349 349 } 350 350 351 - func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 351 + func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 352 var conditions []string 353 353 var args []any 354 354 for _, filter := range filters { ··· 448 448 idxs[did] = idx + 1 449 449 } 450 450 451 - return profileMap, nil 451 + var profiles []Profile 452 + for _, p := range profileMap { 453 + profiles = append(profiles, *p) 454 + } 455 + 456 + return profiles, nil 452 457 } 453 458 454 459 func GetProfile(e Execer, did string) (*Profile, error) {
+22 -6
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 + type FollowStats struct { 24 + Followers int 25 + Following int 26 + } 27 + 23 28 const Limit = 50 24 29 25 30 // TODO: this gathers heterogenous events from different sources and aggregates ··· 132 137 } 133 138 134 139 func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 140 + follows, err := GetAllFollows(e, Limit) 136 141 if err != nil { 137 142 return nil, err 138 143 } ··· 146 151 return nil, nil 147 152 } 148 153 154 + profileMap := make(map[string]Profile) 149 155 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 150 156 if err != nil { 151 157 return nil, err 158 + } 159 + for _, p := range profiles { 160 + profileMap[p.Did] = p 152 161 } 153 162 154 - followStatMap, err := GetFollowerFollowingCounts(e, subjects) 155 - if err != nil { 156 - return nil, err 163 + followStatMap := make(map[string]FollowStats) 164 + for _, s := range subjects { 165 + followers, following, err := GetFollowerFollowingCount(e, s) 166 + if err != nil { 167 + return nil, err 168 + } 169 + followStatMap[s] = FollowStats{ 170 + Followers: followers, 171 + Following: following, 172 + } 157 173 } 158 174 159 175 var events []TimelineEvent 160 176 for _, f := range follows { 161 - profile, _ := profiles[f.SubjectDid] 177 + profile, _ := profileMap[f.SubjectDid] 162 178 followStatMap, _ := followStatMap[f.SubjectDid] 163 179 164 180 events = append(events, TimelineEvent{ 165 181 Follow: &f, 166 - Profile: profile, 182 + Profile: &profile, 167 183 FollowStats: &followStatMap, 168 184 EventAt: f.FollowedAt, 169 185 })
+179 -6
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 15 "tangled.sh/tangled.sh/core/api/tangled" 15 16 "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 17 19 "tangled.sh/tangled.sh/core/appview/spindleverify" 18 20 "tangled.sh/tangled.sh/core/idresolver" 19 21 "tangled.sh/tangled.sh/core/rbac" ··· 61 63 case tangled.ActorProfileNSID: 62 64 err = i.ingestProfile(e) 63 65 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 66 + err = i.ingestSpindleMember(ctx, e) 65 67 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 68 + err = i.ingestSpindle(ctx, e) 67 69 case tangled.StringNSID: 68 70 err = i.ingestString(e) 71 + case tangled.RepoIssueNSID: 72 + err = i.ingestIssue(ctx, e) 73 + case tangled.RepoIssueCommentNSID: 74 + err = i.ingestIssueComment(e) 69 75 } 70 76 l = i.Logger.With("nsid", e.Commit.Collection) 71 77 } ··· 336 342 return nil 337 343 } 338 344 339 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 345 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 340 346 did := e.Did 341 347 var err error 342 348 ··· 359 365 return fmt.Errorf("failed to enforce permissions: %w", err) 360 366 } 361 367 362 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 368 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 363 369 if err != nil { 364 370 return err 365 371 } ··· 442 448 return nil 443 449 } 444 450 445 - func (i *Ingester) ingestSpindle(e *models.Event) error { 451 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 446 452 did := e.Did 447 453 var err error 448 454 ··· 475 481 return err 476 482 } 477 483 478 - err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 484 + err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 479 485 if err != nil { 480 486 l.Error("failed to add spindle to db", "err", err, "instance", instance) 481 487 return err ··· 609 615 610 616 return nil 611 617 } 618 + 619 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 620 + did := e.Did 621 + rkey := e.Commit.RKey 622 + 623 + var err error 624 + 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 + } 632 + 633 + switch e.Commit.Operation { 634 + case models.CommitOperationCreate: 635 + raw := json.RawMessage(e.Commit.Record) 636 + record := tangled.RepoIssue{} 637 + err = json.Unmarshal(raw, &record) 638 + if err != nil { 639 + l.Error("invalid record", "err", err) 640 + return err 641 + } 642 + 643 + issue := db.IssueFromRecord(did, rkey, record) 644 + 645 + sanitizer := markup.NewSanitizer() 646 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 647 + return fmt.Errorf("title is empty after HTML sanitization") 648 + } 649 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 650 + return fmt.Errorf("body is empty after HTML sanitization") 651 + } 652 + 653 + tx, err := ddb.BeginTx(ctx, nil) 654 + if err != nil { 655 + l.Error("failed to begin transaction", "err", err) 656 + return err 657 + } 658 + 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) 673 + return err 674 + } 675 + 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) 690 + if err != nil { 691 + l.Error("failed to update issue", "err", err) 692 + return err 693 + } 694 + 695 + return nil 696 + 697 + 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 704 + } 705 + 706 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 707 + } 708 + 709 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 710 + did := e.Did 711 + rkey := e.Commit.RKey 712 + 713 + var err error 714 + 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 + } 722 + 723 + switch e.Commit.Operation { 724 + case models.CommitOperationCreate: 725 + raw := json.RawMessage(e.Commit.Record) 726 + record := tangled.RepoIssueComment{} 727 + err = json.Unmarshal(raw, &record) 728 + if err != nil { 729 + l.Error("invalid record", "err", err) 730 + return err 731 + } 732 + 733 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 734 + if err != nil { 735 + l.Error("failed to parse comment from record", "err", err) 736 + return err 737 + } 738 + 739 + sanitizer := markup.NewSanitizer() 740 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 741 + return fmt.Errorf("body is empty after HTML sanitization") 742 + } 743 + 744 + err = db.NewIssueComment(ddb, &comment) 745 + if err != nil { 746 + l.Error("failed to create issue comment", "err", err) 747 + return err 748 + } 749 + 750 + return nil 751 + 752 + case models.CommitOperationUpdate: 753 + raw := json.RawMessage(e.Commit.Record) 754 + record := tangled.RepoIssueComment{} 755 + err = json.Unmarshal(raw, &record) 756 + if err != nil { 757 + l.Error("invalid record", "err", err) 758 + return err 759 + } 760 + 761 + sanitizer := markup.NewSanitizer() 762 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 763 + return fmt.Errorf("body is empty after HTML sanitization") 764 + } 765 + 766 + err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 767 + if err != nil { 768 + l.Error("failed to update issue comment", "err", err) 769 + return err 770 + } 771 + 772 + return nil 773 + 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) 778 + } 779 + 780 + return nil 781 + } 782 + 783 + return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 784 + }
+4 -9
appview/issues/issues.go
··· 278 278 } 279 279 280 280 createdAt := time.Now().Format(time.RFC3339) 281 - commentIdInt64 := int64(commentId) 282 281 ownerDid := user.Did 283 282 issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 284 283 if err != nil { ··· 302 301 Val: &tangled.RepoIssueComment{ 303 302 Repo: &atUri, 304 303 Issue: issueAt, 305 - CommentId: &commentIdInt64, 306 304 Owner: &ownerDid, 307 305 Body: body, 308 306 CreatedAt: createdAt, ··· 451 449 repoAt := record["repo"].(string) 452 450 issueAt := record["issue"].(string) 453 451 createdAt := record["createdAt"].(string) 454 - commentIdInt64 := int64(commentIdInt) 455 452 456 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 457 454 Collection: tangled.RepoIssueCommentNSID, ··· 462 459 Val: &tangled.RepoIssueComment{ 463 460 Repo: &repoAt, 464 461 Issue: issueAt, 465 - CommentId: &commentIdInt64, 466 462 Owner: &comment.OwnerDid, 467 463 Body: newBody, 468 464 CreatedAt: createdAt, ··· 687 683 Rkey: issue.Rkey, 688 684 Record: &lexutil.LexiconTypeDecoder{ 689 685 Val: &tangled.RepoIssue{ 690 - Repo: atUri, 691 - Title: title, 692 - Body: &body, 693 - Owner: user.Did, 694 - IssueId: int64(issue.IssueId), 686 + Repo: atUri, 687 + Title: title, 688 + Body: &body, 689 + Owner: user.Did, 695 690 }, 696 691 }, 697 692 })
+3 -3
appview/middleware/middleware.go
··· 217 217 if err != nil { 218 218 // invalid did or handle 219 219 log.Println("failed to resolve repo") 220 - mw.pages.ErrorKnot404(w) 220 + mw.pages.Error404(w) 221 221 return 222 222 } 223 223 ··· 234 234 f, err := mw.repoResolver.Resolve(r) 235 235 if err != nil { 236 236 log.Println("failed to fully resolve repo", err) 237 - mw.pages.ErrorKnot404(w) 237 + http.Error(w, "invalid repo url", http.StatusNotFound) 238 238 return 239 239 } 240 240 ··· 283 283 f, err := mw.repoResolver.Resolve(r) 284 284 if err != nil { 285 285 log.Println("failed to fully resolve repo", err) 286 - mw.pages.ErrorKnot404(w) 286 + http.Error(w, "invalid repo url", http.StatusNotFound) 287 287 return 288 288 } 289 289
-13
appview/pages/funcmap.go
··· 21 21 "github.com/go-enry/go-enry/v2" 22 22 "tangled.sh/tangled.sh/core/appview/filetree" 23 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 - "tangled.sh/tangled.sh/core/crypto" 25 24 ) 26 25 27 26 func (p *Pages) funcMap() template.FuncMap { ··· 277 276 }, 278 277 "layoutCenter": func() string { 279 278 return "col-span-1 md:col-span-8 lg:col-span-6" 280 - }, 281 - 282 - "normalizeForHtmlId": func(s string) string { 283 - // TODO: extend this to handle other cases? 284 - return strings.ReplaceAll(s, ":", "_") 285 - }, 286 - "sshFingerprint": func(pubKey string) string { 287 - fp, err := crypto.SSHFingerprint(pubKey) 288 - if err != nil { 289 - return "error" 290 - } 291 - return fp 292 279 }, 293 280 } 294 281 }
+10 -64
appview/pages/pages.go
··· 306 306 return p.execute("timeline/timeline", w, params) 307 307 } 308 308 309 - type UserProfileSettingsParams struct { 310 - LoggedInUser *oauth.User 311 - Tabs []map[string]any 312 - Tab string 313 - } 314 - 315 - func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 316 - return p.execute("user/settings/profile", w, params) 317 - } 318 - 319 - type UserKeysSettingsParams struct { 309 + type SettingsParams struct { 320 310 LoggedInUser *oauth.User 321 311 PubKeys []db.PublicKey 322 - Tabs []map[string]any 323 - Tab string 324 - } 325 - 326 - func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 327 - return p.execute("user/settings/keys", w, params) 328 - } 329 - 330 - type UserEmailsSettingsParams struct { 331 - LoggedInUser *oauth.User 332 312 Emails []db.Email 333 - Tabs []map[string]any 334 - Tab string 335 313 } 336 314 337 - func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 338 - return p.execute("user/settings/emails", w, params) 315 + func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 316 + return p.execute("settings", w, params) 339 317 } 340 318 341 319 type KnotsParams struct { ··· 430 408 return p.execute("repo/fork", w, params) 431 409 } 432 410 433 - type ProfileHomePageParams struct { 411 + type ProfilePageParams struct { 434 412 LoggedInUser *oauth.User 435 413 Repos []db.Repo 436 414 CollaboratingRepos []db.Repo ··· 440 418 } 441 419 442 420 type ProfileCard struct { 443 - UserDid string 444 - UserHandle string 445 - FollowStatus db.FollowStatus 446 - FollowersCount int 447 - FollowingCount int 421 + UserDid string 422 + UserHandle string 423 + FollowStatus db.FollowStatus 424 + Followers int 425 + Following int 448 426 449 427 Profile *db.Profile 450 428 } 451 429 452 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 430 + func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 453 431 return p.execute("user/profile", w, params) 454 432 } 455 433 ··· 461 439 462 440 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 463 441 return p.execute("user/repos", w, params) 464 - } 465 - 466 - type FollowCard struct { 467 - UserDid string 468 - FollowStatus db.FollowStatus 469 - FollowersCount int 470 - FollowingCount int 471 - Profile *db.Profile 472 - } 473 - 474 - type FollowersPageParams struct { 475 - LoggedInUser *oauth.User 476 - Followers []FollowCard 477 - Card ProfileCard 478 - } 479 - 480 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 481 - return p.execute("user/followers", w, params) 482 - } 483 - 484 - type FollowingPageParams struct { 485 - LoggedInUser *oauth.User 486 - Following []FollowCard 487 - Card ProfileCard 488 - } 489 - 490 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 491 - return p.execute("user/following", w, params) 492 442 } 493 443 494 444 type FollowFragmentParams struct { ··· 1320 1270 1321 1271 func (p *Pages) Error404(w io.Writer) error { 1322 1272 return p.execute("errors/404", w, nil) 1323 - } 1324 - 1325 - func (p *Pages) ErrorKnot404(w io.Writer) error { 1326 - return p.execute("errors/knot404", w, nil) 1327 1273 } 1328 1274 1329 1275 func (p *Pages) Error503(w io.Writer) error {
+4 -24
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 - {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 404 &mdash; page not found 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 - </p> 19 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 21 - {{ i "arrow-left" "w-4 h-4" }} 22 - go back 23 - </a> 24 - </div> 25 - </div> 26 - </div> 27 - </div> 4 + <h1>404 &mdash; nothing like that here!</h1> 5 + <p> 6 + It seems we couldn't find what you were looking for. Sorry about that! 7 + </p> 28 8 {{ end }}
+3 -36
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 500 &mdash; internal server error 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 26 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 - {{ i "refresh-cw" "w-4 h-4" }} 29 - try again 30 - </button> 31 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 - {{ i "home" "w-4 h-4" }} 33 - back to home 34 - </a> 35 - </div> 36 - </div> 37 - </div> 38 - </div> 39 - {{ end }} 4 + <h1>500 &mdash; something broke!</h1> 5 + <p>We're working on getting service back up. Hang tight!</p> 6 + {{ end }}
+5 -28
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 - {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 503 &mdash; service unavailable 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 - </p> 19 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 - {{ i "refresh-cw" "w-4 h-4" }} 22 - try again 23 - </button> 24 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 25 - {{ i "arrow-left" "w-4 h-4" }} 26 - back to timeline 27 - </a> 28 - </div> 29 - </div> 30 - </div> 31 - </div> 4 + <h1>503 &mdash; unable to reach knot</h1> 5 + <p> 6 + We were unable to reach the knot hosting this repository. Try again 7 + later. 8 + </p> 32 9 {{ end }}
-28
appview/pages/templates/errors/knot404.html
··· 1 - {{ define "title" }}404 &middot; tangled{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 - <div class="mb-6"> 7 - <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 - {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 - </div> 10 - </div> 11 - 12 - <div class="space-y-4"> 13 - <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 - 404 &mdash; repository not found 15 - </h1> 16 - <p class="text-gray-600 dark:text-gray-300"> 17 - The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 - </p> 19 - <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 21 - {{ i "arrow-left" "w-4 h-4" }} 22 - back to timeline 23 - </a> 24 - </div> 25 - </div> 26 - </div> 27 - </div> 28 - {{ end }}
+14 -22
appview/pages/templates/repo/index.html
··· 356 356 357 357 {{ define "repoAfter" }} 358 358 {{- if or .HTMLReadme .Readme -}} 359 - <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 360 - {{- if .ReadmeFileName -}} 361 - <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"> 362 - {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 363 - <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 364 - </div> 365 - {{- end -}} 366 - <section 367 - class="p-6 overflow-auto {{ if not .Raw }} 368 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 369 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 370 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 371 - {{ end }}" 372 - > 373 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 374 - {{- .Readme -}} 375 - </pre> 376 - {{- else -}} 377 - {{ .HTMLReadme }} 378 - {{- end -}}</article> 379 - </section> 380 - </div> 359 + <section 360 + class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 361 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 362 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 363 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 364 + {{ end }}" 365 + > 366 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 367 + {{- .Readme -}} 368 + </pre> 369 + {{- else -}} 370 + {{ .HTMLReadme }} 371 + {{- end -}}</article> 372 + </section> 381 373 {{- end -}} 382 374 {{ end }}
+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 }}
+3 -3
appview/pages/templates/timeline/timeline.html
··· 171 171 {{ end }} 172 172 {{ end }} 173 173 {{ with $stat }} 174 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 175 175 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 176 + <span id="followers">{{ .Followers }} followers</span> 177 177 <span class="select-none after:content-['ยท']"></span> 178 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 178 + <span id="following">{{ .Following }} following</span> 179 179 </div> 180 180 {{ end }} 181 181 </div>
-30
appview/pages/templates/user/followers.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "followers" . }}{{ end }} 17 - </div> 18 - </div> 19 - {{ end }} 20 - 21 - {{ define "followers" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 23 - <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 24 - {{ range .Followers }} 25 - {{ template "user/fragments/followCard" . }} 26 - {{ else }} 27 - <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 28 - {{ end }} 29 - </div> 30 - {{ end }}
-30
appview/pages/templates/user/following.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "following" . }}{{ end }} 17 - </div> 18 - </div> 19 - {{ end }} 20 - 21 - {{ define "following" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 23 - <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 24 - {{ range .Following }} 25 - {{ template "user/fragments/followCard" . }} 26 - {{ else }} 27 - <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 28 - {{ end }} 29 - </div> 30 - {{ end }}
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 - <button id="{{ normalizeForHtmlId .UserDid }}" 2 + <button id="followBtn" 3 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 9 {{ end }} 10 10 11 11 hx-trigger="click" 12 - hx-target="#{{ normalizeForHtmlId .UserDid }}" 12 + hx-target="#followBtn" 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
-29
appview/pages/templates/user/fragments/followCard.html
··· 1 - {{ define "user/fragments/followCard" }} 2 - {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 - </div> 8 - 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['ยท']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 - </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 - {{ template "user/fragments/follow" . }} 25 - </div> 26 - {{ end }} 27 - </div> 28 - </div> 29 - {{ end }}
+14 -17
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 4 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 9 8 </div> 10 9 <div class="col-span-2"> 11 10 <div class="flex items-center flex-row flex-nowrap gap-2"> 12 - <p title="{{ $userIdent }}" 11 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 14 - {{ $userIdent }} 13 + {{ didOrHandle .UserDid .UserHandle }} 15 14 </p> 16 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 17 16 </div> 18 17 19 18 <div class="md:hidden"> 20 - {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 19 + {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 21 20 </div> 22 21 </div> 23 22 <div class="col-span-3 md:col-span-full"> ··· 30 29 {{ end }} 31 30 32 31 <div class="hidden md:block"> 33 - {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 32 + {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 34 33 </div> 35 34 36 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 43 42 {{ if .IncludeBluesky }} 44 43 <div class="flex items-center gap-2"> 45 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 46 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 45 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 47 46 </div> 48 47 {{ end }} 49 48 {{ range $link := .Links }} ··· 89 88 {{ end }} 90 89 91 90 {{ define "followerFollowing" }} 92 - {{ $root := index . 0 }} 93 - {{ $userIdent := index . 1 }} 94 - {{ with $root }} 95 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 98 - <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 100 - </div> 101 - {{ end }} 91 + {{ $followers := index . 0 }} 92 + {{ $following := index . 1 }} 93 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers">{{ $followers }} followers</span> 96 + <span class="select-none after:content-['ยท']"></span> 97 + <span id="following">{{ $following }} following</span> 98 + </div> 102 99 {{ end }} 103 100
+1 -1
appview/pages/templates/user/repos.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
-94
appview/pages/templates/user/settings/emails.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "emailSettings" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "emailSettings" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Commits authored using emails listed here will be associated with your Tangled profile. 25 - </p> 26 - </div> 27 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 - {{ template "addEmailButton" . }} 29 - </div> 30 - </div> 31 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 - {{ range .Emails }} 33 - {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 - {{ else }} 35 - <div class="flex items-center justify-center p-2 text-gray-500"> 36 - no emails added yet 37 - </div> 38 - {{ end }} 39 - </div> 40 - {{ end }} 41 - 42 - {{ define "addEmailButton" }} 43 - <button 44 - class="btn flex items-center gap-2" 45 - popovertarget="add-email-modal" 46 - popovertargetaction="toggle"> 47 - {{ i "plus" "size-4" }} 48 - add email 49 - </button> 50 - <div 51 - id="add-email-modal" 52 - popover 53 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 - {{ template "addEmailModal" . }} 55 - </div> 56 - {{ end}} 57 - 58 - {{ define "addEmailModal" }} 59 - <form 60 - hx-put="/settings/emails" 61 - hx-indicator="#spinner" 62 - hx-swap="none" 63 - class="flex flex-col gap-2" 64 - > 65 - <p class="uppercase p-0">ADD EMAIL</p> 66 - <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 - <input 68 - type="email" 69 - id="email-address" 70 - name="email" 71 - required 72 - placeholder="your@email.com" 73 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 - /> 75 - <div class="flex gap-2 pt-2"> 76 - <button 77 - type="button" 78 - popovertarget="add-email-modal" 79 - popovertargetaction="hide" 80 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 - > 82 - {{ i "x" "size-4" }} cancel 83 - </button> 84 - <button type="submit" class="btn w-1/2 flex items-center"> 85 - <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 - <span id="spinner" class="group"> 87 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 - </span> 89 - </button> 90 - </div> 91 - <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 - <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 - </form> 94 - {{ end }}
-62
appview/pages/templates/user/settings/fragments/emailListing.html
··· 1 - {{ define "user/settings/fragments/emailListing" }} 2 - {{ $root := index . 0 }} 3 - {{ $email := index . 1 }} 4 - <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 - <div class="flex items-center gap-2"> 7 - {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 - <span class="font-bold"> 9 - {{ $email.Address }} 10 - </span> 11 - <div class="inline-flex items-center gap-1"> 12 - {{ if $email.Verified }} 13 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 - {{ else }} 15 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 - {{ end }} 17 - {{ if $email.Primary }} 18 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 - {{ end }} 20 - </div> 21 - </div> 22 - <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 - <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 - </div> 25 - </div> 26 - <div class="flex gap-2 items-center"> 27 - {{ if not $email.Verified }} 28 - <button 29 - class="btn flex gap-2 text-sm px-2 py-1" 30 - hx-post="/settings/emails/verify/resend" 31 - hx-swap="none" 32 - hx-vals='{"email": "{{ $email.Address }}"}'> 33 - {{ i "rotate-cw" "w-4 h-4" }} 34 - <span class="hidden md:inline">resend</span> 35 - </button> 36 - {{ end }} 37 - {{ if and (not $email.Primary) $email.Verified }} 38 - <button 39 - class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 - hx-post="/settings/emails/primary" 41 - hx-swap="none" 42 - hx-vals='{"email": "{{ $email.Address }}"}'> 43 - set as primary 44 - </button> 45 - {{ end }} 46 - {{ if not $email.Primary }} 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete email" 50 - hx-delete="/settings/emails" 51 - hx-swap="none" 52 - hx-vals='{"email": "{{ $email.Address }}"}' 53 - hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 - > 55 - {{ i "trash-2" "w-5 h-5" }} 56 - <span class="hidden md:inline">delete</span> 57 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 - </button> 59 - {{ end }} 60 - </div> 61 - </div> 62 - {{ end }}
-31
appview/pages/templates/user/settings/fragments/keyListing.html
··· 1 - {{ define "user/settings/fragments/keyListing" }} 2 - {{ $root := index . 0 }} 3 - {{ $key := index . 1 }} 4 - <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 - <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 - <div class="flex items-center gap-2"> 7 - <span>{{ i "key" "w-4" "h-4" }}</span> 8 - <span class="font-bold"> 9 - {{ $key.Name }} 10 - </span> 11 - </div> 12 - <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 - {{ sshFingerprint $key.Key }} 14 - </span> 15 - <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 - <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 - </div> 18 - </div> 19 - <button 20 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 - title="Delete key" 22 - hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 - hx-swap="none" 24 - hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 - > 26 - {{ i "trash-2" "w-5 h-5" }} 27 - <span class="hidden md:inline">delete</span> 28 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 - </button> 30 - </div> 31 - {{ end }}
-16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 - {{ define "user/settings/fragments/sidebar" }} 2 - {{ $active := .Tab }} 3 - {{ $tabs := .Tabs }} 4 - <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 - {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 - {{ range $tabs }} 8 - <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 - <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 - {{ i .Icon "size-4" }} 11 - {{ .Name }} 12 - </div> 13 - </a> 14 - {{ end }} 15 - </div> 16 - {{ end }}
-101
appview/pages/templates/user/settings/keys.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "sshKeysSettings" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "sshKeysSettings" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - SSH public keys added here will be broadcasted to knots that you are a member of, 25 - allowing you to push to repositories there. 26 - </p> 27 - </div> 28 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 - {{ template "addKeyButton" . }} 30 - </div> 31 - </div> 32 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 - {{ range .PubKeys }} 34 - {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 - {{ else }} 36 - <div class="flex items-center justify-center p-2 text-gray-500"> 37 - no keys added yet 38 - </div> 39 - {{ end }} 40 - </div> 41 - {{ end }} 42 - 43 - {{ define "addKeyButton" }} 44 - <button 45 - class="btn flex items-center gap-2" 46 - popovertarget="add-key-modal" 47 - popovertargetaction="toggle"> 48 - {{ i "plus" "size-4" }} 49 - add key 50 - </button> 51 - <div 52 - id="add-key-modal" 53 - popover 54 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 - {{ template "addKeyModal" . }} 56 - </div> 57 - {{ end}} 58 - 59 - {{ define "addKeyModal" }} 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#spinner" 63 - hx-swap="none" 64 - class="flex flex-col gap-2" 65 - > 66 - <p class="uppercase p-0">ADD SSH KEY</p> 67 - <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 - <input 69 - type="text" 70 - id="key-name" 71 - name="name" 72 - required 73 - placeholder="key name" 74 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 - /> 76 - <textarea 77 - type="text" 78 - id="key-value" 79 - name="key" 80 - required 81 - placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 - <div class="flex gap-2 pt-2"> 84 - <button 85 - type="button" 86 - popovertarget="add-key-modal" 87 - popovertargetaction="hide" 88 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 - > 90 - {{ i "x" "size-4" }} cancel 91 - </button> 92 - <button type="submit" class="btn w-1/2 flex items-center"> 93 - <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 - <span id="spinner" class="group"> 95 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 - </span> 97 - </button> 98 - </div> 99 - <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 - </form> 101 - {{ end }}
-64
appview/pages/templates/user/settings/profile.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "profileInfo" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "profileInfo" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Your account information from your AT Protocol identity. 25 - </p> 26 - </div> 27 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 - </div> 29 - </div> 30 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 - <div class="flex items-center justify-between p-4"> 32 - <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 - {{ 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 }}
-27
appview/repo/repo.go
··· 125 125 126 126 repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 127 127 if err != nil { 128 - rp.pages.Error503(w) 129 128 log.Println("failed to reach knotserver", err) 130 129 return 131 130 } 132 131 133 132 tagResult, err := us.Tags(f.OwnerDid(), f.Name) 134 133 if err != nil { 135 - rp.pages.Error503(w) 136 134 log.Println("failed to reach knotserver", err) 137 135 return 138 136 } ··· 148 146 149 147 branchResult, err := us.Branches(f.OwnerDid(), f.Name) 150 148 if err != nil { 151 - rp.pages.Error503(w) 152 149 log.Println("failed to reach knotserver", err) 153 150 return 154 151 } ··· 315 312 316 313 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 317 314 if err != nil { 318 - rp.pages.Error503(w) 319 315 log.Println("failed to reach knotserver", err) 320 316 return 321 317 } ··· 379 375 if !rp.config.Core.Dev { 380 376 protocol = "https" 381 377 } 382 - 383 - // if the tree path has a trailing slash, let's strip it 384 - // so we don't 404 385 - treePath = strings.TrimSuffix(treePath, "/") 386 - 387 378 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 388 379 if err != nil { 389 - rp.pages.Error503(w) 390 380 log.Println("failed to reach knotserver", err) 391 381 return 392 382 } 393 383 394 - // uhhh so knotserver returns a 500 if the entry isn't found in 395 - // the requested tree path, so let's stick to not-OK here. 396 - // we can fix this once we build out the xrpc apis for these operations. 397 - if resp.StatusCode != http.StatusOK { 398 - rp.pages.Error404(w) 399 - return 400 - } 401 - 402 384 body, err := io.ReadAll(resp.Body) 403 385 if err != nil { 404 386 log.Printf("Error reading response body: %v", err) ··· 456 438 457 439 result, err := us.Tags(f.OwnerDid(), f.Name) 458 440 if err != nil { 459 - rp.pages.Error503(w) 460 441 log.Println("failed to reach knotserver", err) 461 442 return 462 443 } ··· 514 495 515 496 result, err := us.Branches(f.OwnerDid(), f.Name) 516 497 if err != nil { 517 - rp.pages.Error503(w) 518 498 log.Println("failed to reach knotserver", err) 519 499 return 520 500 } ··· 544 524 } 545 525 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 546 526 if err != nil { 547 - rp.pages.Error503(w) 548 527 log.Println("failed to reach knotserver", err) 549 - return 550 - } 551 - 552 - if resp.StatusCode == http.StatusNotFound { 553 - rp.pages.Error404(w) 554 528 return 555 529 } 556 530 ··· 1268 1242 1269 1243 result, err := us.Branches(f.OwnerDid(), f.Name) 1270 1244 if err != nil { 1271 - rp.pages.Error503(w) 1272 1245 log.Println("failed to reach knotserver", err) 1273 1246 return 1274 1247 }
+9 -44
appview/settings/settings.go
··· 33 33 Config *config.Config 34 34 } 35 35 36 - type tab = map[string]any 37 - 38 - var ( 39 - settingsTabs []tab = []tab{ 40 - {"Name": "profile", "Icon": "user"}, 41 - {"Name": "keys", "Icon": "key"}, 42 - {"Name": "emails", "Icon": "mail"}, 43 - } 44 - ) 45 - 46 36 func (s *Settings) Router() http.Handler { 47 37 r := chi.NewRouter() 48 38 49 39 r.Use(middleware.AuthMiddleware(s.OAuth)) 50 40 51 - // settings pages 52 - r.Get("/", s.profileSettings) 53 - r.Get("/profile", s.profileSettings) 41 + r.Get("/", s.settings) 54 42 55 43 r.Route("/keys", func(r chi.Router) { 56 - r.Get("/", s.keysSettings) 57 44 r.Put("/", s.keys) 58 45 r.Delete("/", s.keys) 59 46 }) 60 47 61 48 r.Route("/emails", func(r chi.Router) { 62 - r.Get("/", s.emailsSettings) 63 49 r.Put("/", s.emails) 64 50 r.Delete("/", s.emails) 65 51 r.Get("/verify", s.emailsVerify) ··· 70 56 return r 71 57 } 72 58 73 - func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 - user := s.OAuth.GetUser(r) 75 - 76 - s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 - LoggedInUser: user, 78 - Tabs: settingsTabs, 79 - Tab: "profile", 80 - }) 81 - } 82 - 83 - func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 59 + func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 84 60 user := s.OAuth.GetUser(r) 85 61 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 86 62 if err != nil { 87 63 log.Println(err) 88 64 } 89 65 90 - s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 - LoggedInUser: user, 92 - PubKeys: pubKeys, 93 - Tabs: settingsTabs, 94 - Tab: "keys", 95 - }) 96 - } 97 - 98 - func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 - user := s.OAuth.GetUser(r) 100 66 emails, err := db.GetAllEmails(s.Db, user.Did) 101 67 if err != nil { 102 68 log.Println(err) 103 69 } 104 70 105 - s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 71 + s.Pages.Settings(w, pages.SettingsParams{ 106 72 LoggedInUser: user, 73 + PubKeys: pubKeys, 107 74 Emails: emails, 108 - Tabs: settingsTabs, 109 - Tab: "emails", 110 75 }) 111 76 } 112 77 ··· 236 201 return 237 202 } 238 203 239 - s.Pages.HxLocation(w, "/settings/emails") 204 + s.Pages.HxLocation(w, "/settings") 240 205 return 241 206 } 242 207 } ··· 279 244 return 280 245 } 281 246 282 - http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 247 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 283 248 } 284 249 285 250 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 374 339 return 375 340 } 376 341 377 - s.Pages.HxLocation(w, "/settings/emails") 342 + s.Pages.HxLocation(w, "/settings") 378 343 } 379 344 380 345 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 445 410 return 446 411 } 447 412 448 - s.Pages.HxLocation(w, "/settings/keys") 413 + s.Pages.HxLocation(w, "/settings") 449 414 return 450 415 451 416 case http.MethodDelete: ··· 490 455 } 491 456 log.Println("deleted successfully") 492 457 493 - s.Pages.HxLocation(w, "/settings/keys") 458 + s.Pages.HxLocation(w, "/settings") 494 459 return 495 460 } 496 461 }
+62 -212
appview/state/profile.go
··· 17 17 "github.com/gorilla/feeds" 18 18 "tangled.sh/tangled.sh/core/api/tangled" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 22 21 ) 23 22 ··· 25 24 tabVal := r.URL.Query().Get("tab") 26 25 switch tabVal { 27 26 case "": 28 - s.profileHomePage(w, r) 27 + s.profilePage(w, r) 29 28 case "repos": 30 29 s.reposPage(w, r) 31 - case "followers": 32 - s.followersPage(w, r) 33 - case "following": 34 - s.followingPage(w, r) 35 30 } 36 31 } 37 32 38 - type ProfilePageParams struct { 39 - Id identity.Identity 40 - LoggedInUser *oauth.User 41 - Card pages.ProfileCard 42 - } 43 - 44 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 33 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 45 34 didOrHandle := chi.URLParam(r, "user") 46 35 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 36 + http.Error(w, "Bad request", http.StatusBadRequest) 37 + return 49 38 } 50 39 51 40 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 41 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 42 + s.pages.Error404(w) 43 + return 56 44 } 57 - did := ident.DID.String() 58 45 59 - profile, err := db.GetProfile(s.db, did) 46 + profile, err := db.GetProfile(s.db, ident.DID.String()) 60 47 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 48 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 64 49 } 65 50 66 - followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 - if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 69 - } 70 - 71 - loggedInUser := s.oauth.GetUser(r) 72 - followStatus := db.IsNotFollowing 73 - if loggedInUser != nil { 74 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 - } 76 - 77 - return &ProfilePageParams{ 78 - Id: ident, 79 - LoggedInUser: loggedInUser, 80 - Card: pages.ProfileCard{ 81 - UserDid: did, 82 - UserHandle: ident.Handle.String(), 83 - Profile: profile, 84 - FollowStatus: followStatus, 85 - FollowersCount: followStats.Followers, 86 - FollowingCount: followStats.Following, 87 - }, 88 - } 89 - } 90 - 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 94 - return 95 - } 96 - 97 - id := pageWithProfile.Id 98 51 repos, err := db.GetRepos( 99 52 s.db, 100 53 0, 101 - db.FilterEq("did", id.DID), 54 + db.FilterEq("did", ident.DID.String()), 102 55 ) 103 56 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 57 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 105 58 } 106 59 107 - profile := pageWithProfile.Card.Profile 108 60 // filter out ones that are pinned 109 61 pinnedRepos := []db.Repo{} 110 62 for i, r := range repos { ··· 119 71 } 120 72 } 121 73 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 74 + collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 123 75 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 76 + log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 125 77 } 126 78 127 79 pinnedCollaboratingRepos := []db.Repo{} ··· 132 84 } 133 85 } 134 86 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 87 + timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 136 88 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 89 + log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 138 90 } 139 91 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 92 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 93 + if err != nil { 94 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 143 95 } 144 - for _, byMonth := range timeline.ByMonth { 145 - for _, pe := range byMonth.PullEvents.Items { 146 - didsToResolve = append(didsToResolve, pe.Repo.Did) 147 - } 148 - for _, ie := range byMonth.IssueEvents.Items { 149 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 150 - } 151 - for _, re := range byMonth.RepoEvents { 152 - didsToResolve = append(didsToResolve, re.Repo.Did) 153 - if re.Source != nil { 154 - didsToResolve = append(didsToResolve, re.Source.Did) 155 - } 156 - } 96 + 97 + loggedInUser := s.oauth.GetUser(r) 98 + followStatus := db.IsNotFollowing 99 + if loggedInUser != nil { 100 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 157 101 } 158 102 159 103 now := time.Now() 160 104 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 105 punchcard, err := db.MakePunchcard( 162 106 s.db, 163 - db.FilterEq("did", id.DID), 107 + db.FilterEq("did", ident.DID.String()), 164 108 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 109 db.FilterLte("date", now.Format(time.DateOnly)), 166 110 ) 167 111 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 112 + log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 169 113 } 170 114 171 - s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 - LoggedInUser: pageWithProfile.LoggedInUser, 115 + s.pages.ProfilePage(w, pages.ProfilePageParams{ 116 + LoggedInUser: loggedInUser, 173 117 Repos: pinnedRepos, 174 118 CollaboratingRepos: pinnedCollaboratingRepos, 175 - Card: pageWithProfile.Card, 176 - Punchcard: punchcard, 177 - ProfileTimeline: timeline, 119 + Card: pages.ProfileCard{ 120 + UserDid: ident.DID.String(), 121 + UserHandle: ident.Handle.String(), 122 + Profile: profile, 123 + FollowStatus: followStatus, 124 + Followers: followers, 125 + Following: following, 126 + }, 127 + Punchcard: punchcard, 128 + ProfileTimeline: timeline, 178 129 }) 179 130 } 180 131 181 132 func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 133 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 134 + if !ok { 135 + s.pages.Error404(w) 184 136 return 185 137 } 186 138 187 - id := pageWithProfile.Id 139 + profile, err := db.GetProfile(s.db, ident.DID.String()) 140 + if err != nil { 141 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 142 + } 143 + 188 144 repos, err := db.GetRepos( 189 145 s.db, 190 146 0, 191 - db.FilterEq("did", id.DID), 147 + db.FilterEq("did", ident.DID.String()), 192 148 ) 193 149 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 195 - } 196 - 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 199 - Repos: repos, 200 - Card: pageWithProfile.Card, 201 - }) 202 - } 203 - 204 - type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 208 - } 209 - 210 - func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 - pageWithProfile := s.profilePage(w, r) 212 - if pageWithProfile == nil { 213 - return FollowsPageParams{}, nil 150 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 214 151 } 215 152 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 218 - 219 - follows, err := fetchFollows(s.db, id.DID.String()) 220 - if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 153 + loggedInUser := s.oauth.GetUser(r) 154 + followStatus := db.IsNotFollowing 155 + if loggedInUser != nil { 156 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 223 157 } 224 158 225 - if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 231 - } 232 - 233 - followDids := make([]string, 0, len(follows)) 234 - for _, follow := range follows { 235 - followDids = append(followDids, extractDid(follow)) 236 - } 237 - 238 - profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 159 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 239 160 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 242 - } 243 - 244 - followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 245 - if err != nil { 246 - log.Printf("getting follow counts for %s: %s", followDids, err) 247 - } 248 - 249 - var loggedInUserFollowing map[string]struct{} 250 - if loggedInUser != nil { 251 - following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 - if err != nil { 253 - return FollowsPageParams{}, err 254 - } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 260 - } 161 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 261 162 } 262 163 263 - followCards := make([]pages.FollowCard, 0, len(follows)) 264 - for _, did := range followDids { 265 - followStats, exists := followStatsMap[did] 266 - if !exists { 267 - followStats = db.FollowStats{} 268 - } 269 - followStatus := db.IsNotFollowing 270 - if loggedInUserFollowing != nil { 271 - if _, exists := loggedInUserFollowing[did]; exists { 272 - followStatus = db.IsFollowing 273 - } else if loggedInUser.Did == did { 274 - followStatus = db.IsSelf 275 - } 276 - } 277 - var profile *db.Profile 278 - if p, exists := profiles[did]; exists { 279 - profile = p 280 - } else { 281 - profile = &db.Profile{} 282 - profile.Did = did 283 - } 284 - followCards = append(followCards, pages.FollowCard{ 285 - UserDid: did, 286 - FollowStatus: followStatus, 287 - FollowersCount: followStats.Followers, 288 - FollowingCount: followStats.Following, 289 - Profile: profile, 290 - }) 291 - } 292 - 293 - return FollowsPageParams{ 164 + s.pages.ReposPage(w, pages.ReposPageParams{ 294 165 LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 298 - } 299 - 300 - func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 - followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 - if err != nil { 303 - s.pages.Notice(w, "all-followers", "Failed to load followers") 304 - return 305 - } 306 - 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 309 - Followers: followPage.Follows, 310 - Card: followPage.Card, 311 - }) 312 - } 313 - 314 - func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 - followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 - if err != nil { 317 - s.pages.Notice(w, "all-following", "Failed to load following") 318 - return 319 - } 320 - 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 323 - Following: followPage.Follows, 324 - Card: followPage.Card, 166 + Repos: repos, 167 + Card: pages.ProfileCard{ 168 + UserDid: ident.DID.String(), 169 + UserHandle: ident.Handle.String(), 170 + Profile: profile, 171 + FollowStatus: followStatus, 172 + Followers: followers, 173 + Following: following, 174 + }, 325 175 }) 326 176 } 327 177
+2
appview/state/state.go
··· 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 96 tangled.StringNSID, 97 + tangled.RepoIssueNSID, 98 + tangled.RepoIssueCommentNSID, 97 99 }, 98 100 nil, 99 101 slog.Default(),
+7 -7
appview/strings/strings.go
··· 202 202 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 203 } 204 204 205 - followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 205 + followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 206 if err != nil { 207 207 l.Error("failed to get follow stats", "err", err) 208 208 } ··· 210 210 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 211 LoggedInUser: s.OAuth.GetUser(r), 212 212 Card: pages.ProfileCard{ 213 - UserDid: id.DID.String(), 214 - UserHandle: id.Handle.String(), 215 - Profile: profile, 216 - FollowStatus: followStatus, 217 - FollowersCount: followStats.Followers, 218 - FollowingCount: followStats.Following, 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + Followers: followers, 218 + Following: following, 219 219 }, 220 220 Strings: all, 221 221 })
-1
cmd/gen.go
··· 24 24 tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 25 tangled.GitRefUpdate_Pair{}, 26 26 tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 27 tangled.KnotMember{}, 29 28 tangled.Pipeline{}, 30 29 tangled.Pipeline_CloneOpts{},
+1 -1
knotserver/config/config.go
··· 17 17 type Server struct { 18 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 + Secret string `env:"SECRET, required"` 20 21 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 22 Hostname string `env:"HOSTNAME, required"` 22 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 - Owner string `env:"OWNER, required"` 24 24 LogDids bool `env:"LOG_DIDS, default=true"` 25 25 26 26 // This disables signature verification so use with caution.
+29 -828
knotserver/handler.go
··· 16 16 tlog "tangled.sh/tangled.sh/core/log" 17 17 "tangled.sh/tangled.sh/core/notifier" 18 18 "tangled.sh/tangled.sh/core/rbac" 19 - "tangled.sh/tangled.sh/core/types" 20 19 ) 21 20 22 21 type Handle struct { ··· 27 26 l *slog.Logger 28 27 n *notifier.Notifier 29 28 resolver *idresolver.Resolver 29 + 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 30 34 } 31 35 32 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) { ··· 40 44 jc: jc, 41 45 n: n, 42 46 resolver: idresolver.DefaultResolver(), 47 + init: make(chan struct{}), 43 48 } 44 49 45 50 err := e.AddKnot(rbac.ThisServer) ··· 47 52 return nil, fmt.Errorf("failed to setup enforcer: %w", err) 48 53 } 49 54 50 - // configure owner 51 - if err = h.configureOwner(); err != nil { 52 - return nil, err 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() 59 + if err != nil { 60 + return nil, fmt.Errorf("failed to get all Dids: %w", err) 53 61 } 54 - h.l.Info("owner set", "did", h.c.Server.Owner) 55 - h.jc.AddDid(h.c.Server.Owner) 56 62 57 - // configure known-dids in jetstream consumer 58 - dids, err := h.db.GetAllDids() 59 - if err != nil { 60 - return nil, fmt.Errorf("failed to get all dids: %w", err) 61 - } 62 - for _, d := range dids { 63 - jc.AddDid(d) 63 + if len(dids) > 0 { 64 + h.knotInitialized = true 65 + close(h.init) 66 + for _, d := range dids { 67 + h.jc.AddDid(d) 68 + } 64 69 } 65 70 66 71 err = h.jc.StartJetstream(ctx, h.processMessages) ··· 71 76 r.Get("/", h.Index) 72 77 r.Get("/capabilities", h.Capabilities) 73 78 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 79 r.Route("/{did}", func(r chi.Router) { 78 80 // Repo routes 79 81 r.Route("/{name}", func(r chi.Router) { ··· 136 138 // Create a new repository. 137 139 r.Route("/repo", func(r chi.Router) { 138 140 r.Use(h.VerifySignature) 141 + r.Put("/new", h.NewRepo) 139 142 r.Delete("/", h.RemoveRepo) 140 143 r.Route("/fork", func(r chi.Router) { 141 144 r.Post("/", h.RepoFork) ··· 152 155 // Socket that streams git oplogs 153 156 r.Get("/events", h.Events) 154 157 158 + // Initialize the knot with an owner and public key. 159 + r.With(h.VerifySignature).Post("/init", h.Init) 160 + 155 161 // Health check. Used for two-way verification with appview. 156 162 r.With(h.VerifySignature).Get("/health", h.Health) 157 163 ··· 164 170 func (h *Handle) XrpcRouter() http.Handler { 165 171 logger := tlog.New("knots") 166 172 167 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 168 - 169 173 xrpc := &xrpc.Xrpc{ 170 - Config: h.c, 171 - Db: h.db, 172 - Ingester: h.jc, 173 - Enforcer: h.e, 174 - Logger: logger, 175 - Notifier: h.n, 176 - Resolver: h.resolver, 177 - ServiceAuth: serviceAuth, 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, 178 181 } 179 182 return xrpc.Router() 180 183 } ··· 206 209 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 207 210 fmt.Fprintf(w, "knotserver/%s", version) 208 211 } 209 - 210 - func (h *Handle) configureOwner() error { 211 - cfgOwner := h.c.Server.Owner 212 - 213 - rbacDomain := "thisserver" 214 - 215 - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 216 - if err != nil { 217 - return err 218 - } 219 - 220 - switch len(existing) { 221 - case 0: 222 - // no owner configured, continue 223 - case 1: 224 - // find existing owner 225 - existingOwner := existing[0] 226 - 227 - // no ownership change, this is okay 228 - if existingOwner == h.c.Server.Owner { 229 - break 230 - } 231 - 232 - // remove existing owner 233 - err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) 234 - if err != nil { 235 - return nil 236 - } 237 - default: 238 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 239 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 240 - return 241 - } 242 - 243 - w.Header().Set("Content-Type", mimeType) 244 - w.Write(contents) 245 - } 246 - 247 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 248 - treePath := chi.URLParam(r, "*") 249 - ref := chi.URLParam(r, "ref") 250 - ref, _ = url.PathUnescape(ref) 251 - 252 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 253 - 254 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 255 - gr, err := git.Open(path, ref) 256 - if err != nil { 257 - notFound(w) 258 - return 259 - } 260 - 261 - var isBinaryFile bool = false 262 - contents, err := gr.FileContent(treePath) 263 - if errors.Is(err, git.ErrBinaryFile) { 264 - isBinaryFile = true 265 - } else if errors.Is(err, object.ErrFileNotFound) { 266 - notFound(w) 267 - return 268 - } else if err != nil { 269 - writeError(w, err.Error(), http.StatusInternalServerError) 270 - return 271 - } 272 - 273 - bytes := []byte(contents) 274 - // safe := string(sanitize(bytes)) 275 - sizeHint := len(bytes) 276 - 277 - resp := types.RepoBlobResponse{ 278 - Ref: ref, 279 - Contents: string(bytes), 280 - Path: treePath, 281 - IsBinary: isBinaryFile, 282 - SizeHint: uint64(sizeHint), 283 - } 284 - 285 - h.showFile(resp, w, l) 286 - } 287 - 288 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 289 - name := chi.URLParam(r, "name") 290 - file := chi.URLParam(r, "file") 291 - 292 - l := h.l.With("handler", "Archive", "name", name, "file", file) 293 - 294 - // TODO: extend this to add more files compression (e.g.: xz) 295 - if !strings.HasSuffix(file, ".tar.gz") { 296 - notFound(w) 297 - return 298 - } 299 - 300 - ref := strings.TrimSuffix(file, ".tar.gz") 301 - 302 - unescapedRef, err := url.PathUnescape(ref) 303 - if err != nil { 304 - notFound(w) 305 - return 306 - } 307 - 308 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 309 - 310 - // This allows the browser to use a proper name for the file when 311 - // downloading 312 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 313 - setContentDisposition(w, filename) 314 - setGZipMIME(w) 315 - 316 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 317 - gr, err := git.Open(path, unescapedRef) 318 - if err != nil { 319 - notFound(w) 320 - return 321 - } 322 - 323 - gw := gzip.NewWriter(w) 324 - defer gw.Close() 325 - 326 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 327 - err = gr.WriteTar(gw, prefix) 328 - if err != nil { 329 - // once we start writing to the body we can't report error anymore 330 - // so we are only left with printing the error. 331 - l.Error("writing tar file", "error", err.Error()) 332 - return 333 - } 334 - 335 - err = gw.Flush() 336 - if err != nil { 337 - // once we start writing to the body we can't report error anymore 338 - // so we are only left with printing the error. 339 - l.Error("flushing?", "error", err.Error()) 340 - return 341 - } 342 - } 343 - 344 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 345 - ref := chi.URLParam(r, "ref") 346 - ref, _ = url.PathUnescape(ref) 347 - 348 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 349 - 350 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 351 - 352 - gr, err := git.Open(path, ref) 353 - if err != nil { 354 - notFound(w) 355 - return 356 - } 357 - 358 - // Get page parameters 359 - page := 1 360 - pageSize := 30 361 - 362 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 363 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 364 - page = p 365 - } 366 - } 367 - 368 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 369 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 370 - pageSize = ps 371 - } 372 - } 373 - 374 - // convert to offset/limit 375 - offset := (page - 1) * pageSize 376 - limit := pageSize 377 - 378 - commits, err := gr.Commits(offset, limit) 379 - if err != nil { 380 - writeError(w, err.Error(), http.StatusInternalServerError) 381 - l.Error("fetching commits", "error", err.Error()) 382 - return 383 - } 384 - 385 - total := len(commits) 386 - 387 - resp := types.RepoLogResponse{ 388 - Commits: commits, 389 - Ref: ref, 390 - Description: getDescription(path), 391 - Log: true, 392 - Total: total, 393 - Page: page, 394 - PerPage: pageSize, 395 - } 396 - 397 - writeJSON(w, resp) 398 - } 399 - 400 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 401 - ref := chi.URLParam(r, "ref") 402 - ref, _ = url.PathUnescape(ref) 403 - 404 - l := h.l.With("handler", "Diff", "ref", ref) 405 - 406 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 407 - gr, err := git.Open(path, ref) 408 - if err != nil { 409 - notFound(w) 410 - return 411 - } 412 - 413 - diff, err := gr.Diff() 414 - if err != nil { 415 - writeError(w, err.Error(), http.StatusInternalServerError) 416 - l.Error("getting diff", "error", err.Error()) 417 - return 418 - } 419 - 420 - resp := types.RepoCommitResponse{ 421 - Ref: ref, 422 - Diff: diff, 423 - } 424 - 425 - writeJSON(w, resp) 426 - } 427 - 428 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 429 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 430 - l := h.l.With("handler", "Refs") 431 - 432 - gr, err := git.Open(path, "") 433 - if err != nil { 434 - notFound(w) 435 - return 436 - } 437 - 438 - tags, err := gr.Tags() 439 - if err != nil { 440 - // Non-fatal, we *should* have at least one branch to show. 441 - l.Warn("getting tags", "error", err.Error()) 442 - } 443 - 444 - rtags := []*types.TagReference{} 445 - for _, tag := range tags { 446 - var target *object.Tag 447 - if tag.Target != plumbing.ZeroHash { 448 - target = &tag 449 - } 450 - tr := types.TagReference{ 451 - Tag: target, 452 - } 453 - 454 - tr.Reference = types.Reference{ 455 - Name: tag.Name, 456 - Hash: tag.Hash.String(), 457 - } 458 - 459 - if tag.Message != "" { 460 - tr.Message = tag.Message 461 - } 462 - 463 - rtags = append(rtags, &tr) 464 - } 465 - 466 - resp := types.RepoTagsResponse{ 467 - Tags: rtags, 468 - } 469 - 470 - writeJSON(w, resp) 471 - } 472 - 473 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 474 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 475 - 476 - gr, err := git.PlainOpen(path) 477 - if err != nil { 478 - notFound(w) 479 - return 480 - } 481 - 482 - branches, _ := gr.Branches() 483 - 484 - resp := types.RepoBranchesResponse{ 485 - Branches: branches, 486 - } 487 - 488 - writeJSON(w, resp) 489 - } 490 - 491 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 492 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 493 - branchName := chi.URLParam(r, "branch") 494 - branchName, _ = url.PathUnescape(branchName) 495 - 496 - l := h.l.With("handler", "Branch") 497 - 498 - gr, err := git.PlainOpen(path) 499 - if err != nil { 500 - notFound(w) 501 - return 502 - } 503 - 504 - ref, err := gr.Branch(branchName) 505 - if err != nil { 506 - l.Error("getting branch", "error", err.Error()) 507 - writeError(w, err.Error(), http.StatusInternalServerError) 508 - return 509 - } 510 - 511 - commit, err := gr.Commit(ref.Hash()) 512 - if err != nil { 513 - l.Error("getting commit object", "error", err.Error()) 514 - writeError(w, err.Error(), http.StatusInternalServerError) 515 - return 516 - } 517 - 518 - defaultBranch, err := gr.FindMainBranch() 519 - isDefault := false 520 - if err != nil { 521 - l.Error("getting default branch", "error", err.Error()) 522 - // do not quit though 523 - } else if defaultBranch == branchName { 524 - isDefault = true 525 - } 526 - 527 - resp := types.RepoBranchResponse{ 528 - Branch: types.Branch{ 529 - Reference: types.Reference{ 530 - Name: ref.Name().Short(), 531 - Hash: ref.Hash().String(), 532 - }, 533 - Commit: commit, 534 - IsDefault: isDefault, 535 - }, 536 - } 537 - 538 - writeJSON(w, resp) 539 - } 540 - 541 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 542 - l := h.l.With("handler", "Keys") 543 - 544 - switch r.Method { 545 - case http.MethodGet: 546 - keys, err := h.db.GetAllPublicKeys() 547 - if err != nil { 548 - writeError(w, err.Error(), http.StatusInternalServerError) 549 - l.Error("getting public keys", "error", err.Error()) 550 - return 551 - } 552 - 553 - data := make([]map[string]any, 0) 554 - for _, key := range keys { 555 - j := key.JSON() 556 - data = append(data, j) 557 - } 558 - writeJSON(w, data) 559 - return 560 - 561 - case http.MethodPut: 562 - pk := db.PublicKey{} 563 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 564 - writeError(w, "invalid request body", http.StatusBadRequest) 565 - return 566 - } 567 - 568 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 569 - if err != nil { 570 - writeError(w, "invalid pubkey", http.StatusBadRequest) 571 - } 572 - 573 - if err := h.db.AddPublicKey(pk); err != nil { 574 - writeError(w, err.Error(), http.StatusInternalServerError) 575 - l.Error("adding public key", "error", err.Error()) 576 - return 577 - } 578 - 579 - w.WriteHeader(http.StatusNoContent) 580 - return 581 - } 582 - } 583 - 584 - // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 585 - // l := h.l.With("handler", "RepoForkSync") 586 - // 587 - // data := struct { 588 - // Did string `json:"did"` 589 - // Source string `json:"source"` 590 - // Name string `json:"name,omitempty"` 591 - // HiddenRef string `json:"hiddenref"` 592 - // }{} 593 - // 594 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 595 - // writeError(w, "invalid request body", http.StatusBadRequest) 596 - // return 597 - // } 598 - // 599 - // did := data.Did 600 - // source := data.Source 601 - // 602 - // if did == "" || source == "" { 603 - // l.Error("invalid request body, empty did or name") 604 - // w.WriteHeader(http.StatusBadRequest) 605 - // return 606 - // } 607 - // 608 - // var name string 609 - // if data.Name != "" { 610 - // name = data.Name 611 - // } else { 612 - // name = filepath.Base(source) 613 - // } 614 - // 615 - // branch := chi.URLParam(r, "branch") 616 - // branch, _ = url.PathUnescape(branch) 617 - // 618 - // relativeRepoPath := filepath.Join(did, name) 619 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 620 - // 621 - // gr, err := git.PlainOpen(repoPath) 622 - // if err != nil { 623 - // log.Println(err) 624 - // notFound(w) 625 - // return 626 - // } 627 - // 628 - // forkCommit, err := gr.ResolveRevision(branch) 629 - // if err != nil { 630 - // l.Error("error resolving ref revision", "msg", err.Error()) 631 - // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 632 - // return 633 - // } 634 - // 635 - // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 636 - // if err != nil { 637 - // l.Error("error resolving hidden ref revision", "msg", err.Error()) 638 - // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 639 - // return 640 - // } 641 - // 642 - // status := types.UpToDate 643 - // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 644 - // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 645 - // if err != nil { 646 - // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 647 - // return 648 - // } 649 - // 650 - // if isAncestor { 651 - // status = types.FastForwardable 652 - // } else { 653 - // status = types.Conflict 654 - // } 655 - // } 656 - // 657 - // w.Header().Set("Content-Type", "application/json") 658 - // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 659 - // } 660 - 661 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 662 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 663 - ref := chi.URLParam(r, "ref") 664 - ref, _ = url.PathUnescape(ref) 665 - 666 - l := h.l.With("handler", "RepoLanguages") 667 - 668 - gr, err := git.Open(repoPath, ref) 669 - if err != nil { 670 - l.Error("opening repo", "error", err.Error()) 671 - notFound(w) 672 - return 673 - } 674 - 675 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 676 - defer cancel() 677 - 678 - sizes, err := gr.AnalyzeLanguages(ctx) 679 - if err != nil { 680 - l.Error("failed to analyze languages", "error", err.Error()) 681 - writeError(w, err.Error(), http.StatusNoContent) 682 - return 683 - } 684 - 685 - resp := types.RepoLanguageResponse{Languages: sizes} 686 - 687 - writeJSON(w, resp) 688 - } 689 - 690 - // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 691 - // l := h.l.With("handler", "RepoForkSync") 692 - // 693 - // data := struct { 694 - // Did string `json:"did"` 695 - // Source string `json:"source"` 696 - // Name string `json:"name,omitempty"` 697 - // }{} 698 - // 699 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 700 - // writeError(w, "invalid request body", http.StatusBadRequest) 701 - // return 702 - // } 703 - // 704 - // did := data.Did 705 - // source := data.Source 706 - // 707 - // if did == "" || source == "" { 708 - // l.Error("invalid request body, empty did or name") 709 - // w.WriteHeader(http.StatusBadRequest) 710 - // return 711 - // } 712 - // 713 - // var name string 714 - // if data.Name != "" { 715 - // name = data.Name 716 - // } else { 717 - // name = filepath.Base(source) 718 - // } 719 - // 720 - // branch := chi.URLParam(r, "branch") 721 - // branch, _ = url.PathUnescape(branch) 722 - // 723 - // relativeRepoPath := filepath.Join(did, name) 724 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 725 - // 726 - // gr, err := git.Open(repoPath, branch) 727 - // if err != nil { 728 - // log.Println(err) 729 - // notFound(w) 730 - // return 731 - // } 732 - // 733 - // err = gr.Sync() 734 - // if err != nil { 735 - // l.Error("error syncing repo fork", "error", err.Error()) 736 - // writeError(w, err.Error(), http.StatusInternalServerError) 737 - // return 738 - // } 739 - // 740 - // w.WriteHeader(http.StatusNoContent) 741 - // } 742 - 743 - // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 744 - // l := h.l.With("handler", "RepoFork") 745 - // 746 - // data := struct { 747 - // Did string `json:"did"` 748 - // Source string `json:"source"` 749 - // Name string `json:"name,omitempty"` 750 - // }{} 751 - // 752 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 753 - // writeError(w, "invalid request body", http.StatusBadRequest) 754 - // return 755 - // } 756 - // 757 - // did := data.Did 758 - // source := data.Source 759 - // 760 - // if did == "" || source == "" { 761 - // l.Error("invalid request body, empty did or name") 762 - // w.WriteHeader(http.StatusBadRequest) 763 - // return 764 - // } 765 - // 766 - // var name string 767 - // if data.Name != "" { 768 - // name = data.Name 769 - // } else { 770 - // name = filepath.Base(source) 771 - // } 772 - // 773 - // relativeRepoPath := filepath.Join(did, name) 774 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 775 - // 776 - // err := git.Fork(repoPath, source) 777 - // if err != nil { 778 - // l.Error("forking repo", "error", err.Error()) 779 - // writeError(w, err.Error(), http.StatusInternalServerError) 780 - // return 781 - // } 782 - // 783 - // // add perms for this user to access the repo 784 - // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 785 - // if err != nil { 786 - // l.Error("adding repo permissions", "error", err.Error()) 787 - // writeError(w, err.Error(), http.StatusInternalServerError) 788 - // return 789 - // } 790 - // 791 - // hook.SetupRepo( 792 - // hook.Config( 793 - // hook.WithScanPath(h.c.Repo.ScanPath), 794 - // hook.WithInternalApi(h.c.Server.InternalListenAddr), 795 - // ), 796 - // repoPath, 797 - // ) 798 - // 799 - // w.WriteHeader(http.StatusNoContent) 800 - // } 801 - 802 - // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 803 - // l := h.l.With("handler", "RemoveRepo") 804 - // 805 - // data := struct { 806 - // Did string `json:"did"` 807 - // Name string `json:"name"` 808 - // }{} 809 - // 810 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 811 - // writeError(w, "invalid request body", http.StatusBadRequest) 812 - // return 813 - // } 814 - // 815 - // did := data.Did 816 - // name := data.Name 817 - // 818 - // if did == "" || name == "" { 819 - // l.Error("invalid request body, empty did or name") 820 - // w.WriteHeader(http.StatusBadRequest) 821 - // return 822 - // } 823 - // 824 - // relativeRepoPath := filepath.Join(did, name) 825 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 826 - // err := os.RemoveAll(repoPath) 827 - // if err != nil { 828 - // l.Error("removing repo", "error", err.Error()) 829 - // writeError(w, err.Error(), http.StatusInternalServerError) 830 - // return 831 - // } 832 - // 833 - // w.WriteHeader(http.StatusNoContent) 834 - // 835 - // } 836 - 837 - // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 838 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 839 - // 840 - // data := types.MergeRequest{} 841 - // 842 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 843 - // writeError(w, err.Error(), http.StatusBadRequest) 844 - // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 845 - // return 846 - // } 847 - // 848 - // mo := &git.MergeOptions{ 849 - // AuthorName: data.AuthorName, 850 - // AuthorEmail: data.AuthorEmail, 851 - // CommitBody: data.CommitBody, 852 - // CommitMessage: data.CommitMessage, 853 - // } 854 - // 855 - // patch := data.Patch 856 - // branch := data.Branch 857 - // gr, err := git.Open(path, branch) 858 - // if err != nil { 859 - // notFound(w) 860 - // return 861 - // } 862 - // 863 - // mo.FormatPatch = patchutil.IsFormatPatch(patch) 864 - // 865 - // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 866 - // var mergeErr *git.ErrMerge 867 - // if errors.As(err, &mergeErr) { 868 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 869 - // for i, conflict := range mergeErr.Conflicts { 870 - // conflicts[i] = types.ConflictInfo{ 871 - // Filename: conflict.Filename, 872 - // Reason: conflict.Reason, 873 - // } 874 - // } 875 - // response := types.MergeCheckResponse{ 876 - // IsConflicted: true, 877 - // Conflicts: conflicts, 878 - // Message: mergeErr.Message, 879 - // } 880 - // writeConflict(w, response) 881 - // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 882 - // } else { 883 - // writeError(w, err.Error(), http.StatusBadRequest) 884 - // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 885 - // } 886 - // return 887 - // } 888 - // 889 - // w.WriteHeader(http.StatusOK) 890 - // } 891 - 892 - // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 893 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 894 - // 895 - // var data struct { 896 - // Patch string `json:"patch"` 897 - // Branch string `json:"branch"` 898 - // } 899 - // 900 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 901 - // writeError(w, err.Error(), http.StatusBadRequest) 902 - // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 903 - // return 904 - // } 905 - // 906 - // patch := data.Patch 907 - // branch := data.Branch 908 - // gr, err := git.Open(path, branch) 909 - // if err != nil { 910 - // notFound(w) 911 - // return 912 - // } 913 - // 914 - // err = gr.MergeCheck([]byte(patch), branch) 915 - // if err == nil { 916 - // response := types.MergeCheckResponse{ 917 - // IsConflicted: false, 918 - // } 919 - // writeJSON(w, response) 920 - // return 921 - // } 922 - // 923 - // var mergeErr *git.ErrMerge 924 - // if errors.As(err, &mergeErr) { 925 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 926 - // for i, conflict := range mergeErr.Conflicts { 927 - // conflicts[i] = types.ConflictInfo{ 928 - // Filename: conflict.Filename, 929 - // Reason: conflict.Reason, 930 - // } 931 - // } 932 - // response := types.MergeCheckResponse{ 933 - // IsConflicted: true, 934 - // Conflicts: conflicts, 935 - // Message: mergeErr.Message, 936 - // } 937 - // writeConflict(w, response) 938 - // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 939 - // return 940 - // } 941 - // writeError(w, err.Error(), http.StatusInternalServerError) 942 - // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 943 - // } 944 - 945 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 946 - rev1 := chi.URLParam(r, "rev1") 947 - rev1, _ = url.PathUnescape(rev1) 948 - 949 - rev2 := chi.URLParam(r, "rev2") 950 - rev2, _ = url.PathUnescape(rev2) 951 - 952 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 953 - 954 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 955 - gr, err := git.PlainOpen(path) 956 - if err != nil { 957 - notFound(w) 958 - return 959 - } 960 - 961 - commit1, err := gr.ResolveRevision(rev1) 962 - if err != nil { 963 - l.Error("error resolving revision 1", "msg", err.Error()) 964 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 965 - return 966 - } 967 - 968 - commit2, err := gr.ResolveRevision(rev2) 969 - if err != nil { 970 - l.Error("error resolving revision 2", "msg", err.Error()) 971 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 972 - return 973 - } 974 - 975 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 976 - if err != nil { 977 - l.Error("error comparing revisions", "msg", err.Error()) 978 - writeError(w, "error comparing revisions", http.StatusBadRequest) 979 - return 980 - } 981 - 982 - writeJSON(w, types.RepoFormatPatchResponse{ 983 - Rev1: commit1.Hash.String(), 984 - Rev2: commit2.Hash.String(), 985 - FormatPatch: formatPatch, 986 - Patch: rawPatch, 987 - }) 988 - } 989 - 990 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 991 - l := h.l.With("handler", "DefaultBranch") 992 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 993 - 994 - gr, err := git.Open(path, "") 995 - if err != nil { 996 - notFound(w) 997 - return 998 - } 999 - 1000 - branch, err := gr.FindMainBranch() 1001 - if err != nil { 1002 - writeError(w, err.Error(), http.StatusInternalServerError) 1003 - l.Error("getting default branch", "error", err.Error()) 1004 - return 1005 - } 1006 - 1007 - writeJSON(w, types.RepoDefaultBranchResponse{ 1008 - Branch: branch, 1009 - }) 1010 - }
+116
knotserver/routes.go
··· 3 3 import ( 4 4 "compress/gzip" 5 5 "context" 6 + "crypto/hmac" 6 7 "crypto/sha256" 8 + "encoding/hex" 7 9 "encoding/json" 8 10 "errors" 9 11 "fmt" ··· 20 22 securejoin "github.com/cyphar/filepath-securejoin" 21 23 "github.com/gliderlabs/ssh" 22 24 "github.com/go-chi/chi/v5" 25 + gogit "github.com/go-git/go-git/v5" 23 26 "github.com/go-git/go-git/v5/plumbing" 24 27 "github.com/go-git/go-git/v5/plumbing/object" 25 28 "tangled.sh/tangled.sh/core/hook" ··· 650 653 } 651 654 } 652 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 + } 673 + 674 + did := data.Did 675 + name := data.Name 676 + defaultBranch := data.DefaultBranch 677 + 678 + if err := validateRepoName(name); err != nil { 679 + l.Error("creating repo", "error", err.Error()) 680 + writeError(w, err.Error(), http.StatusBadRequest) 681 + return 682 + } 683 + 684 + relativeRepoPath := filepath.Join(did, name) 685 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 686 + err := git.InitBare(repoPath, defaultBranch) 687 + if err != nil { 688 + l.Error("initializing bare repo", "error", err.Error()) 689 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 690 + writeError(w, "That repo already exists!", http.StatusConflict) 691 + return 692 + } else { 693 + writeError(w, err.Error(), http.StatusInternalServerError) 694 + return 695 + } 696 + } 697 + 698 + // add perms for this user to access the repo 699 + err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 700 + if err != nil { 701 + l.Error("adding repo permissions", "error", err.Error()) 702 + writeError(w, err.Error(), http.StatusInternalServerError) 703 + return 704 + } 705 + 706 + hook.SetupRepo( 707 + hook.Config( 708 + hook.WithScanPath(h.c.Repo.ScanPath), 709 + hook.WithInternalApi(h.c.Server.InternalListenAddr), 710 + ), 711 + repoPath, 712 + ) 713 + 714 + w.WriteHeader(http.StatusNoContent) 715 + } 716 + 653 717 func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 654 718 l := h.l.With("handler", "RepoForkAheadBehind") 655 719 ··· 1204 1268 l.Error("setting default branch", "error", err.Error()) 1205 1269 return 1206 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))) 1207 1323 1208 1324 w.WriteHeader(http.StatusNoContent) 1209 1325 }
-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 - }
-82
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 - // "github.com/bluesky-social/indigo/atproto/syntax" 11 - // securejoin "github.com/cyphar/filepath-securejoin" 12 - // "tangled.sh/tangled.sh/core/api/tangled" 13 - // "tangled.sh/tangled.sh/core/rbac" 14 - // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 - // ) 16 - 17 - // func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 18 - // l := x.Logger.With("handler", "DeleteRepo") 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 - // isMember, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer) 31 - // if err != nil { 32 - // fail(xrpcerr.GenericError(err)) 33 - // return 34 - // } 35 - // if !isMember { 36 - // fail(xrpcerr.AccessControlError(actorDid.String())) 37 - // return 38 - // } 39 - // 40 - // var data tangled.RepoDelete_Input 41 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 42 - // fail(xrpcerr.GenericError(err)) 43 - // return 44 - // } 45 - // 46 - // did := data.Did 47 - // name := data.Name 48 - // 49 - // if did == "" || name == "" { 50 - // fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 51 - // return 52 - // } 53 - // 54 - // relativeRepoPath := filepath.Join(did, name) 55 - // if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 56 - // l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 57 - // writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 58 - // return 59 - // } 60 - // 61 - // repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 62 - // if err != nil { 63 - // fail(xrpcerr.GenericError(err)) 64 - // return 65 - // } 66 - // 67 - // err = os.RemoveAll(repoPath) 68 - // if err != nil { 69 - // l.Error("deleting repo", "error", err.Error()) 70 - // writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 71 - // return 72 - // } 73 - // 74 - // err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 75 - // if err != nil { 76 - // l.Error("failed to delete repo from enforcer", "error", err.Error()) 77 - // writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 78 - // return 79 - // } 80 - // 81 - // w.WriteHeader(http.StatusOK) 82 - // }
-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 - }
+116 -23
knotserver/xrpc/router.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 6 + "fmt" 5 7 "log/slog" 6 8 "net/http" 9 + "strings" 7 10 8 11 "tangled.sh/tangled.sh/core/api/tangled" 9 12 "tangled.sh/tangled.sh/core/idresolver" ··· 12 15 "tangled.sh/tangled.sh/core/knotserver/db" 13 16 "tangled.sh/tangled.sh/core/notifier" 14 17 "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 19 + "github.com/bluesky-social/indigo/atproto/auth" 18 20 "github.com/go-chi/chi/v5" 19 21 ) 20 22 21 23 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 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 30 31 } 31 32 32 33 func (x *Xrpc) Router() http.Handler { 33 34 r := chi.NewRouter() 34 - r.Group(func(r chi.Router) { 35 - r.Use(x.ServiceAuth.VerifyServiceAuth) 36 35 37 - r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 38 - r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 39 - r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 40 - r.Post("/"+tangled.RepoForkNSID, x.ForkRepo) 41 - r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 42 - r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 36 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 43 37 44 - r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 38 + return r 39 + } 45 40 46 - r.Post("/"+tangled.RepoMergeNSID, x.Merge) 47 - r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 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) 48 65 }) 49 - return r 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 + ) 50 126 } 51 127 52 - func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 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) { 53 146 w.Header().Set("Content-Type", "application/json") 54 147 w.WriteHeader(status) 55 148 json.NewEncoder(w).Encode(e)
+10 -12
knotserver/xrpc/set_default_branch.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/knotserver/git" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 - 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 15 ) 18 16 19 17 const ActorDid string = "ActorDid" 20 18 21 19 func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 22 20 l := x.Logger 23 - fail := func(e xrpcerr.XrpcError) { 21 + fail := func(e XrpcError) { 24 22 l.Error("failed", "kind", e.Tag, "error", e.Message) 25 23 writeError(w, e, http.StatusBadRequest) 26 24 } 27 25 28 26 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 27 if !ok { 30 - fail(xrpcerr.MissingActorDidError) 28 + fail(MissingActorDidError) 31 29 return 32 30 } 33 31 34 32 var data tangled.RepoSetDefaultBranch_Input 35 33 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 36 - fail(xrpcerr.GenericError(err)) 34 + fail(GenericError(err)) 37 35 return 38 36 } 39 37 40 38 // unfortunately we have to resolve repo-at here 41 39 repoAt, err := syntax.ParseATURI(data.Repo) 42 40 if err != nil { 43 - fail(xrpcerr.InvalidRepoError(data.Repo)) 41 + fail(InvalidRepoError(data.Repo)) 44 42 return 45 43 } 46 44 47 45 // resolve this aturi to extract the repo record 48 46 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 49 47 if err != nil || ident.Handle.IsInvalidHandle() { 50 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 51 49 return 52 50 } 53 51 54 52 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 55 53 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 56 54 if err != nil { 57 - fail(xrpcerr.GenericError(err)) 55 + fail(GenericError(err)) 58 56 return 59 57 } 60 58 61 59 repo := resp.Value.Val.(*tangled.Repo) 62 60 didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 63 61 if err != nil { 64 - fail(xrpcerr.GenericError(err)) 62 + fail(GenericError(err)) 65 63 return 66 64 } 67 65 68 66 if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 69 67 l.Error("insufficent permissions", "did", actorDid.String()) 70 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 71 69 return 72 70 } 73 71 74 72 path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 75 73 gr, err := git.PlainOpen(path) 76 74 if err != nil { 77 - fail(xrpcerr.GenericError(err)) 75 + fail(InvalidRepoError(data.Repo)) 78 76 return 79 77 } 80 78 81 79 err = gr.SetDefaultBranch(data.DefaultBranch) 82 80 if err != nil { 83 81 l.Error("setting default branch", "error", err.Error()) 84 - writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 82 + writeError(w, GitError(err), http.StatusInternalServerError) 85 83 return 86 84 } 87 85
+1 -8
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "issue", 14 - "body", 15 - "createdAt" 16 - ], 12 + "required": ["issue", "body", "createdAt"], 17 13 "properties": { 18 14 "issue": { 19 15 "type": "string", ··· 22 18 "repo": { 23 19 "type": "string", 24 20 "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 21 }, 29 22 "owner": { 30 23 "type": "string",
+1 -10
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "owner", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 17 }, 27 18 "owner": { 28 19 "type": "string",
-24
lexicons/knot/knot.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.knot", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - }
-33
lexicons/repo/create.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.create", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Create a new repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "rkey" 14 - ], 15 - "properties": { 16 - "rkey": { 17 - "type": "string", 18 - "description": "Rkey of the repository record" 19 - }, 20 - "defaultBranch": { 21 - "type": "string", 22 - "description": "Default branch to push to" 23 - }, 24 - "source": { 25 - "type": "string", 26 - "description": "A source URL to clone from, populate this when forking or importing a repository." 27 - } 28 - } 29 - } 30 - } 31 - } 32 - } 33 - }
-28
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"], 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 - } 24 - } 25 - } 26 - } 27 - } 28 - }
-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 - }
-3
spindle/engines/nixery/engine.go
··· 201 201 Tty: false, 202 202 Hostname: "spindle", 203 203 WorkingDir: workspaceDir, 204 - Labels: map[string]string{ 205 - "sh.tangled.pipeline/workflow_id": wid.String(), 206 - }, 207 204 // TODO(winter): investigate whether environment variables passed here 208 205 // get propagated to ContainerExec processes 209 206 }, &container.HostConfig{
+7 -11
spindle/server.go
··· 25 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 26 "tangled.sh/tangled.sh/core/spindle/secrets" 27 27 "tangled.sh/tangled.sh/core/spindle/xrpc" 28 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 29 28 ) 30 29 31 30 //go:embed motd ··· 214 213 func (s *Spindle) XrpcRouter() http.Handler { 215 214 logger := s.l.With("route", "xrpc") 216 215 217 - serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 218 - 219 216 x := xrpc.Xrpc{ 220 - Logger: logger, 221 - Db: s.db, 222 - Enforcer: s.e, 223 - Engines: s.engs, 224 - Config: s.cfg, 225 - Resolver: s.res, 226 - Vault: s.vault, 227 - ServiceAuth: serviceAuth, 217 + Logger: logger, 218 + Db: s.db, 219 + Enforcer: s.e, 220 + Engines: s.engs, 221 + Config: s.cfg, 222 + Resolver: s.res, 223 + Vault: s.vault, 228 224 } 229 225 230 226 return x.Router()
+10 -11
spindle/xrpc/add_secret.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 16 ) 18 17 19 18 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 19 l := x.Logger 21 - fail := func(e xrpcerr.XrpcError) { 20 + fail := func(e XrpcError) { 22 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 22 writeError(w, e, http.StatusBadRequest) 24 23 } 25 24 26 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 26 if !ok { 28 - fail(xrpcerr.MissingActorDidError) 27 + fail(MissingActorDidError) 29 28 return 30 29 } 31 30 32 31 var data tangled.RepoAddSecret_Input 33 32 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 - fail(xrpcerr.GenericError(err)) 33 + fail(GenericError(err)) 35 34 return 36 35 } 37 36 38 37 if err := secrets.ValidateKey(data.Key); err != nil { 39 - fail(xrpcerr.GenericError(err)) 38 + fail(GenericError(err)) 40 39 return 41 40 } 42 41 43 42 // unfortunately we have to resolve repo-at here 44 43 repoAt, err := syntax.ParseATURI(data.Repo) 45 44 if err != nil { 46 - fail(xrpcerr.InvalidRepoError(data.Repo)) 45 + fail(InvalidRepoError(data.Repo)) 47 46 return 48 47 } 49 48 50 49 // resolve this aturi to extract the repo record 51 50 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 51 if err != nil || ident.Handle.IsInvalidHandle() { 53 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 52 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 53 return 55 54 } 56 55 57 56 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 57 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 58 if err != nil { 60 - fail(xrpcerr.GenericError(err)) 59 + fail(GenericError(err)) 61 60 return 62 61 } 63 62 64 63 repo := resp.Value.Val.(*tangled.Repo) 65 64 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 65 if err != nil { 67 - fail(xrpcerr.GenericError(err)) 66 + fail(GenericError(err)) 68 67 return 69 68 } 70 69 71 70 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 71 l.Error("insufficent permissions", "did", actorDid.String()) 73 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 72 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 73 return 75 74 } 76 75 ··· 84 83 err = x.Vault.AddSecret(r.Context(), secret) 85 84 if err != nil { 86 85 l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 86 + writeError(w, GenericError(err), http.StatusInternalServerError) 88 87 return 89 88 } 90 89
+9 -10
spindle/xrpc/list_secrets.go
··· 13 13 "tangled.sh/tangled.sh/core/api/tangled" 14 14 "tangled.sh/tangled.sh/core/rbac" 15 15 "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 16 ) 18 17 19 18 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 19 l := x.Logger 21 - fail := func(e xrpcerr.XrpcError) { 20 + fail := func(e XrpcError) { 22 21 l.Error("failed", "kind", e.Tag, "error", e.Message) 23 22 writeError(w, e, http.StatusBadRequest) 24 23 } 25 24 26 25 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 26 if !ok { 28 - fail(xrpcerr.MissingActorDidError) 27 + fail(MissingActorDidError) 29 28 return 30 29 } 31 30 32 31 repoParam := r.URL.Query().Get("repo") 33 32 if repoParam == "" { 34 - fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 33 + fail(GenericError(fmt.Errorf("empty params"))) 35 34 return 36 35 } 37 36 38 37 // unfortunately we have to resolve repo-at here 39 38 repoAt, err := syntax.ParseATURI(repoParam) 40 39 if err != nil { 41 - fail(xrpcerr.InvalidRepoError(repoParam)) 40 + fail(InvalidRepoError(repoParam)) 42 41 return 43 42 } 44 43 45 44 // resolve this aturi to extract the repo record 46 45 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 46 if err != nil || ident.Handle.IsInvalidHandle() { 48 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 47 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 48 return 50 49 } 51 50 52 51 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 52 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 53 if err != nil { 55 - fail(xrpcerr.GenericError(err)) 54 + fail(GenericError(err)) 56 55 return 57 56 } 58 57 59 58 repo := resp.Value.Val.(*tangled.Repo) 60 59 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 60 if err != nil { 62 - fail(xrpcerr.GenericError(err)) 61 + fail(GenericError(err)) 63 62 return 64 63 } 65 64 66 65 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 66 l.Error("insufficent permissions", "did", actorDid.String()) 68 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 67 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 68 return 70 69 } 71 70 72 71 ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 72 if err != nil { 74 73 l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 74 + writeError(w, GenericError(err), http.StatusInternalServerError) 76 75 return 77 76 } 78 77
+9 -10
spindle/xrpc/remove_secret.go
··· 12 12 "tangled.sh/tangled.sh/core/api/tangled" 13 13 "tangled.sh/tangled.sh/core/rbac" 14 14 "tangled.sh/tangled.sh/core/spindle/secrets" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 15 ) 17 16 18 17 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 18 l := x.Logger 20 - fail := func(e xrpcerr.XrpcError) { 19 + fail := func(e XrpcError) { 21 20 l.Error("failed", "kind", e.Tag, "error", e.Message) 22 21 writeError(w, e, http.StatusBadRequest) 23 22 } 24 23 25 24 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 25 if !ok { 27 - fail(xrpcerr.MissingActorDidError) 26 + fail(MissingActorDidError) 28 27 return 29 28 } 30 29 31 30 var data tangled.RepoRemoveSecret_Input 32 31 if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 - fail(xrpcerr.GenericError(err)) 32 + fail(GenericError(err)) 34 33 return 35 34 } 36 35 37 36 // unfortunately we have to resolve repo-at here 38 37 repoAt, err := syntax.ParseATURI(data.Repo) 39 38 if err != nil { 40 - fail(xrpcerr.InvalidRepoError(data.Repo)) 39 + fail(InvalidRepoError(data.Repo)) 41 40 return 42 41 } 43 42 44 43 // resolve this aturi to extract the repo record 45 44 ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 45 if err != nil || ident.Handle.IsInvalidHandle() { 47 - fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 46 + fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 47 return 49 48 } 50 49 51 50 xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 51 resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 52 if err != nil { 54 - fail(xrpcerr.GenericError(err)) 53 + fail(GenericError(err)) 55 54 return 56 55 } 57 56 58 57 repo := resp.Value.Val.(*tangled.Repo) 59 58 didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 59 if err != nil { 61 - fail(xrpcerr.GenericError(err)) 60 + fail(GenericError(err)) 62 61 return 63 62 } 64 63 65 64 if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 65 l.Error("insufficent permissions", "did", actorDid.String()) 67 - writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 66 + writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 67 return 69 68 } 70 69 ··· 75 74 err = x.Vault.RemoveSecret(r.Context(), secret) 76 75 if err != nil { 77 76 l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 - writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 77 + writeError(w, GenericError(err), http.StatusInternalServerError) 79 78 return 80 79 } 81 80
+109 -14
spindle/xrpc/xrpc.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "context" 4 5 _ "embed" 5 6 "encoding/json" 7 + "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "strings" 8 11 12 + "github.com/bluesky-social/indigo/atproto/auth" 9 13 "github.com/go-chi/chi/v5" 10 14 11 15 "tangled.sh/tangled.sh/core/api/tangled" ··· 15 19 "tangled.sh/tangled.sh/core/spindle/db" 16 20 "tangled.sh/tangled.sh/core/spindle/models" 17 21 "tangled.sh/tangled.sh/core/spindle/secrets" 18 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 22 ) 21 23 22 24 const ActorDid string = "ActorDid" 23 25 24 26 type Xrpc struct { 25 - Logger *slog.Logger 26 - Db *db.DB 27 - Enforcer *rbac.Enforcer 28 - Engines map[string]models.Engine 29 - Config *config.Config 30 - Resolver *idresolver.Resolver 31 - Vault secrets.Manager 32 - ServiceAuth *serviceauth.ServiceAuth 27 + Logger *slog.Logger 28 + Db *db.DB 29 + Enforcer *rbac.Enforcer 30 + Engines map[string]models.Engine 31 + Config *config.Config 32 + Resolver *idresolver.Resolver 33 + Vault secrets.Manager 33 34 } 34 35 35 36 func (x *Xrpc) Router() http.Handler { 36 37 r := chi.NewRouter() 37 38 38 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 39 - r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 40 - r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 39 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 40 + r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 41 + r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 41 42 42 43 return r 43 44 } 44 45 46 + func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler { 47 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 + l := x.Logger.With("url", r.URL) 49 + 50 + token := r.Header.Get("Authorization") 51 + token = strings.TrimPrefix(token, "Bearer ") 52 + 53 + s := auth.ServiceAuthValidator{ 54 + Audience: x.Config.Server.Did().String(), 55 + Dir: x.Resolver.Directory(), 56 + } 57 + 58 + did, err := s.Validate(r.Context(), token, nil) 59 + if err != nil { 60 + l.Error("signature verification failed", "err", err) 61 + writeError(w, AuthError(err), http.StatusForbidden) 62 + return 63 + } 64 + 65 + r = r.WithContext( 66 + context.WithValue(r.Context(), ActorDid, did), 67 + ) 68 + 69 + next.ServeHTTP(w, r) 70 + }) 71 + } 72 + 73 + type XrpcError struct { 74 + Tag string `json:"error"` 75 + Message string `json:"message"` 76 + } 77 + 78 + func NewXrpcError(opts ...ErrOpt) XrpcError { 79 + x := XrpcError{} 80 + for _, o := range opts { 81 + o(&x) 82 + } 83 + 84 + return x 85 + } 86 + 87 + type ErrOpt = func(xerr *XrpcError) 88 + 89 + func WithTag(tag string) ErrOpt { 90 + return func(xerr *XrpcError) { 91 + xerr.Tag = tag 92 + } 93 + } 94 + 95 + func WithMessage[S ~string](s S) ErrOpt { 96 + return func(xerr *XrpcError) { 97 + xerr.Message = string(s) 98 + } 99 + } 100 + 101 + func WithError(e error) ErrOpt { 102 + return func(xerr *XrpcError) { 103 + xerr.Message = e.Error() 104 + } 105 + } 106 + 107 + var MissingActorDidError = NewXrpcError( 108 + WithTag("MissingActorDid"), 109 + WithMessage("actor DID not supplied"), 110 + ) 111 + 112 + var AuthError = func(err error) XrpcError { 113 + return NewXrpcError( 114 + WithTag("Auth"), 115 + WithError(fmt.Errorf("signature verification failed: %w", err)), 116 + ) 117 + } 118 + 119 + var InvalidRepoError = func(r string) XrpcError { 120 + return NewXrpcError( 121 + WithTag("InvalidRepo"), 122 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 123 + ) 124 + } 125 + 126 + func GenericError(err error) XrpcError { 127 + return NewXrpcError( 128 + WithTag("Generic"), 129 + WithError(err), 130 + ) 131 + } 132 + 133 + var AccessControlError = func(d string) XrpcError { 134 + return NewXrpcError( 135 + WithTag("AccessControl"), 136 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 137 + ) 138 + } 139 + 45 140 // this is slightly different from http_util::write_error to follow the spec: 46 141 // 47 142 // the json object returned must include an "error" and a "message" 48 - func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 143 + func writeError(w http.ResponseWriter, e XrpcError, status int) { 49 144 w.Header().Set("Content-Type", "application/json") 50 145 w.WriteHeader(status) 51 146 json.NewEncoder(w).Encode(e)
-103
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 - func GenericError(err error) XrpcError { 90 - return NewXrpcError( 91 - WithTag("Generic"), 92 - WithError(err), 93 - ) 94 - } 95 - 96 - func Unmarshal(errStr string) (XrpcError, error) { 97 - var xerr XrpcError 98 - err := json.Unmarshal([]byte(errStr), &xerr) 99 - if err != nil { 100 - return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 101 - } 102 - return xerr, nil 103 - }
-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 - }