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

Compare changes

Choose any two refs to compare.

Changed files
+8263 -4975
api
appview
db
issues
knots
middleware
pages
posthog
pulls
repo
serververify
spindles
state
strings
validator
xrpcclient
cmd
appview
docs
knotclient
knotserver
legal
lexicons
nix
patchutil
spindle
xrpc
errors
+16 -73
api/tangled/cbor_gen.go
··· 5898 5898 } 5899 5899 5900 5900 cw := cbg.NewCborWriter(w) 5901 - fieldCount := 6 5902 - 5903 - if t.Owner == nil { 5904 - fieldCount-- 5905 - } 5901 + fieldCount := 5 5906 5902 5907 - if t.Repo == nil { 5903 + if t.ReplyTo == nil { 5908 5904 fieldCount-- 5909 5905 } 5910 5906 ··· 5935 5931 return err 5936 5932 } 5937 5933 5938 - // t.Repo (string) (string) 5939 - if t.Repo != nil { 5940 - 5941 - if len("repo") > 1000000 { 5942 - return xerrors.Errorf("Value in field \"repo\" was too long") 5943 - } 5944 - 5945 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5946 - return err 5947 - } 5948 - if _, err := cw.WriteString(string("repo")); err != nil { 5949 - return err 5950 - } 5951 - 5952 - if t.Repo == nil { 5953 - if _, err := cw.Write(cbg.CborNull); err != nil { 5954 - return err 5955 - } 5956 - } else { 5957 - if len(*t.Repo) > 1000000 { 5958 - return xerrors.Errorf("Value in field t.Repo was too long") 5959 - } 5960 - 5961 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 5962 - return err 5963 - } 5964 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 5965 - return err 5966 - } 5967 - } 5968 - } 5969 - 5970 5934 // t.LexiconTypeID (string) (string) 5971 5935 if len("$type") > 1000000 { 5972 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6009 5973 return err 6010 5974 } 6011 5975 6012 - // t.Owner (string) (string) 6013 - if t.Owner != nil { 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 6014 5978 6015 - if len("owner") > 1000000 { 6016 - return xerrors.Errorf("Value in field \"owner\" was too long") 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 6017 5981 } 6018 5982 6019 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 6020 5984 return err 6021 5985 } 6022 - if _, err := cw.WriteString(string("owner")); err != nil { 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 6023 5987 return err 6024 5988 } 6025 5989 6026 - if t.Owner == nil { 5990 + if t.ReplyTo == nil { 6027 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 6028 5992 return err 6029 5993 } 6030 5994 } else { 6031 - if len(*t.Owner) > 1000000 { 6032 - return xerrors.Errorf("Value in field t.Owner was too long") 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 6033 5997 } 6034 5998 6035 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6036 6000 return err 6037 6001 } 6038 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6039 6003 return err 6040 6004 } 6041 6005 } ··· 6118 6082 6119 6083 t.Body = string(sval) 6120 6084 } 6121 - // t.Repo (string) (string) 6122 - case "repo": 6123 - 6124 - { 6125 - b, err := cr.ReadByte() 6126 - if err != nil { 6127 - return err 6128 - } 6129 - if b != cbg.CborNull[0] { 6130 - if err := cr.UnreadByte(); err != nil { 6131 - return err 6132 - } 6133 - 6134 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6135 - if err != nil { 6136 - return err 6137 - } 6138 - 6139 - t.Repo = (*string)(&sval) 6140 - } 6141 - } 6142 6085 // t.LexiconTypeID (string) (string) 6143 6086 case "$type": 6144 6087 ··· 6161 6104 6162 6105 t.Issue = string(sval) 6163 6106 } 6164 - // t.Owner (string) (string) 6165 - case "owner": 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6166 6109 6167 6110 { 6168 6111 b, err := cr.ReadByte() ··· 6179 6122 return err 6180 6123 } 6181 6124 6182 - t.Owner = (*string)(&sval) 6125 + t.ReplyTo = (*string)(&sval) 6183 6126 } 6184 6127 } 6185 6128 // t.CreatedAt (string) (string)
+1 -2
api/tangled/issuecomment.go
··· 21 21 Body string `json:"body" cborgen:"body"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 23 Issue string `json:"issue" cborgen:"issue"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 25 }
+53
api/tangled/knotlistKeys.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.listKeys 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotListKeysNSID = "sh.tangled.knot.listKeys" 15 + ) 16 + 17 + // KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call. 18 + type KnotListKeys_Output struct { 19 + // cursor: Pagination cursor for next page 20 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 21 + Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"` 22 + } 23 + 24 + // KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema. 25 + type KnotListKeys_PublicKey struct { 26 + // createdAt: Key upload timestamp 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // did: DID associated with the public key 29 + Did string `json:"did" cborgen:"did"` 30 + // key: Public key contents 31 + Key string `json:"key" cborgen:"key"` 32 + } 33 + 34 + // KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys". 35 + // 36 + // cursor: Pagination cursor 37 + // limit: Maximum number of keys to return 38 + func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) { 39 + var out KnotListKeys_Output 40 + 41 + params := map[string]interface{}{} 42 + if cursor != "" { 43 + params["cursor"] = cursor 44 + } 45 + if limit != 0 { 46 + params["limit"] = limit 47 + } 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+30
api/tangled/knotversion.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.version 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotVersionNSID = "sh.tangled.knot.version" 15 + ) 16 + 17 + // KnotVersion_Output is the output of a sh.tangled.knot.version call. 18 + type KnotVersion_Output struct { 19 + Version string `json:"version" cborgen:"version"` 20 + } 21 + 22 + // KnotVersion calls the XRPC method "sh.tangled.knot.version". 23 + func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) { 24 + var out KnotVersion_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+41
api/tangled/repoarchive.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.archive 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoArchiveNSID = "sh.tangled.repo.archive" 16 + ) 17 + 18 + // RepoArchive calls the XRPC method "sh.tangled.repo.archive". 19 + // 20 + // format: Archive format 21 + // prefix: Prefix for files in the archive 22 + // ref: Git reference (branch, tag, or commit SHA) 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 + buf := new(bytes.Buffer) 26 + 27 + params := map[string]interface{}{} 28 + if format != "" { 29 + params["format"] = format 30 + } 31 + if prefix != "" { 32 + params["prefix"] = prefix 33 + } 34 + params["ref"] = ref 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil { 37 + return nil, err 38 + } 39 + 40 + return buf.Bytes(), nil 41 + }
+80
api/tangled/repoblob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.blob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBlobNSID = "sh.tangled.repo.blob" 15 + ) 16 + 17 + // RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema. 18 + type RepoBlob_LastCommit struct { 19 + Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Commit hash 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Commit message 23 + Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 + // when: Commit timestamp 27 + When string `json:"when" cborgen:"when"` 28 + } 29 + 30 + // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 + type RepoBlob_Output struct { 32 + // content: File content (base64 encoded for binary files) 33 + Content string `json:"content" cborgen:"content"` 34 + // encoding: Content encoding 35 + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 + // isBinary: Whether the file is binary 37 + IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` 38 + LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 39 + // mimeType: MIME type of the file 40 + MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"` 41 + // path: The file path 42 + Path string `json:"path" cborgen:"path"` 43 + // ref: The git reference used 44 + Ref string `json:"ref" cborgen:"ref"` 45 + // size: File size in bytes 46 + Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + } 48 + 49 + // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. 50 + type RepoBlob_Signature struct { 51 + // email: Author email 52 + Email string `json:"email" cborgen:"email"` 53 + // name: Author name 54 + Name string `json:"name" cborgen:"name"` 55 + // when: Author timestamp 56 + When string `json:"when" cborgen:"when"` 57 + } 58 + 59 + // RepoBlob calls the XRPC method "sh.tangled.repo.blob". 60 + // 61 + // path: Path to the file within the repository 62 + // raw: Return raw file content instead of JSON response 63 + // ref: Git reference (branch, tag, or commit SHA) 64 + // repo: Repository identifier in format 'did:plc:.../repoName' 65 + func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) { 66 + var out RepoBlob_Output 67 + 68 + params := map[string]interface{}{} 69 + params["path"] = path 70 + if raw { 71 + params["raw"] = raw 72 + } 73 + params["ref"] = ref 74 + params["repo"] = repo 75 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+59
api/tangled/repobranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBranchNSID = "sh.tangled.repo.branch" 15 + ) 16 + 17 + // RepoBranch_Output is the output of a sh.tangled.repo.branch call. 18 + type RepoBranch_Output struct { 19 + Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on this branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // isDefault: Whether this is the default branch 23 + IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"` 24 + // message: Latest commit message 25 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 26 + // name: Branch name 27 + Name string `json:"name" cborgen:"name"` 28 + // shortHash: Short commit hash 29 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 30 + // when: Timestamp of latest commit 31 + When string `json:"when" cborgen:"when"` 32 + } 33 + 34 + // RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema. 35 + type RepoBranch_Signature struct { 36 + // email: Author email 37 + Email string `json:"email" cborgen:"email"` 38 + // name: Author name 39 + Name string `json:"name" cborgen:"name"` 40 + // when: Author timestamp 41 + When string `json:"when" cborgen:"when"` 42 + } 43 + 44 + // RepoBranch calls the XRPC method "sh.tangled.repo.branch". 45 + // 46 + // name: Branch name to get information for 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) { 49 + var out RepoBranch_Output 50 + 51 + params := map[string]interface{}{} 52 + params["name"] = name 53 + params["repo"] = repo 54 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+39
api/tangled/repobranches.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branches 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoBranchesNSID = "sh.tangled.repo.branches" 16 + ) 17 + 18 + // RepoBranches calls the XRPC method "sh.tangled.repo.branches". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of branches to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+35
api/tangled/repocompare.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.compare 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoCompareNSID = "sh.tangled.repo.compare" 16 + ) 17 + 18 + // RepoCompare calls the XRPC method "sh.tangled.repo.compare". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // rev1: First revision (commit, branch, or tag) 22 + // rev2: Second revision (commit, branch, or tag) 23 + func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + params["repo"] = repo 28 + params["rev1"] = rev1 29 + params["rev2"] = rev2 30 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil { 31 + return nil, err 32 + } 33 + 34 + return buf.Bytes(), nil 35 + }
+33
api/tangled/repodiff.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.diff 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoDiffNSID = "sh.tangled.repo.diff" 16 + ) 17 + 18 + // RepoDiff calls the XRPC method "sh.tangled.repo.diff". 19 + // 20 + // ref: Git reference (branch, tag, or commit SHA) 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["ref"] = ref 27 + params["repo"] = repo 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+55
api/tangled/repogetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch" 15 + ) 16 + 17 + // RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call. 18 + type RepoGetDefaultBranch_Output struct { 19 + Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on default branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Latest commit message 23 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 + // name: Default branch name 25 + Name string `json:"name" cborgen:"name"` 26 + // shortHash: Short commit hash 27 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 28 + // when: Timestamp of latest commit 29 + When string `json:"when" cborgen:"when"` 30 + } 31 + 32 + // RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema. 33 + type RepoGetDefaultBranch_Signature struct { 34 + // email: Author email 35 + Email string `json:"email" cborgen:"email"` 36 + // name: Author name 37 + Name string `json:"name" cborgen:"name"` 38 + // when: Author timestamp 39 + When string `json:"when" cborgen:"when"` 40 + } 41 + 42 + // RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch". 43 + // 44 + // repo: Repository identifier in format 'did:plc:.../repoName' 45 + func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) { 46 + var out RepoGetDefaultBranch_Output 47 + 48 + params := map[string]interface{}{} 49 + params["repo"] = repo 50 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil { 51 + return nil, err 52 + } 53 + 54 + return &out, nil 55 + }
+61
api/tangled/repolanguages.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.languages 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoLanguagesNSID = "sh.tangled.repo.languages" 15 + ) 16 + 17 + // RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema. 18 + type RepoLanguages_Language struct { 19 + // color: Hex color code for this language 20 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 + // extensions: File extensions associated with this language 22 + Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 + // fileCount: Number of files in this language 24 + FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 + // name: Programming language name 26 + Name string `json:"name" cborgen:"name"` 27 + // percentage: Percentage of total codebase (0-100) 28 + Percentage int64 `json:"percentage" cborgen:"percentage"` 29 + // size: Total size of files in this language (bytes) 30 + Size int64 `json:"size" cborgen:"size"` 31 + } 32 + 33 + // RepoLanguages_Output is the output of a sh.tangled.repo.languages call. 34 + type RepoLanguages_Output struct { 35 + Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + // totalFiles: Total number of files analyzed 39 + TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 + // totalSize: Total size of all analyzed files in bytes 41 + TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 + } 43 + 44 + // RepoLanguages calls the XRPC method "sh.tangled.repo.languages". 45 + // 46 + // ref: Git reference (branch, tag, or commit SHA) 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) { 49 + var out RepoLanguages_Output 50 + 51 + params := map[string]interface{}{} 52 + if ref != "" { 53 + params["ref"] = ref 54 + } 55 + params["repo"] = repo 56 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil { 57 + return nil, err 58 + } 59 + 60 + return &out, nil 61 + }
+45
api/tangled/repolog.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.log 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoLogNSID = "sh.tangled.repo.log" 16 + ) 17 + 18 + // RepoLog calls the XRPC method "sh.tangled.repo.log". 19 + // 20 + // cursor: Pagination cursor (commit SHA) 21 + // limit: Maximum number of commits to return 22 + // path: Path to filter commits by 23 + // ref: Git reference (branch, tag, or commit SHA) 24 + // repo: Repository identifier in format 'did:plc:.../repoName' 25 + func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) { 26 + buf := new(bytes.Buffer) 27 + 28 + params := map[string]interface{}{} 29 + if cursor != "" { 30 + params["cursor"] = cursor 31 + } 32 + if limit != 0 { 33 + params["limit"] = limit 34 + } 35 + if path != "" { 36 + params["path"] = path 37 + } 38 + params["ref"] = ref 39 + params["repo"] = repo 40 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil { 41 + return nil, err 42 + } 43 + 44 + return buf.Bytes(), nil 45 + }
+39
api/tangled/repotags.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+72
api/tangled/repotree.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tree 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoTreeNSID = "sh.tangled.repo.tree" 15 + ) 16 + 17 + // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 + type RepoTree_LastCommit struct { 19 + // hash: Commit hash 20 + Hash string `json:"hash" cborgen:"hash"` 21 + // message: Commit message 22 + Message string `json:"message" cborgen:"message"` 23 + // when: Commit timestamp 24 + When string `json:"when" cborgen:"when"` 25 + } 26 + 27 + // RepoTree_Output is the output of a sh.tangled.repo.tree call. 28 + type RepoTree_Output struct { 29 + // dotdot: Parent directory path 30 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 + // parent: The parent path in the tree 33 + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // ref: The git reference used 35 + Ref string `json:"ref" cborgen:"ref"` 36 + } 37 + 38 + // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 39 + type RepoTree_TreeEntry struct { 40 + // is_file: Whether this entry is a file 41 + Is_file bool `json:"is_file" cborgen:"is_file"` 42 + // is_subtree: Whether this entry is a directory/subtree 43 + Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 44 + Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 45 + // mode: File mode 46 + Mode string `json:"mode" cborgen:"mode"` 47 + // name: Relative file or directory name 48 + Name string `json:"name" cborgen:"name"` 49 + // size: File size in bytes 50 + Size int64 `json:"size" cborgen:"size"` 51 + } 52 + 53 + // RepoTree calls the XRPC method "sh.tangled.repo.tree". 54 + // 55 + // path: Path within the repository tree 56 + // ref: Git reference (branch, tag, or commit SHA) 57 + // repo: Repository identifier in format 'did:plc:.../repoName' 58 + func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) { 59 + var out RepoTree_Output 60 + 61 + params := map[string]interface{}{} 62 + if path != "" { 63 + params["path"] = path 64 + } 65 + params["ref"] = ref 66 + params["repo"] = repo 67 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil { 68 + return nil, err 69 + } 70 + 71 + return &out, nil 72 + }
+30
api/tangled/tangledowner.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.owner 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + OwnerNSID = "sh.tangled.owner" 15 + ) 16 + 17 + // Owner_Output is the output of a sh.tangled.owner call. 18 + type Owner_Output struct { 19 + Owner string `json:"owner" cborgen:"owner"` 20 + } 21 + 22 + // Owner calls the XRPC method "sh.tangled.owner". 23 + func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) { 24 + var out Owner_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+169
appview/db/db.go
··· 703 703 return err 704 704 }) 705 705 706 + // repurpose the read-only column to "needs-upgrade" 707 + runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 708 + _, err := tx.Exec(` 709 + alter table registrations rename column read_only to needs_upgrade; 710 + `) 711 + return err 712 + }) 713 + 714 + // require all knots to upgrade after the release of total xrpc 715 + runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 716 + _, err := tx.Exec(` 717 + update registrations set needs_upgrade = 1; 718 + `) 719 + return err 720 + }) 721 + 722 + // require all knots to upgrade after the release of total xrpc 723 + runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 724 + _, err := tx.Exec(` 725 + alter table spindles add column needs_upgrade integer not null default 0; 726 + `) 727 + if err != nil { 728 + return err 729 + } 730 + 731 + _, err = tx.Exec(` 732 + update spindles set needs_upgrade = 1; 733 + `) 734 + return err 735 + }) 736 + 737 + // remove issue_at from issues and replace with generated column 738 + // 739 + // this requires a full table recreation because stored columns 740 + // cannot be added via alter 741 + // 742 + // couple other changes: 743 + // - columns renamed to be more consistent 744 + // - adds edited and deleted fields 745 + // 746 + // disable foreign-keys for the next migration 747 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 748 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 749 + _, err := tx.Exec(` 750 + create table if not exists issues_new ( 751 + -- identifiers 752 + id integer primary key autoincrement, 753 + did text not null, 754 + rkey text not null, 755 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 756 + 757 + -- at identifiers 758 + repo_at text not null, 759 + 760 + -- content 761 + issue_id integer not null, 762 + title text not null, 763 + body text not null, 764 + open integer not null default 1, 765 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 766 + edited text, -- timestamp 767 + deleted text, -- timestamp 768 + 769 + unique(did, rkey), 770 + unique(repo_at, issue_id), 771 + unique(at_uri), 772 + foreign key (repo_at) references repos(at_uri) on delete cascade 773 + ); 774 + `) 775 + if err != nil { 776 + return err 777 + } 778 + 779 + // transfer data 780 + _, err = tx.Exec(` 781 + insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 782 + select 783 + i.id, 784 + i.owner_did, 785 + i.rkey, 786 + i.repo_at, 787 + i.issue_id, 788 + i.title, 789 + i.body, 790 + i.open, 791 + i.created 792 + from issues i; 793 + `) 794 + if err != nil { 795 + return err 796 + } 797 + 798 + // drop old table 799 + _, err = tx.Exec(`drop table issues`) 800 + if err != nil { 801 + return err 802 + } 803 + 804 + // rename new table 805 + _, err = tx.Exec(`alter table issues_new rename to issues`) 806 + return err 807 + }) 808 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 809 + 810 + // - renames the comments table to 'issue_comments' 811 + // - rework issue comments to update constraints: 812 + // * unique(did, rkey) 813 + // * remove comment-id and just use the global ID 814 + // * foreign key (repo_at, issue_id) 815 + // - new columns 816 + // * column "reply_to" which can be any other comment 817 + // * column "at-uri" which is a generated column 818 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 819 + _, err := tx.Exec(` 820 + create table if not exists issue_comments ( 821 + -- identifiers 822 + id integer primary key autoincrement, 823 + did text not null, 824 + rkey text, 825 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 826 + 827 + -- at identifiers 828 + issue_at text not null, 829 + reply_to text, -- at_uri of parent comment 830 + 831 + -- content 832 + body text not null, 833 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 834 + edited text, 835 + deleted text, 836 + 837 + -- constraints 838 + unique(did, rkey), 839 + unique(at_uri), 840 + foreign key (issue_at) references issues(at_uri) on delete cascade 841 + ); 842 + `) 843 + if err != nil { 844 + return err 845 + } 846 + 847 + // transfer data 848 + _, err = tx.Exec(` 849 + insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 850 + select 851 + c.id, 852 + c.owner_did, 853 + c.rkey, 854 + i.at_uri, -- get at_uri from issues table 855 + c.body, 856 + c.created, 857 + c.edited, 858 + c.deleted 859 + from comments c 860 + join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 861 + `) 862 + if err != nil { 863 + return err 864 + } 865 + 866 + // drop old table 867 + _, err = tx.Exec(`drop table comments`) 868 + return err 869 + }) 870 + 706 871 return &DB{db}, nil 707 872 } 708 873 ··· 747 912 } 748 913 749 914 return nil 915 + } 916 + 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 750 919 } 751 920 752 921 type filter struct {
+4 -4
appview/db/follow.go
··· 56 56 } 57 57 58 58 type FollowStats struct { 59 - Followers int 60 - Following int 59 + Followers int64 60 + Following int64 61 61 } 62 62 63 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 - followers, following := 0, 0 64 + var followers, following int64 65 65 err := e.QueryRow( 66 66 `SELECT 67 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 122 123 123 for rows.Next() { 124 124 var did string 125 - var followers, following int 125 + var followers, following int64 126 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 127 return nil, err 128 128 }
+410 -453
appview/db/issues.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 - mathrand "math/rand/v2" 6 + "maps" 7 + "slices" 8 + "sort" 7 9 "strings" 8 10 "time" 9 11 ··· 13 15 ) 14 16 15 17 type Issue struct { 16 - ID int64 17 - RepoAt syntax.ATURI 18 - OwnerDid string 19 - IssueId int 20 - Rkey string 21 - Created time.Time 22 - Title string 23 - Body string 24 - Open bool 18 + Id int64 19 + Did string 20 + Rkey string 21 + RepoAt syntax.ATURI 22 + IssueId int 23 + Created time.Time 24 + Edited *time.Time 25 + Deleted *time.Time 26 + Title string 27 + Body string 28 + Open bool 25 29 26 30 // optionally, populate this when querying for reverse mappings 27 31 // like comment counts, parent repo etc. 28 - Metadata *IssueMetadata 32 + Comments []IssueComment 33 + Repo *Repo 29 34 } 30 35 31 - type IssueMetadata struct { 32 - CommentCount int 33 - Repo *Repo 34 - // labels, assignee etc. 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 35 38 } 36 39 37 - type Comment struct { 38 - OwnerDid string 39 - RepoAt syntax.ATURI 40 - Rkey string 41 - Issue int 42 - CommentId int 43 - Body string 44 - Created *time.Time 45 - Deleted *time.Time 46 - Edited *time.Time 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 47 47 } 48 48 49 - func (i *Issue) AtUri() syntax.ATURI { 50 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 + } 55 + 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 59 + } 60 + 61 + func (i *Issue) CommentList() []CommentListItem { 62 + // Create a map to quickly find comments by their aturi 63 + toplevel := make(map[string]*CommentListItem) 64 + var replies []*IssueComment 65 + 66 + // collect top level comments into the map 67 + for _, comment := range i.Comments { 68 + if comment.IsTopLevel() { 69 + toplevel[comment.AtUri().String()] = &CommentListItem{ 70 + Self: &comment, 71 + } 72 + } else { 73 + replies = append(replies, &comment) 74 + } 75 + } 76 + 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 82 + } 83 + 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 + } 88 + 89 + // sort everything 90 + sortFunc := func(a, b *IssueComment) bool { 91 + return a.Created.Before(b.Created) 92 + } 93 + sort.Slice(listing, func(i, j int) bool { 94 + return sortFunc(listing[i].Self, listing[j].Self) 95 + }) 96 + for _, r := range listing { 97 + sort.Slice(r.Replies, func(i, j int) bool { 98 + return sortFunc(r.Replies[i], r.Replies[j]) 99 + }) 100 + } 101 + 102 + return listing 51 103 } 52 104 53 105 func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { ··· 62 114 } 63 115 64 116 return Issue{ 65 - RepoAt: syntax.ATURI(record.Repo), 66 - OwnerDid: did, 67 - Rkey: rkey, 68 - Created: created, 69 - Title: record.Title, 70 - Body: body, 71 - Open: true, // new issues are open by default 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 72 124 } 73 125 } 74 126 75 - func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 - ownerDid := issueUri.Authority().String() 77 - issueRkey := issueUri.RecordKey().String() 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 137 + } 78 138 79 - var repoAt string 80 - var issueId int 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 141 + } 81 142 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 143 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 + return tangled.RepoIssueComment{ 145 + Body: i.Body, 146 + Issue: i.IssueAt, 147 + CreatedAt: i.Created.Format(time.RFC3339), 148 + ReplyTo: i.ReplyTo, 86 149 } 150 + } 87 151 88 - return syntax.ATURI(repoAt), issueId, nil 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 89 154 } 90 155 91 - func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 92 157 created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 158 if err != nil { 94 159 created = time.Now() 95 160 } 96 161 97 162 ownerDid := did 98 - if record.Owner != nil { 99 - ownerDid = *record.Owner 100 - } 101 163 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 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 165 + return nil, err 110 166 } 111 167 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, 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 120 175 } 121 176 122 - return comment, nil 177 + return &comment, nil 123 178 } 124 179 125 - func NewIssue(tx *sql.Tx, issue *Issue) error { 126 - defer tx.Rollback() 127 - 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 128 182 _, err := tx.Exec(` 129 183 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 130 184 values (?, 1) 131 - `, issue.RepoAt) 185 + `, issue.RepoAt) 132 186 if err != nil { 133 187 return err 134 188 } 135 189 136 - var nextId int 137 - err = tx.QueryRow(` 138 - update repo_issue_seqs 139 - set next_issue_id = next_issue_id + 1 140 - where repo_at = ? 141 - returning next_issue_id - 1 142 - `, issue.RepoAt).Scan(&nextId) 143 - if err != nil { 190 + issues, err := GetIssues( 191 + tx, 192 + FilterEq("did", issue.Did), 193 + FilterEq("rkey", issue.Rkey), 194 + ) 195 + switch { 196 + case err != nil: 144 197 return err 145 - } 146 - 147 - issue.IssueId = nextId 198 + case len(issues) == 0: 199 + return createNewIssue(tx, issue) 200 + case len(issues) != 1: // should be unreachable 201 + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 202 + default: 203 + // if content is identical, do not edit 204 + existingIssue := issues[0] 205 + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 206 + return nil 207 + } 148 208 149 - res, err := tx.Exec(` 150 - insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body) 151 - values (?, ?, ?, ?, ?, ?, ?) 152 - `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body) 153 - if err != nil { 154 - return err 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 155 212 } 213 + } 156 214 157 - lastID, err := res.LastInsertId() 215 + func createNewIssue(tx *sql.Tx, issue *Issue) error { 216 + // get next issue_id 217 + var newIssueId int 218 + err := tx.QueryRow(` 219 + update repo_issue_seqs 220 + set next_issue_id = next_issue_id + 1 221 + where repo_at = ? 222 + returning next_issue_id - 1 223 + `, issue.RepoAt).Scan(&newIssueId) 158 224 if err != nil { 159 225 return err 160 226 } 161 - issue.ID = lastID 162 227 163 - if err := tx.Commit(); err != nil { 164 - return err 165 - } 228 + // insert new issue 229 + row := tx.QueryRow(` 230 + insert into issues (repo_at, did, rkey, issue_id, title, body) 231 + values (?, ?, ?, ?, ?, ?) 232 + returning rowid, issue_id 233 + `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 166 234 167 - return nil 235 + return row.Scan(&issue.Id, &issue.IssueId) 168 236 } 169 237 170 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 171 - var issueAt string 172 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 173 - return issueAt, err 174 - } 175 - 176 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 177 - var ownerDid string 178 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 179 - return ownerDid, err 180 - } 181 - 182 - func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 183 - var issues []Issue 184 - openValue := 0 185 - if isOpen { 186 - openValue = 1 187 - } 188 - 189 - rows, err := e.Query( 190 - ` 191 - with numbered_issue as ( 192 - select 193 - i.id, 194 - i.owner_did, 195 - i.rkey, 196 - i.issue_id, 197 - i.created, 198 - i.title, 199 - i.body, 200 - i.open, 201 - count(c.id) as comment_count, 202 - row_number() over (order by i.created desc) as row_num 203 - from 204 - issues i 205 - left join 206 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 207 - where 208 - i.repo_at = ? and i.open = ? 209 - group by 210 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 211 - ) 212 - select 213 - id, 214 - owner_did, 215 - rkey, 216 - issue_id, 217 - created, 218 - title, 219 - body, 220 - open, 221 - comment_count 222 - from 223 - numbered_issue 224 - where 225 - row_num between ? and ?`, 226 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 227 - if err != nil { 228 - return nil, err 229 - } 230 - defer rows.Close() 231 - 232 - for rows.Next() { 233 - var issue Issue 234 - var createdAt string 235 - var metadata IssueMetadata 236 - err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 237 - if err != nil { 238 - return nil, err 239 - } 240 - 241 - createdTime, err := time.Parse(time.RFC3339, createdAt) 242 - if err != nil { 243 - return nil, err 244 - } 245 - issue.Created = createdTime 246 - issue.Metadata = &metadata 247 - 248 - issues = append(issues, issue) 249 - } 250 - 251 - if err := rows.Err(); err != nil { 252 - return nil, err 253 - } 254 - 255 - return issues, nil 238 + func updateIssue(tx *sql.Tx, issue *Issue) error { 239 + // update existing issue 240 + _, err := tx.Exec(` 241 + update issues 242 + set title = ?, body = ?, edited = ? 243 + where did = ? and rkey = ? 244 + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 245 + return err 256 246 } 257 247 258 - func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 - issues := make([]Issue, 0, limit) 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 260 250 261 251 var conditions []string 262 252 var args []any 253 + 263 254 for _, filter := range filters { 264 255 conditions = append(conditions, filter.Condition()) 265 256 args = append(args, filter.Arg()...) ··· 269 260 if conditions != nil { 270 261 whereClause = " where " + strings.Join(conditions, " and ") 271 262 } 272 - limitClause := "" 273 - if limit != 0 { 274 - limitClause = fmt.Sprintf(" limit %d ", limit) 275 - } 263 + 264 + pLower := FilterGte("row_num", page.Offset+1) 265 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 266 + 267 + args = append(args, pLower.Arg()...) 268 + args = append(args, pUpper.Arg()...) 269 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 276 270 277 271 query := fmt.Sprintf( 278 - `select 279 - i.id, 280 - i.owner_did, 281 - i.repo_at, 282 - i.issue_id, 283 - i.created, 284 - i.title, 285 - i.body, 286 - i.open 287 - from 288 - issues i 272 + ` 273 + select * from ( 274 + select 275 + id, 276 + did, 277 + rkey, 278 + repo_at, 279 + issue_id, 280 + title, 281 + body, 282 + open, 283 + created, 284 + edited, 285 + deleted, 286 + row_number() over (order by created desc) as row_num 287 + from 288 + issues 289 + %s 290 + ) ranked_issues 289 291 %s 290 - order by 291 - i.created desc 292 - %s`, 293 - whereClause, limitClause) 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 294 296 295 297 rows, err := e.Query(query, args...) 296 298 if err != nil { 297 - return nil, err 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 298 300 } 299 301 defer rows.Close() 300 302 301 303 for rows.Next() { 302 304 var issue Issue 303 - var issueCreatedAt string 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 304 308 err := rows.Scan( 305 - &issue.ID, 306 - &issue.OwnerDid, 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 307 312 &issue.RepoAt, 308 313 &issue.IssueId, 309 - &issueCreatedAt, 310 314 &issue.Title, 311 315 &issue.Body, 312 316 &issue.Open, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 313 321 ) 314 322 if err != nil { 315 - return nil, err 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 324 + } 325 + 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 328 + } 329 + 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 316 334 } 317 335 318 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 - if err != nil { 320 - return nil, err 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 321 340 } 322 - issue.Created = issueCreatedTime 323 341 324 - issues = append(issues, issue) 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 325 344 } 326 345 327 - if err := rows.Err(); err != nil { 328 - return nil, err 346 + // collect reverse repos 347 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 348 + for _, issue := range issueMap { 349 + repoAts = append(repoAts, string(issue.RepoAt)) 329 350 } 330 351 331 - return issues, nil 332 - } 333 - 334 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 335 - return GetIssuesWithLimit(e, 0, filters...) 336 - } 337 - 338 - // timeframe here is directly passed into the sql query filter, and any 339 - // timeframe in the past should be negative; e.g.: "-3 months" 340 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 341 - var issues []Issue 342 - 343 - rows, err := e.Query( 344 - `select 345 - i.id, 346 - i.owner_did, 347 - i.rkey, 348 - i.repo_at, 349 - i.issue_id, 350 - i.created, 351 - i.title, 352 - i.body, 353 - i.open, 354 - r.did, 355 - r.name, 356 - r.knot, 357 - r.rkey, 358 - r.created 359 - from 360 - issues i 361 - join 362 - repos r on i.repo_at = r.at_uri 363 - where 364 - i.owner_did = ? and i.created >= date ('now', ?) 365 - order by 366 - i.created desc`, 367 - ownerDid, timeframe) 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 368 353 if err != nil { 369 - return nil, err 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 370 355 } 371 - defer rows.Close() 372 356 373 - for rows.Next() { 374 - var issue Issue 375 - var issueCreatedAt, repoCreatedAt string 376 - var repo Repo 377 - err := rows.Scan( 378 - &issue.ID, 379 - &issue.OwnerDid, 380 - &issue.Rkey, 381 - &issue.RepoAt, 382 - &issue.IssueId, 383 - &issueCreatedAt, 384 - &issue.Title, 385 - &issue.Body, 386 - &issue.Open, 387 - &repo.Did, 388 - &repo.Name, 389 - &repo.Knot, 390 - &repo.Rkey, 391 - &repoCreatedAt, 392 - ) 393 - if err != nil { 394 - return nil, err 395 - } 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 396 361 397 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 398 - if err != nil { 399 - return nil, err 362 + for issueAt, i := range issueMap { 363 + if r, ok := repoMap[string(i.RepoAt)]; ok { 364 + i.Repo = r 365 + } else { 366 + // do not show up the issue if the repo is deleted 367 + // TODO: foreign key where? 368 + delete(issueMap, issueAt) 400 369 } 401 - issue.Created = issueCreatedTime 370 + } 402 371 403 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 404 - if err != nil { 405 - return nil, err 406 - } 407 - repo.Created = repoCreatedTime 372 + // collect comments 373 + issueAts := slices.Collect(maps.Keys(issueMap)) 374 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 + if err != nil { 376 + return nil, fmt.Errorf("failed to query comments: %w", err) 377 + } 408 378 409 - issue.Metadata = &IssueMetadata{ 410 - Repo: &repo, 379 + for i := range comments { 380 + issueAt := comments[i].IssueAt 381 + if issue, ok := issueMap[issueAt]; ok { 382 + issue.Comments = append(issue.Comments, comments[i]) 411 383 } 412 - 413 - issues = append(issues, issue) 414 384 } 415 385 416 - if err := rows.Err(); err != nil { 417 - return nil, err 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 418 389 } 419 390 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 394 + 420 395 return issues, nil 421 396 } 422 397 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 + } 401 + 423 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 424 403 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 425 404 row := e.QueryRow(query, repoAt, issueId) 426 405 427 406 var issue Issue 428 407 var createdAt string 429 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 430 409 if err != nil { 431 410 return nil, err 432 411 } ··· 440 419 return &issue, nil 441 420 } 442 421 443 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 444 - query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 445 - row := e.QueryRow(query, repoAt, issueId) 446 - 447 - var issue Issue 448 - var createdAt string 449 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 422 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 423 + result, err := e.Exec( 424 + `insert into issue_comments ( 425 + did, 426 + rkey, 427 + issue_at, 428 + body, 429 + reply_to, 430 + created, 431 + edited 432 + ) 433 + values (?, ?, ?, ?, ?, ?, null) 434 + on conflict(did, rkey) do update set 435 + issue_at = excluded.issue_at, 436 + body = excluded.body, 437 + edited = case 438 + when 439 + issue_comments.issue_at != excluded.issue_at 440 + or issue_comments.body != excluded.body 441 + or issue_comments.reply_to != excluded.reply_to 442 + then ? 443 + else issue_comments.edited 444 + end`, 445 + c.Did, 446 + c.Rkey, 447 + c.IssueAt, 448 + c.Body, 449 + c.ReplyTo, 450 + c.Created.Format(time.RFC3339), 451 + time.Now().Format(time.RFC3339), 452 + ) 450 453 if err != nil { 451 - return nil, nil, err 454 + return 0, err 452 455 } 453 456 454 - createdTime, err := time.Parse(time.RFC3339, createdAt) 457 + id, err := result.LastInsertId() 455 458 if err != nil { 456 - return nil, nil, err 459 + return 0, err 457 460 } 458 - issue.Created = createdTime 461 + 462 + return id, nil 463 + } 459 464 460 - comments, err := GetComments(e, repoAt, issueId) 461 - if err != nil { 462 - return nil, nil, err 465 + func DeleteIssueComments(e Execer, filters ...filter) error { 466 + var conditions []string 467 + var args []any 468 + for _, filter := range filters { 469 + conditions = append(conditions, filter.Condition()) 470 + args = append(args, filter.Arg()...) 463 471 } 464 472 465 - return &issue, comments, nil 466 - } 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 467 477 468 - func NewIssueComment(e Execer, comment *Comment) error { 469 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 470 - _, err := e.Exec( 471 - query, 472 - comment.OwnerDid, 473 - comment.RepoAt, 474 - comment.Rkey, 475 - comment.Issue, 476 - comment.CommentId, 477 - comment.Body, 478 - ) 478 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 479 + 480 + _, err := e.Exec(query, args...) 479 481 return err 480 482 } 481 483 482 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 483 - var comments []Comment 484 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 + var comments []IssueComment 486 + 487 + var conditions []string 488 + var args []any 489 + for _, filter := range filters { 490 + conditions = append(conditions, filter.Condition()) 491 + args = append(args, filter.Arg()...) 492 + } 493 + 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 484 498 485 - rows, err := e.Query(` 499 + query := fmt.Sprintf(` 486 500 select 487 - owner_did, 488 - issue_id, 489 - comment_id, 501 + id, 502 + did, 490 503 rkey, 504 + issue_at, 505 + reply_to, 491 506 body, 492 507 created, 493 508 edited, 494 509 deleted 495 510 from 496 - comments 497 - where 498 - repo_at = ? and issue_id = ? 499 - order by 500 - created asc`, 501 - repoAt, 502 - issueId, 503 - ) 504 - if err == sql.ErrNoRows { 505 - return []Comment{}, nil 506 - } 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 507 516 if err != nil { 508 517 return nil, err 509 518 } 510 - defer rows.Close() 511 519 512 520 for rows.Next() { 513 - var comment Comment 514 - var createdAt string 515 - var deletedAt, editedAt, rkey sql.NullString 516 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 521 + var comment IssueComment 522 + var created string 523 + var rkey, edited, deleted, replyTo sql.Null[string] 524 + err := rows.Scan( 525 + &comment.Id, 526 + &comment.Did, 527 + &rkey, 528 + &comment.IssueAt, 529 + &replyTo, 530 + &comment.Body, 531 + &created, 532 + &edited, 533 + &deleted, 534 + ) 517 535 if err != nil { 518 536 return nil, err 519 537 } 520 538 521 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 522 - if err != nil { 523 - return nil, err 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 524 542 } 525 - comment.Created = &createdAtTime 526 543 527 - if deletedAt.Valid { 528 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 529 - if err != nil { 530 - return nil, err 544 + if t, err := time.Parse(time.RFC3339, created); err == nil { 545 + comment.Created = t 546 + } 547 + 548 + if edited.Valid { 549 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 550 + comment.Edited = &t 531 551 } 532 - comment.Deleted = &deletedTime 533 552 } 534 553 535 - if editedAt.Valid { 536 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 537 - if err != nil { 538 - return nil, err 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 539 557 } 540 - comment.Edited = &editedTime 541 558 } 542 559 543 - if rkey.Valid { 544 - comment.Rkey = rkey.String 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 545 562 } 546 563 547 564 comments = append(comments, comment) 548 565 } 549 566 550 - if err := rows.Err(); err != nil { 567 + if err = rows.Err(); err != nil { 551 568 return nil, err 552 569 } 553 570 554 571 return comments, nil 555 572 } 556 573 557 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 558 - query := ` 559 - select 560 - owner_did, body, rkey, created, deleted, edited 561 - from 562 - comments where repo_at = ? and issue_id = ? and comment_id = ? 563 - ` 564 - row := e.QueryRow(query, repoAt, issueId, commentId) 565 - 566 - var comment Comment 567 - var createdAt string 568 - var deletedAt, editedAt, rkey sql.NullString 569 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 570 - if err != nil { 571 - return nil, err 574 + func DeleteIssues(e Execer, filters ...filter) error { 575 + var conditions []string 576 + var args []any 577 + for _, filter := range filters { 578 + conditions = append(conditions, filter.Condition()) 579 + args = append(args, filter.Arg()...) 572 580 } 573 581 574 - createdTime, err := time.Parse(time.RFC3339, createdAt) 575 - if err != nil { 576 - return nil, err 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 577 585 } 578 - comment.Created = &createdTime 579 586 580 - if deletedAt.Valid { 581 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 582 - if err != nil { 583 - return nil, err 584 - } 585 - comment.Deleted = &deletedTime 586 - } 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 587 591 588 - if editedAt.Valid { 589 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 590 - if err != nil { 591 - return nil, err 592 - } 593 - comment.Edited = &editedTime 592 + func CloseIssues(e Execer, filters ...filter) error { 593 + var conditions []string 594 + var args []any 595 + for _, filter := range filters { 596 + conditions = append(conditions, filter.Condition()) 597 + args = append(args, filter.Arg()...) 594 598 } 595 599 596 - if rkey.Valid { 597 - comment.Rkey = rkey.String 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 598 603 } 599 604 600 - comment.RepoAt = repoAt 601 - comment.Issue = issueId 602 - comment.CommentId = commentId 603 - 604 - return &comment, nil 605 - } 606 - 607 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 608 - _, err := e.Exec( 609 - ` 610 - update comments 611 - set body = ?, 612 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 613 - where repo_at = ? and issue_id = ? and comment_id = ? 614 - `, newBody, repoAt, issueId, commentId) 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 615 607 return err 616 608 } 617 609 618 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 619 - _, err := e.Exec( 620 - ` 621 - update comments 622 - set body = "", 623 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 624 - where repo_at = ? and issue_id = ? and comment_id = ? 625 - `, repoAt, issueId, commentId) 626 - return err 627 - } 610 + func ReopenIssues(e Execer, filters ...filter) error { 611 + var conditions []string 612 + var args []any 613 + for _, filter := range filters { 614 + conditions = append(conditions, filter.Condition()) 615 + args = append(args, filter.Arg()...) 616 + } 628 617 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 - } 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 639 622 640 - func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 - _, err := e.Exec( 642 - ` 643 - update comments 644 - set body = "", 645 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 - where owner_did = ? and rkey = ? 647 - `, ownerDid, rkey) 648 - return err 649 - } 650 - 651 - func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 - _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 - return err 654 - } 655 - 656 - func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 - _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 658 - return err 659 - } 660 - 661 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 662 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 663 - return err 664 - } 665 - 666 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 667 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 668 625 return err 669 626 } 670 627
+23 -5
appview/db/profile.go
··· 22 22 ByMonth []ByMonth 23 23 } 24 24 25 + func (p *ProfileTimeline) IsEmpty() bool { 26 + if p == nil { 27 + return true 28 + } 29 + 30 + for _, m := range p.ByMonth { 31 + if !m.IsEmpty() { 32 + return false 33 + } 34 + } 35 + 36 + return true 37 + } 38 + 25 39 type ByMonth struct { 26 40 RepoEvents []RepoEvent 27 41 IssueEvents IssueEvents ··· 118 132 *items = append(*items, &pull) 119 133 } 120 134 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 122 140 if err != nil { 123 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 142 } ··· 137 155 *items = append(*items, &issue) 138 156 } 139 157 140 - repos, err := GetAllReposByDid(e, forDid) 158 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 141 159 if err != nil { 142 160 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 161 } ··· 535 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 536 554 args = append(args, did, PullOpen) 537 555 case VanityStatOpenIssueCount: 538 - query = `select count(id) from issues where owner_did = ? and open = 1` 556 + query = `select count(id) from issues where did = ? and open = 1` 539 557 args = append(args, did) 540 558 case VanityStatClosedIssueCount: 541 - query = `select count(id) from issues where owner_did = ? and open = 0` 559 + query = `select count(id) from issues where did = ? and open = 0` 542 560 args = append(args, did) 543 561 case VanityStatRepositoryCount: 544 562 query = `select count(id) from repos where did = ?` ··· 572 590 } 573 591 574 592 // ensure all pinned repos are either own repos or collaborating repos 575 - repos, err := GetAllReposByDid(e, profile.Did) 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 576 594 if err != nil { 577 595 log.Printf("getting repos for %s: %s", profile.Did, err) 578 596 }
+4 -4
appview/db/punchcard.go
··· 29 29 Punches []Punch 30 30 } 31 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 34 now := time.Now() 35 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 63 64 64 rows, err := e.Query(query, args...) 65 65 if err != nil { 66 - return punchcard, err 66 + return nil, err 67 67 } 68 68 defer rows.Close() 69 69 ··· 72 72 var date string 73 73 var count sql.NullInt64 74 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 75 + return nil, err 76 76 } 77 77 78 78 punch.Date, err = time.Parse(time.DateOnly, date)
+17 -17
appview/db/registration.go
··· 10 10 // Registration represents a knot registration. Knot would've been a better 11 11 // name but we're stuck with this for historical reasons. 12 12 type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - ReadOnly bool 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 19 19 } 20 20 21 21 func (r *Registration) Status() Status { 22 - if r.ReadOnly { 23 - return ReadOnly 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 24 24 } else if r.Registered != nil { 25 25 return Registered 26 26 } else { ··· 32 32 return r.Status() == Registered 33 33 } 34 34 35 - func (r *Registration) IsReadOnly() bool { 36 - return r.Status() == ReadOnly 35 + func (r *Registration) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 37 37 } 38 38 39 39 func (r *Registration) IsPending() bool { ··· 45 45 const ( 46 46 Registered Status = iota 47 47 Pending 48 - ReadOnly 48 + NeedsUpgrade 49 49 ) 50 50 51 51 func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { ··· 64 64 } 65 65 66 66 query := fmt.Sprintf(` 67 - select id, domain, did, created, registered, read_only 67 + select id, domain, did, created, registered, needs_upgrade 68 68 from registrations 69 69 %s 70 70 order by created ··· 80 80 for rows.Next() { 81 81 var createdAt string 82 82 var registeredAt sql.Null[string] 83 - var readOnly int 83 + var needsUpgrade int 84 84 var reg Registration 85 85 86 - err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 87 if err != nil { 88 88 return nil, err 89 89 } ··· 98 98 } 99 99 } 100 100 101 - if readOnly != 0 { 102 - reg.ReadOnly = true 101 + if needsUpgrade != 0 { 102 + reg.NeedsUpgrade = true 103 103 } 104 104 105 105 registrations = append(registrations, reg) ··· 116 116 args = append(args, filter.Arg()...) 117 117 } 118 118 119 - query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 120 if len(conditions) > 0 { 121 121 query += " where " + strings.Join(conditions, " and ") 122 122 }
+19 -125
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 "fmt" 6 7 "log" 7 8 "slices" ··· 36 37 func (r Repo) DidSlashRepo() string { 37 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 38 39 return p 39 - } 40 - 41 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 42 - var repos []Repo 43 - 44 - rows, err := e.Query( 45 - `select did, name, knot, rkey, description, created, source 46 - from repos 47 - order by created desc 48 - limit ? 49 - `, 50 - limit, 51 - ) 52 - if err != nil { 53 - return nil, err 54 - } 55 - defer rows.Close() 56 - 57 - for rows.Next() { 58 - var repo Repo 59 - err := scanRepo( 60 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 61 - ) 62 - if err != nil { 63 - return nil, err 64 - } 65 - repos = append(repos, repo) 66 - } 67 - 68 - if err := rows.Err(); err != nil { 69 - return nil, err 70 - } 71 - 72 - return repos, nil 73 40 } 74 41 75 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 310 277 311 278 slices.SortFunc(repos, func(a, b Repo) int { 312 279 if a.Created.After(b.Created) { 313 - return 1 280 + return -1 314 281 } 315 - return -1 282 + return 1 316 283 }) 317 284 318 285 return repos, nil 319 286 } 320 287 321 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 322 - var repos []Repo 323 - 324 - rows, err := e.Query( 325 - `select 326 - r.did, 327 - r.name, 328 - r.knot, 329 - r.rkey, 330 - r.description, 331 - r.created, 332 - count(s.id) as star_count, 333 - r.source 334 - from 335 - repos r 336 - left join 337 - stars s on r.at_uri = s.repo_at 338 - where 339 - r.did = ? 340 - group by 341 - r.at_uri 342 - order by r.created desc`, 343 - did) 344 - if err != nil { 345 - return nil, err 288 + func CountRepos(e Execer, filters ...filter) (int64, error) { 289 + var conditions []string 290 + var args []any 291 + for _, filter := range filters { 292 + conditions = append(conditions, filter.Condition()) 293 + args = append(args, filter.Arg()...) 346 294 } 347 - defer rows.Close() 348 295 349 - for rows.Next() { 350 - var repo Repo 351 - var repoStats RepoStats 352 - var createdAt string 353 - var nullableDescription sql.NullString 354 - var nullableSource sql.NullString 355 - 356 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 357 - if err != nil { 358 - return nil, err 359 - } 360 - 361 - if nullableDescription.Valid { 362 - repo.Description = nullableDescription.String 363 - } 364 - 365 - if nullableSource.Valid { 366 - repo.Source = nullableSource.String 367 - } 368 - 369 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 370 - if err != nil { 371 - repo.Created = time.Now() 372 - } else { 373 - repo.Created = createdAtTime 374 - } 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 299 + } 375 300 376 - repo.RepoStats = &repoStats 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 377 304 378 - repos = append(repos, repo) 379 - } 380 - 381 - if err := rows.Err(); err != nil { 382 - return nil, err 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 383 307 } 384 308 385 - return repos, nil 309 + return count, nil 386 310 } 387 311 388 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 570 494 IssueCount IssueCount 571 495 PullCount PullCount 572 496 } 573 - 574 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 575 - var createdAt string 576 - var nullableDescription sql.NullString 577 - var nullableSource sql.NullString 578 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 579 - return err 580 - } 581 - 582 - if nullableDescription.Valid { 583 - *description = nullableDescription.String 584 - } else { 585 - *description = "" 586 - } 587 - 588 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 589 - if err != nil { 590 - *created = time.Now() 591 - } else { 592 - *created = createdAtTime 593 - } 594 - 595 - if nullableSource.Valid { 596 - *source = nullableSource.String 597 - } else { 598 - *source = "" 599 - } 600 - 601 - return nil 602 - }
+14 -7
appview/db/spindle.go
··· 10 10 ) 11 11 12 12 type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + NeedsUpgrade bool 18 19 } 19 20 20 21 type SpindleMember struct { ··· 42 43 } 43 44 44 45 query := fmt.Sprintf( 45 - `select id, owner, instance, verified, created 46 + `select id, owner, instance, verified, created, needs_upgrade 46 47 from spindles 47 48 %s 48 49 order by created ··· 61 62 var spindle Spindle 62 63 var createdAt string 63 64 var verified sql.NullString 65 + var needsUpgrade int 64 66 65 67 if err := rows.Scan( 66 68 &spindle.Id, ··· 68 70 &spindle.Instance, 69 71 &verified, 70 72 &createdAt, 73 + &needsUpgrade, 71 74 ); err != nil { 72 75 return nil, err 73 76 } ··· 86 89 spindle.Verified = &t 87 90 } 88 91 92 + if needsUpgrade != 0 { 93 + spindle.NeedsUpgrade = true 94 + } 95 + 89 96 spindles = append(spindles, spindle) 90 97 } 91 98 ··· 115 122 whereClause = " where " + strings.Join(conditions, " and ") 116 123 } 117 124 118 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 125 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 119 126 120 127 res, err := e.Exec(query, args...) 121 128 if err != nil {
+26
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 5 + "errors" 4 6 "fmt" 5 7 "log" 6 8 "strings" ··· 181 183 } 182 184 183 185 return stars, nil 186 + } 187 + 188 + func CountStars(e Execer, filters ...filter) (int64, error) { 189 + var conditions []string 190 + var args []any 191 + for _, filter := range filters { 192 + conditions = append(conditions, filter.Condition()) 193 + args = append(args, filter.Arg()...) 194 + } 195 + 196 + whereClause := "" 197 + if conditions != nil { 198 + whereClause = " where " + strings.Join(conditions, " and ") 199 + } 200 + 201 + repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause) 202 + var count int64 203 + err := e.QueryRow(repoQuery, args...).Scan(&count) 204 + 205 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 206 + return 0, err 207 + } 208 + 209 + return count, nil 184 210 } 185 211 186 212 func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
··· 206 206 return all, nil 207 207 } 208 208 209 + func CountStrings(e Execer, filters ...filter) (int64, error) { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause) 223 + var count int64 224 + err := e.QueryRow(repoQuery, args...).Scan(&count) 225 + 226 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 227 + return 0, err 228 + } 229 + 230 + return count, nil 231 + } 232 + 209 233 func DeleteString(e Execer, filters ...filter) error { 210 234 var conditions []string 211 235 var args []any
+12 -14
appview/db/timeline.go
··· 20 20 *FollowStats 21 21 } 22 22 23 - const Limit = 50 24 - 25 23 // TODO: this gathers heterogenous events from different sources and aggregates 26 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 27 - func MakeTimeline(e Execer) ([]TimelineEvent, error) { 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 28 26 var events []TimelineEvent 29 27 30 - repos, err := getTimelineRepos(e) 28 + repos, err := getTimelineRepos(e, limit) 31 29 if err != nil { 32 30 return nil, err 33 31 } 34 32 35 - stars, err := getTimelineStars(e) 33 + stars, err := getTimelineStars(e, limit) 36 34 if err != nil { 37 35 return nil, err 38 36 } 39 37 40 - follows, err := getTimelineFollows(e) 38 + follows, err := getTimelineFollows(e, limit) 41 39 if err != nil { 42 40 return nil, err 43 41 } ··· 51 49 }) 52 50 53 51 // Limit the slice to 100 events 54 - if len(events) > Limit { 55 - events = events[:Limit] 52 + if len(events) > limit { 53 + events = events[:limit] 56 54 } 57 55 58 56 return events, nil 59 57 } 60 58 61 - func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 62 - repos, err := GetRepos(e, Limit) 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 63 61 if err != nil { 64 62 return nil, err 65 63 } ··· 104 102 return events, nil 105 103 } 106 104 107 - func getTimelineStars(e Execer) ([]TimelineEvent, error) { 108 - stars, err := GetStars(e, Limit) 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 109 107 if err != nil { 110 108 return nil, err 111 109 } ··· 131 129 return events, nil 132 130 } 133 131 134 - func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 136 134 if err != nil { 137 135 return nil, err 138 136 }
+29 -74
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 - "strings" 8 + 9 9 "time" 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 15 "tangled.sh/tangled.sh/core/api/tangled" 16 16 "tangled.sh/tangled.sh/core/appview/config" 17 17 "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/pages/markup" 19 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 20 20 "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/rbac" 22 22 ) ··· 27 27 IdResolver *idresolver.Resolver 28 28 Config *config.Config 29 29 Logger *slog.Logger 30 + Validator *validator.Validator 30 31 } 31 32 32 33 type processFunc func(ctx context.Context, e *models.Event) error ··· 790 791 } 791 792 792 793 switch e.Commit.Operation { 793 - case models.CommitOperationCreate: 794 + case models.CommitOperationCreate, models.CommitOperationUpdate: 794 795 raw := json.RawMessage(e.Commit.Record) 795 796 record := tangled.RepoIssue{} 796 797 err = json.Unmarshal(raw, &record) ··· 801 802 802 803 issue := db.IssueFromRecord(did, rkey, record) 803 804 804 - sanitizer := markup.NewSanitizer() 805 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" { 806 - return fmt.Errorf("title is empty after HTML sanitization") 807 - } 808 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" { 809 - return fmt.Errorf("body is empty after HTML sanitization") 805 + if err := i.Validator.ValidateIssue(&issue); err != nil { 806 + return fmt.Errorf("failed to validate issue: %w", err) 810 807 } 811 808 812 809 tx, err := ddb.BeginTx(ctx, nil) ··· 814 811 l.Error("failed to begin transaction", "err", err) 815 812 return err 816 813 } 814 + defer tx.Rollback() 817 815 818 - err = db.NewIssue(tx, &issue) 816 + err = db.PutIssue(tx, &issue) 819 817 if err != nil { 820 818 l.Error("failed to create issue", "err", err) 821 819 return err 822 820 } 823 821 824 - return nil 825 - 826 - case models.CommitOperationUpdate: 827 - raw := json.RawMessage(e.Commit.Record) 828 - record := tangled.RepoIssue{} 829 - err = json.Unmarshal(raw, &record) 822 + err = tx.Commit() 830 823 if err != nil { 831 - l.Error("invalid record", "err", err) 832 - return err 833 - } 834 - 835 - body := "" 836 - if record.Body != nil { 837 - body = *record.Body 838 - } 839 - 840 - sanitizer := markup.NewSanitizer() 841 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" { 842 - return fmt.Errorf("title is empty after HTML sanitization") 843 - } 844 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 845 - return fmt.Errorf("body is empty after HTML sanitization") 846 - } 847 - 848 - err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body) 849 - if err != nil { 850 - l.Error("failed to update issue", "err", err) 824 + l.Error("failed to commit txn", "err", err) 851 825 return err 852 826 } 853 827 854 828 return nil 855 829 856 830 case models.CommitOperationDelete: 857 - if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 831 + if err := db.DeleteIssues( 832 + ddb, 833 + db.FilterEq("did", did), 834 + db.FilterEq("rkey", rkey), 835 + ); err != nil { 858 836 l.Error("failed to delete", "err", err) 859 837 return fmt.Errorf("failed to delete issue record: %w", err) 860 838 } ··· 862 840 return nil 863 841 } 864 842 865 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 843 + return nil 866 844 } 867 845 868 846 func (i *Ingester) ingestIssueComment(e *models.Event) error { ··· 880 858 } 881 859 882 860 switch e.Commit.Operation { 883 - case models.CommitOperationCreate: 861 + case models.CommitOperationCreate, models.CommitOperationUpdate: 884 862 raw := json.RawMessage(e.Commit.Record) 885 863 record := tangled.RepoIssueComment{} 886 864 err = json.Unmarshal(raw, &record) 887 865 if err != nil { 888 - l.Error("invalid record", "err", err) 889 - return err 866 + return fmt.Errorf("invalid record: %w", err) 890 867 } 891 868 892 869 comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 893 870 if err != nil { 894 - l.Error("failed to parse comment from record", "err", err) 895 - return err 871 + return fmt.Errorf("failed to parse comment from record: %w", err) 896 872 } 897 873 898 - sanitizer := markup.NewSanitizer() 899 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 900 - return fmt.Errorf("body is empty after HTML sanitization") 874 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 875 + return fmt.Errorf("failed to validate comment: %w", err) 901 876 } 902 877 903 - err = db.NewIssueComment(ddb, &comment) 878 + _, err = db.AddIssueComment(ddb, *comment) 904 879 if err != nil { 905 - l.Error("failed to create issue comment", "err", err) 906 - return err 907 - } 908 - 909 - return nil 910 - 911 - case models.CommitOperationUpdate: 912 - raw := json.RawMessage(e.Commit.Record) 913 - record := tangled.RepoIssueComment{} 914 - err = json.Unmarshal(raw, &record) 915 - if err != nil { 916 - l.Error("invalid record", "err", err) 917 - return err 918 - } 919 - 920 - sanitizer := markup.NewSanitizer() 921 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" { 922 - return fmt.Errorf("body is empty after HTML sanitization") 923 - } 924 - 925 - err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body) 926 - if err != nil { 927 - l.Error("failed to update issue comment", "err", err) 928 - return err 880 + return fmt.Errorf("failed to create issue comment: %w", err) 929 881 } 930 882 931 883 return nil 932 884 933 885 case models.CommitOperationDelete: 934 - if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 935 - l.Error("failed to delete", "err", err) 886 + if err := db.DeleteIssueComments( 887 + ddb, 888 + db.FilterEq("did", did), 889 + db.FilterEq("rkey", rkey), 890 + ); err != nil { 936 891 return fmt.Errorf("failed to delete issue comment record: %w", err) 937 892 } 938 893 939 894 return nil 940 895 } 941 896 942 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 897 + return nil 943 898 }
+477 -280
appview/issues/issues.go
··· 1 1 package issues 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 4 7 "fmt" 5 8 "log" 6 - mathrand "math/rand/v2" 9 + "log/slog" 7 10 "net/http" 8 11 "slices" 9 - "strconv" 10 - "strings" 11 12 "time" 12 13 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 15 16 lexutil "github.com/bluesky-social/indigo/lex/util" 16 17 "github.com/go-chi/chi/v5" 17 18 ··· 21 22 "tangled.sh/tangled.sh/core/appview/notify" 22 23 "tangled.sh/tangled.sh/core/appview/oauth" 23 24 "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 28 31 "tangled.sh/tangled.sh/core/tid" 29 32 ) 30 33 ··· 36 39 db *db.DB 37 40 config *config.Config 38 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 39 44 } 40 45 41 46 func New( ··· 46 51 db *db.DB, 47 52 config *config.Config, 48 53 notifier notify.Notifier, 54 + validator *validator.Validator, 49 55 ) *Issues { 50 56 return &Issues{ 51 57 oauth: oauth, ··· 55 61 db: db, 56 62 config: config, 57 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 58 66 } 59 67 } 60 68 61 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 62 71 user := rp.oauth.GetUser(r) 63 72 f, err := rp.repoResolver.Resolve(r) 64 73 if err != nil { ··· 66 75 return 67 76 } 68 77 69 - issueId := chi.URLParam(r, "issue") 70 - issueIdInt, err := strconv.Atoi(issueId) 71 - if err != nil { 72 - http.Error(w, "bad issue id", http.StatusBadRequest) 73 - log.Println("failed to parse issue id", err) 74 - return 75 - } 76 - 77 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt) 78 - if err != nil { 79 - log.Println("failed to get issue and comments", err) 80 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 81 82 return 82 83 } 83 84 84 85 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 86 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + l.Error("failed to get issue reactions", "err", err) 88 88 } 89 89 90 90 userReactions := map[db.ReactionKind]bool{} ··· 92 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 93 } 94 94 95 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 96 - if err != nil { 97 - log.Println("failed to resolve issue owner", err) 98 - } 99 - 100 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 101 - LoggedInUser: user, 102 - RepoInfo: f.RepoInfo(user), 103 - Issue: issue, 104 - Comments: comments, 105 - 106 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 107 - 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 108 100 OrderedReactionKinds: db.OrderedReactionKinds, 109 101 Reactions: reactionCountMap, 110 102 UserReacted: userReactions, 111 103 }) 112 - 113 104 } 114 105 115 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 116 108 user := rp.oauth.GetUser(r) 117 109 f, err := rp.repoResolver.Resolve(r) 118 110 if err != nil { ··· 120 112 return 121 113 } 122 114 123 - issueId := chi.URLParam(r, "issue") 124 - issueIdInt, err := strconv.Atoi(issueId) 125 - if err != nil { 126 - http.Error(w, "bad issue id", http.StatusBadRequest) 127 - log.Println("failed to parse issue id", err) 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 128 119 return 129 120 } 130 121 131 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 132 - if err != nil { 133 - log.Println("failed to get issue", err) 134 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 135 - return 136 - } 122 + switch r.Method { 123 + case http.MethodGet: 124 + rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(user), 127 + Issue: issue, 128 + }) 129 + case http.MethodPost: 130 + noticeId := "issues" 131 + newIssue := issue 132 + newIssue.Title = r.FormValue("title") 133 + newIssue.Body = r.FormValue("body") 137 134 138 - collaborators, err := f.Collaborators(r.Context()) 139 - if err != nil { 140 - log.Println("failed to fetch repo collaborators: %w", err) 141 - } 142 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 143 - return user.Did == collab.Did 144 - }) 145 - isIssueOwner := user.Did == issue.OwnerDid 146 - 147 - // TODO: make this more granular 148 - if isIssueOwner || isCollaborator { 135 + if err := rp.validator.ValidateIssue(newIssue); err != nil { 136 + l.Error("validation error", "err", err) 137 + rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 138 + return 139 + } 149 140 150 - closed := tangled.RepoIssueStateClosed 141 + newRecord := newIssue.AsRecord() 151 142 143 + // edit an atproto record 152 144 client, err := rp.oauth.AuthorizedClient(r) 153 145 if err != nil { 154 - log.Println("failed to get authorized client", err) 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 155 148 return 156 149 } 150 + 151 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 + if err != nil { 153 + l.Error("failed to get record", "err", err) 154 + rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 + return 156 + } 157 + 157 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 159 + Collection: tangled.RepoIssueNSID, 159 160 Repo: user.Did, 160 - Rkey: tid.TID(), 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 161 163 Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 164 + Val: &newRecord, 166 165 }, 167 166 }) 167 + if err != nil { 168 + l.Error("failed to edit record on PDS", "err", err) 169 + rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 170 + return 171 + } 168 172 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 169 175 if err != nil { 170 - log.Println("failed to update issue state", err) 171 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 + return 179 + } 180 + defer tx.Rollback() 181 + 182 + err = db.PutIssue(tx, newIssue) 183 + if err != nil { 184 + log.Println("failed to edit issue", err) 185 + rp.pages.Notice(w, "issues", "Failed to edit issue.") 186 + return 187 + } 188 + 189 + if err = tx.Commit(); err != nil { 190 + l.Error("failed to edit issue", "err", err) 191 + rp.pages.Notice(w, "issues", "Failed to cedit issue.") 172 192 return 173 193 } 174 194 175 - err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 195 + rp.pages.HxRefresh(w) 196 + } 197 + } 198 + 199 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 + l := rp.logger.With("handler", "DeleteIssue") 201 + noticeId := "issue-actions-error" 202 + 203 + user := rp.oauth.GetUser(r) 204 + 205 + f, err := rp.repoResolver.Resolve(r) 206 + if err != nil { 207 + l.Error("failed to get repo and knot", "err", err) 208 + return 209 + } 210 + 211 + issue, ok := r.Context().Value("issue").(*db.Issue) 212 + if !ok { 213 + l.Error("failed to get issue") 214 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 215 + return 216 + } 217 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 218 + 219 + // delete from PDS 220 + client, err := rp.oauth.AuthorizedClient(r) 221 + if err != nil { 222 + log.Println("failed to get authorized client", err) 223 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 224 + return 225 + } 226 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 227 + Collection: tangled.RepoIssueNSID, 228 + Repo: issue.Did, 229 + Rkey: issue.Rkey, 230 + }) 231 + if err != nil { 232 + // TODO: transact this better 233 + l.Error("failed to delete issue from PDS", "err", err) 234 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 235 + return 236 + } 237 + 238 + // delete from db 239 + if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 240 + l.Error("failed to delete issue", "err", err) 241 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 242 + return 243 + } 244 + 245 + // return to all issues page 246 + rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 247 + } 248 + 249 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 250 + l := rp.logger.With("handler", "CloseIssue") 251 + user := rp.oauth.GetUser(r) 252 + f, err := rp.repoResolver.Resolve(r) 253 + if err != nil { 254 + l.Error("failed to get repo and knot", "err", err) 255 + return 256 + } 257 + 258 + issue, ok := r.Context().Value("issue").(*db.Issue) 259 + if !ok { 260 + l.Error("failed to get issue") 261 + rp.pages.Error404(w) 262 + return 263 + } 264 + 265 + collaborators, err := f.Collaborators(r.Context()) 266 + if err != nil { 267 + log.Println("failed to fetch repo collaborators: %w", err) 268 + } 269 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 270 + return user.Did == collab.Did 271 + }) 272 + isIssueOwner := user.Did == issue.Did 273 + 274 + // TODO: make this more granular 275 + if isIssueOwner || isCollaborator { 276 + err = db.CloseIssues( 277 + rp.db, 278 + db.FilterEq("id", issue.Id), 279 + ) 176 280 if err != nil { 177 281 log.Println("failed to close issue", err) 178 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 283 return 180 284 } 181 285 182 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 183 287 return 184 288 } else { 185 289 log.Println("user is not permitted to close issue") ··· 189 293 } 190 294 191 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 192 297 user := rp.oauth.GetUser(r) 193 298 f, err := rp.repoResolver.Resolve(r) 194 299 if err != nil { ··· 196 301 return 197 302 } 198 303 199 - issueId := chi.URLParam(r, "issue") 200 - issueIdInt, err := strconv.Atoi(issueId) 201 - if err != nil { 202 - http.Error(w, "bad issue id", http.StatusBadRequest) 203 - log.Println("failed to parse issue id", err) 204 - return 205 - } 206 - 207 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 208 - if err != nil { 209 - log.Println("failed to get issue", err) 210 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 211 308 return 212 309 } 213 310 ··· 218 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 316 return user.Did == collab.Did 220 317 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 318 + isIssueOwner := user.Did == issue.Did 222 319 223 320 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 225 325 if err != nil { 226 326 log.Println("failed to reopen issue", err) 227 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 328 return 229 329 } 230 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 231 331 return 232 332 } else { 233 333 log.Println("user is not the owner of the repo") ··· 237 337 } 238 338 239 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 240 341 user := rp.oauth.GetUser(r) 241 342 f, err := rp.repoResolver.Resolve(r) 242 343 if err != nil { 243 - log.Println("failed to get repo and knot", err) 344 + l.Error("failed to get repo and knot", "err", err) 244 345 return 245 346 } 246 347 247 - issueId := chi.URLParam(r, "issue") 248 - issueIdInt, err := strconv.Atoi(issueId) 249 - if err != nil { 250 - http.Error(w, "bad issue id", http.StatusBadRequest) 251 - log.Println("failed to parse issue id", err) 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 252 352 return 253 353 } 254 354 255 - switch r.Method { 256 - case http.MethodPost: 257 - body := r.FormValue("body") 258 - if body == "" { 259 - rp.pages.Notice(w, "issue", "Body is required") 260 - return 261 - } 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 262 360 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 265 366 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 - OwnerDid: user.Did, 268 - RepoAt: f.RepoAt(), 269 - Issue: issueIdInt, 270 - CommentId: commentId, 271 - Body: body, 272 - Rkey: rkey, 273 - }) 274 - if err != nil { 275 - log.Println("failed to create comment", err) 276 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 277 - return 278 - } 367 + comment := db.IssueComment{ 368 + Did: user.Did, 369 + Rkey: tid.TID(), 370 + IssueAt: issue.AtUri().String(), 371 + ReplyTo: replyTo, 372 + Body: body, 373 + Created: time.Now(), 374 + } 375 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 376 + l.Error("failed to validate comment", "err", err) 377 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 378 + return 379 + } 380 + record := comment.AsRecord() 279 381 280 - createdAt := time.Now().Format(time.RFC3339) 281 - ownerDid := user.Did 282 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 283 - if err != nil { 284 - log.Println("failed to get issue at", err) 285 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 286 - return 287 - } 382 + client, err := rp.oauth.AuthorizedClient(r) 383 + if err != nil { 384 + l.Error("failed to get authorized client", "err", err) 385 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 386 + return 387 + } 288 388 289 - atUri := f.RepoAt().String() 290 - client, err := rp.oauth.AuthorizedClient(r) 291 - if err != nil { 292 - log.Println("failed to get authorized client", err) 293 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 294 - return 389 + // create a record first 390 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 391 + Collection: tangled.RepoIssueCommentNSID, 392 + Repo: comment.Did, 393 + Rkey: comment.Rkey, 394 + Record: &lexutil.LexiconTypeDecoder{ 395 + Val: &record, 396 + }, 397 + }) 398 + if err != nil { 399 + l.Error("failed to create comment", "err", err) 400 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 401 + return 402 + } 403 + atUri := resp.Uri 404 + defer func() { 405 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 406 + l.Error("rollback failed", "err", err) 295 407 } 296 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 297 - Collection: tangled.RepoIssueCommentNSID, 298 - Repo: user.Did, 299 - Rkey: rkey, 300 - Record: &lexutil.LexiconTypeDecoder{ 301 - Val: &tangled.RepoIssueComment{ 302 - Repo: &atUri, 303 - Issue: issueAt, 304 - Owner: &ownerDid, 305 - Body: body, 306 - CreatedAt: createdAt, 307 - }, 308 - }, 309 - }) 310 - if err != nil { 311 - log.Println("failed to create comment", err) 312 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 - return 314 - } 408 + }() 315 409 316 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 410 + commentId, err := db.AddIssueComment(rp.db, comment) 411 + if err != nil { 412 + l.Error("failed to create comment", "err", err) 413 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 317 414 return 318 415 } 416 + 417 + // reset atUri to make rollback a no-op 418 + atUri = "" 419 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 319 420 } 320 421 321 422 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 322 424 user := rp.oauth.GetUser(r) 323 425 f, err := rp.repoResolver.Resolve(r) 324 426 if err != nil { 325 - log.Println("failed to get repo and knot", err) 427 + l.Error("failed to get repo and knot", "err", err) 326 428 return 327 429 } 328 430 329 - issueId := chi.URLParam(r, "issue") 330 - issueIdInt, err := strconv.Atoi(issueId) 331 - if err != nil { 332 - http.Error(w, "bad issue id", http.StatusBadRequest) 333 - log.Println("failed to parse issue id", err) 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 334 435 return 335 436 } 336 437 337 - commentId := chi.URLParam(r, "comment_id") 338 - commentIdInt, err := strconv.Atoi(commentId) 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 339 443 if err != nil { 340 - http.Error(w, "bad comment id", http.StatusBadRequest) 341 - log.Println("failed to parse issue id", err) 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 342 446 return 343 447 } 344 - 345 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 346 - if err != nil { 347 - log.Println("failed to get issue", err) 348 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 448 + if len(comments) != 1 { 449 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 450 + http.Error(w, "invalid comment id", http.StatusBadRequest) 349 451 return 350 452 } 453 + comment := comments[0] 351 454 352 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 353 - if err != nil { 354 - http.Error(w, "bad comment id", http.StatusBadRequest) 355 - return 356 - } 357 - 358 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 359 456 LoggedInUser: user, 360 457 RepoInfo: f.RepoInfo(user), 361 458 Issue: issue, 362 - Comment: comment, 459 + Comment: &comment, 363 460 }) 364 461 } 365 462 366 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 367 465 user := rp.oauth.GetUser(r) 368 466 f, err := rp.repoResolver.Resolve(r) 369 467 if err != nil { 370 - log.Println("failed to get repo and knot", err) 468 + l.Error("failed to get repo and knot", "err", err) 371 469 return 372 470 } 373 471 374 - issueId := chi.URLParam(r, "issue") 375 - issueIdInt, err := strconv.Atoi(issueId) 376 - if err != nil { 377 - http.Error(w, "bad issue id", http.StatusBadRequest) 378 - log.Println("failed to parse issue id", err) 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 379 476 return 380 477 } 381 478 382 - commentId := chi.URLParam(r, "comment_id") 383 - commentIdInt, err := strconv.Atoi(commentId) 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 384 484 if err != nil { 385 - http.Error(w, "bad comment id", http.StatusBadRequest) 386 - log.Println("failed to parse issue id", err) 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 387 487 return 388 488 } 389 - 390 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 391 - if err != nil { 392 - log.Println("failed to get issue", err) 393 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 489 + if len(comments) != 1 { 490 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 491 + http.Error(w, "invalid comment id", http.StatusBadRequest) 394 492 return 395 493 } 494 + comment := comments[0] 396 495 397 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 398 - if err != nil { 399 - http.Error(w, "bad comment id", http.StatusBadRequest) 400 - return 401 - } 402 - 403 - if comment.OwnerDid != user.Did { 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 404 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 405 499 return 406 500 } ··· 411 505 LoggedInUser: user, 412 506 RepoInfo: f.RepoInfo(user), 413 507 Issue: issue, 414 - Comment: comment, 508 + Comment: &comment, 415 509 }) 416 510 case http.MethodPost: 417 511 // extract form value ··· 422 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 517 return 424 518 } 425 - rkey := comment.Rkey 426 519 427 - // optimistic update 428 - edited := time.Now() 429 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 525 + 526 + _, err = db.AddIssueComment(rp.db, newComment) 430 527 if err != nil { 431 528 log.Println("failed to perferom update-description query", err) 432 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 434 531 } 435 532 436 533 // rkey is optional, it was introduced later 437 - if comment.Rkey != "" { 534 + if newComment.Rkey != "" { 438 535 // update the record on pds 439 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 440 537 if err != nil { 441 - // failed to get record 442 - log.Println(err, rkey) 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 443 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 444 540 return 445 541 } 446 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 447 - record, _ := data.UnmarshalJSON(value) 448 - 449 - repoAt := record["repo"].(string) 450 - issueAt := record["issue"].(string) 451 - createdAt := record["createdAt"].(string) 452 542 453 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 544 Collection: tangled.RepoIssueCommentNSID, 455 545 Repo: user.Did, 456 - Rkey: rkey, 546 + Rkey: newComment.Rkey, 457 547 SwapRecord: ex.Cid, 458 548 Record: &lexutil.LexiconTypeDecoder{ 459 - Val: &tangled.RepoIssueComment{ 460 - Repo: &repoAt, 461 - Issue: issueAt, 462 - Owner: &comment.OwnerDid, 463 - Body: newBody, 464 - CreatedAt: createdAt, 465 - }, 549 + Val: &record, 466 550 }, 467 551 }) 468 552 if err != nil { 469 - log.Println(err) 553 + l.Error("failed to update record on PDS", "err", err) 470 554 } 471 555 } 472 556 473 - // optimistic update for htmx 474 - comment.Body = newBody 475 - comment.Edited = &edited 476 - 477 557 // return new comment body with htmx 478 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 479 559 LoggedInUser: user, 480 560 RepoInfo: f.RepoInfo(user), 481 561 Issue: issue, 482 - Comment: comment, 562 + Comment: &newComment, 483 563 }) 564 + } 565 + } 566 + 567 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 568 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 569 + user := rp.oauth.GetUser(r) 570 + f, err := rp.repoResolver.Resolve(r) 571 + if err != nil { 572 + l.Error("failed to get repo and knot", "err", err) 484 573 return 574 + } 485 575 576 + issue, ok := r.Context().Value("issue").(*db.Issue) 577 + if !ok { 578 + l.Error("failed to get issue") 579 + rp.pages.Error404(w) 580 + return 486 581 } 487 582 583 + commentId := chi.URLParam(r, "commentId") 584 + comments, err := db.GetIssueComments( 585 + rp.db, 586 + db.FilterEq("id", commentId), 587 + ) 588 + if err != nil { 589 + l.Error("failed to fetch comment", "id", commentId) 590 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 591 + return 592 + } 593 + if len(comments) != 1 { 594 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 595 + http.Error(w, "invalid comment id", http.StatusBadRequest) 596 + return 597 + } 598 + comment := comments[0] 599 + 600 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 601 + LoggedInUser: user, 602 + RepoInfo: f.RepoInfo(user), 603 + Issue: issue, 604 + Comment: &comment, 605 + }) 488 606 } 489 607 490 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 491 610 user := rp.oauth.GetUser(r) 492 611 f, err := rp.repoResolver.Resolve(r) 493 612 if err != nil { 494 - log.Println("failed to get repo and knot", err) 613 + l.Error("failed to get repo and knot", "err", err) 495 614 return 496 615 } 497 616 498 - issueId := chi.URLParam(r, "issue") 499 - issueIdInt, err := strconv.Atoi(issueId) 500 - if err != nil { 501 - http.Error(w, "bad issue id", http.StatusBadRequest) 502 - log.Println("failed to parse issue id", err) 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 503 621 return 504 622 } 505 623 506 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 507 629 if err != nil { 508 - log.Println("failed to get issue", err) 509 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 510 632 return 511 633 } 634 + if len(comments) != 1 { 635 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 636 + http.Error(w, "invalid comment id", http.StatusBadRequest) 637 + return 638 + } 639 + comment := comments[0] 512 640 513 - commentId := chi.URLParam(r, "comment_id") 514 - commentIdInt, err := strconv.Atoi(commentId) 641 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 642 + LoggedInUser: user, 643 + RepoInfo: f.RepoInfo(user), 644 + Issue: issue, 645 + Comment: &comment, 646 + }) 647 + } 648 + 649 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 650 + l := rp.logger.With("handler", "DeleteIssueComment") 651 + user := rp.oauth.GetUser(r) 652 + f, err := rp.repoResolver.Resolve(r) 515 653 if err != nil { 516 - http.Error(w, "bad comment id", http.StatusBadRequest) 517 - log.Println("failed to parse issue id", err) 654 + l.Error("failed to get repo and knot", "err", err) 518 655 return 519 656 } 520 657 521 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 662 + return 663 + } 664 + 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 522 670 if err != nil { 523 - http.Error(w, "bad comment id", http.StatusBadRequest) 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 524 673 return 525 674 } 675 + if len(comments) != 1 { 676 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 677 + http.Error(w, "invalid comment id", http.StatusBadRequest) 678 + return 679 + } 680 + comment := comments[0] 526 681 527 - if comment.OwnerDid != user.Did { 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 528 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 529 685 return 530 686 } ··· 536 692 537 693 // optimistic deletion 538 694 deleted := time.Now() 539 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 540 696 if err != nil { 541 - log.Println("failed to delete comment") 697 + l.Error("failed to delete comment", "err", err) 542 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 543 699 return 544 700 } ··· 552 708 return 553 709 } 554 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 555 - Collection: tangled.GraphFollowNSID, 711 + Collection: tangled.RepoIssueCommentNSID, 556 712 Repo: user.Did, 557 713 Rkey: comment.Rkey, 558 714 }) ··· 566 722 comment.Deleted = &deleted 567 723 568 724 // htmx fragment of comment after deletion 569 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 570 726 LoggedInUser: user, 571 727 RepoInfo: f.RepoInfo(user), 572 728 Issue: issue, 573 - Comment: comment, 729 + Comment: &comment, 574 730 }) 575 731 } 576 732 ··· 600 756 return 601 757 } 602 758 603 - issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 759 + openVal := 0 760 + if isOpen { 761 + openVal = 1 762 + } 763 + issues, err := db.GetIssuesPaginated( 764 + rp.db, 765 + page, 766 + db.FilterEq("repo_at", f.RepoAt()), 767 + db.FilterEq("open", openVal), 768 + ) 604 769 if err != nil { 605 770 log.Println("failed to get issues", err) 606 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 617 782 } 618 783 619 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 620 786 user := rp.oauth.GetUser(r) 621 787 622 788 f, err := rp.repoResolver.Resolve(r) 623 789 if err != nil { 624 - log.Println("failed to get repo and knot", err) 790 + l.Error("failed to get repo and knot", "err", err) 625 791 return 626 792 } 627 793 ··· 632 798 RepoInfo: f.RepoInfo(user), 633 799 }) 634 800 case http.MethodPost: 635 - title := r.FormValue("title") 636 - body := r.FormValue("body") 801 + issue := &db.Issue{ 802 + RepoAt: f.RepoAt(), 803 + Rkey: tid.TID(), 804 + Title: r.FormValue("title"), 805 + Body: r.FormValue("body"), 806 + Did: user.Did, 807 + Created: time.Now(), 808 + } 637 809 638 - if title == "" || body == "" { 639 - rp.pages.Notice(w, "issues", "Title and body are required") 810 + if err := rp.validator.ValidateIssue(issue); err != nil { 811 + l.Error("validation error", "err", err) 812 + rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 640 813 return 641 814 } 642 815 643 - sanitizer := markup.NewSanitizer() 644 - if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 645 - rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization") 816 + record := issue.AsRecord() 817 + 818 + // create an atproto record 819 + client, err := rp.oauth.AuthorizedClient(r) 820 + if err != nil { 821 + l.Error("failed to get authorized client", "err", err) 822 + rp.pages.Notice(w, "issues", "Failed to create issue.") 646 823 return 647 824 } 648 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 825 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 826 + Collection: tangled.RepoIssueNSID, 827 + Repo: user.Did, 828 + Rkey: issue.Rkey, 829 + Record: &lexutil.LexiconTypeDecoder{ 830 + Val: &record, 831 + }, 832 + }) 833 + if err != nil { 834 + l.Error("failed to create issue", "err", err) 835 + rp.pages.Notice(w, "issues", "Failed to create issue.") 650 836 return 651 837 } 838 + atUri := resp.Uri 652 839 653 840 tx, err := rp.db.BeginTx(r.Context(), nil) 654 841 if err != nil { 655 842 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 656 843 return 657 844 } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 658 848 659 - issue := &db.Issue{ 660 - RepoAt: f.RepoAt(), 661 - Rkey: tid.TID(), 662 - Title: title, 663 - Body: body, 664 - OwnerDid: user.Did, 849 + if errors.Is(err1, sql.ErrTxDone) { 850 + err1 = nil 851 + } 852 + 853 + if err := errors.Join(err1, err2); err != nil { 854 + l.Error("failed to rollback txn", "err", err) 855 + } 665 856 } 666 - err = db.NewIssue(tx, issue) 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 667 860 if err != nil { 668 861 log.Println("failed to create issue", err) 669 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 670 863 return 671 864 } 672 865 673 - client, err := rp.oauth.AuthorizedClient(r) 674 - if err != nil { 675 - log.Println("failed to get authorized client", err) 676 - rp.pages.Notice(w, "issues", "Failed to create issue.") 677 - return 678 - } 679 - atUri := f.RepoAt().String() 680 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 681 - Collection: tangled.RepoIssueNSID, 682 - Repo: user.Did, 683 - Rkey: issue.Rkey, 684 - Record: &lexutil.LexiconTypeDecoder{ 685 - Val: &tangled.RepoIssue{ 686 - Repo: atUri, 687 - Title: title, 688 - Body: &body, 689 - }, 690 - }, 691 - }) 692 - if err != nil { 866 + if err = tx.Commit(); err != nil { 693 867 log.Println("failed to create issue", err) 694 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 695 869 return 696 870 } 697 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 698 874 rp.notifier.NewIssue(r.Context(), issue) 699 - 700 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 701 876 return 702 877 } 703 878 } 879 + 880 + // this is used to rollback changes made to the PDS 881 + // 882 + // it is a no-op if the provided ATURI is empty 883 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 884 + if aturi == "" { 885 + return nil 886 + } 887 + 888 + parsed := syntax.ATURI(aturi) 889 + 890 + collection := parsed.Collection().String() 891 + repo := parsed.Authority().String() 892 + rkey := parsed.RecordKey().String() 893 + 894 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 895 + Collection: collection, 896 + Repo: repo, 897 + Rkey: rkey, 898 + }) 899 + return err 900 + }
+24 -10
appview/issues/router.go
··· 12 12 13 13 r.Route("/", func(r chi.Router) { 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue()) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 21 + r.Group(func(r chi.Router) { 22 + r.Use(middleware.AuthMiddleware(i.oauth)) 23 + r.Post("/comment", i.NewIssueComment) 24 + r.Route("/comment/{commentId}/", func(r chi.Router) { 25 + r.Get("/", i.IssueComment) 26 + r.Delete("/", i.DeleteIssueComment) 27 + r.Get("/edit", i.EditIssueComment) 28 + r.Post("/edit", i.EditIssueComment) 29 + r.Get("/reply", i.ReplyIssueComment) 30 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 + }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 + r.Post("/close", i.CloseIssue) 36 + r.Post("/reopen", i.ReopenIssue) 37 + }) 38 + }) 16 39 17 40 r.Group(func(r chi.Router) { 18 41 r.Use(middleware.AuthMiddleware(i.oauth)) 19 42 r.Get("/new", i.NewIssue) 20 43 r.Post("/new", i.NewIssue) 21 - r.Post("/{issue}/comment", i.NewIssueComment) 22 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 - r.Get("/", i.IssueComment) 24 - r.Delete("/", i.DeleteIssueComment) 25 - r.Get("/edit", i.EditIssueComment) 26 - r.Post("/edit", i.EditIssueComment) 27 - }) 28 - r.Post("/{issue}/close", i.CloseIssue) 29 - r.Post("/{issue}/reopen", i.ReopenIssue) 30 44 }) 31 45 }) 32 46
+5 -34
appview/knots/knots.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 7 6 "log/slog" 8 7 "net/http" 9 8 "slices" ··· 17 16 "tangled.sh/tangled.sh/core/appview/oauth" 18 17 "tangled.sh/tangled.sh/core/appview/pages" 19 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 21 "tangled.sh/tangled.sh/core/idresolver" 22 22 "tangled.sh/tangled.sh/core/rbac" ··· 49 49 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 - 53 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 54 52 55 53 return r 56 54 } ··· 399 397 if err != nil { 400 398 l.Error("verification failed", "err", err) 401 399 402 - if errors.Is(err, serververify.FetchError) { 403 - k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 400 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 401 + k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 404 402 return 405 403 } 406 404 ··· 420 418 return 421 419 } 422 420 423 - // if this knot was previously read-only, then emit a record too 421 + // if this knot requires upgrade, then emit a record too 424 422 // 425 423 // this is part of migrating from the old knot system to the new one 426 - if registration.ReadOnly { 424 + if registration.NeedsUpgrade { 427 425 // re-announce by registering under same rkey 428 426 client, err := k.OAuth.AuthorizedClient(r) 429 427 if err != nil { ··· 484 482 return 485 483 } 486 484 updatedRegistration := registrations[0] 487 - 488 - log.Println(updatedRegistration) 489 485 490 486 w.Header().Set("HX-Reswap", "outerHTML") 491 487 k.Pages.KnotListing(w, pages.KnotListingParams{ ··· 678 674 // ok 679 675 k.Pages.HxRefresh(w) 680 676 } 681 - 682 - func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 - user := k.OAuth.GetUser(r) 684 - l := k.Logger.With("handler", "removeMember") 685 - l = l.With("did", user.Did) 686 - l = l.With("handle", user.Handle) 687 - 688 - registrations, err := db.GetRegistrations( 689 - k.Db, 690 - db.FilterEq("did", user.Did), 691 - db.FilterEq("read_only", 1), 692 - ) 693 - if err != nil { 694 - l.Error("non-fatal: failed to get registrations") 695 - return 696 - } 697 - 698 - if registrations == nil { 699 - return 700 - } 701 - 702 - k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 - Registrations: registrations, 704 - }) 705 - }
+40
appview/middleware/middleware.go
··· 275 275 } 276 276 } 277 277 278 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 + func (mw Middleware) ResolveIssue() middlewareFunc { 280 + return func(next http.Handler) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 + f, err := mw.repoResolver.Resolve(r) 283 + if err != nil { 284 + log.Println("failed to fully resolve repo", err) 285 + mw.pages.ErrorKnot404(w) 286 + return 287 + } 288 + 289 + issueIdStr := chi.URLParam(r, "issue") 290 + issueId, err := strconv.Atoi(issueIdStr) 291 + if err != nil { 292 + log.Println("failed to fully resolve issue ID", err) 293 + mw.pages.ErrorKnot404(w) 294 + return 295 + } 296 + 297 + issues, err := db.GetIssues( 298 + mw.db, 299 + db.FilterEq("repo_at", f.RepoAt()), 300 + db.FilterEq("issue_id", issueId), 301 + ) 302 + if err != nil { 303 + log.Println("failed to get issues", "err", err) 304 + return 305 + } 306 + if len(issues) != 1 { 307 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 + return 309 + } 310 + issue := issues[0] 311 + 312 + ctx := context.WithValue(r.Context(), "issue", &issue) 313 + next.ServeHTTP(w, r.WithContext(ctx)) 314 + }) 315 + } 316 + } 317 + 278 318 // this should serve the go-import meta tag even if the path is technically 279 319 // a 404 like tangled.sh/oppi.li/go-git/v5 280 320 func (mw Middleware) GoImport() middlewareFunc {
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+3
appview/pages/funcmap.go
··· 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 32 35 "resolve": func(s string) string { 33 36 identity, err := p.resolver.ResolveIdent(context.Background(), s) 34 37
+12
appview/pages/markup/format.go
··· 13 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 14 } 15 15 16 + // ReadmeFilenames contains the list of common README filenames to search for, 17 + // in order of preference. Only includes well-supported formats. 18 + var ReadmeFilenames = []string{ 19 + "README.md", "readme.md", 20 + "README", 21 + "readme", 22 + "README.markdown", 23 + "readme.markdown", 24 + "README.txt", 25 + "readme.txt", 26 + } 27 + 16 28 func GetFormat(filename string) Format { 17 29 for format, extensions := range FileTypes { 18 30 for _, extension := range extensions {
+10 -8
appview/pages/markup/markdown.go
··· 22 22 "github.com/yuin/goldmark/util" 23 23 htmlparse "golang.org/x/net/html" 24 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 25 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 27 ) 27 28 ··· 231 232 232 233 actualPath := rctx.actualPath(dst) 233 234 235 + repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 236 + 237 + query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 238 + url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 239 + 234 240 parsedURL := &url.URL{ 235 - Scheme: scheme, 236 - Host: rctx.Knot, 237 - Path: path.Join("/", 238 - rctx.RepoInfo.OwnerDid, 239 - rctx.RepoInfo.Name, 240 - "raw", 241 - url.PathEscape(rctx.RepoInfo.Ref), 242 - actualPath), 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 243 245 } 244 246 newPath := parsedURL.String() 245 247 return newPath
+270 -190
appview/pages/pages.go
··· 9 9 "html/template" 10 10 "io" 11 11 "io/fs" 12 - "log" 12 + "log/slog" 13 13 "net/http" 14 14 "os" 15 15 "path/filepath" ··· 42 42 var Files embed.FS 43 43 44 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 47 48 48 avatar config.AvatarConfig 49 49 resolver *idresolver.Resolver 50 50 dev bool 51 - embedFS embed.FS 51 + embedFS fs.FS 52 52 templateDir string // Path to templates on disk for dev mode 53 53 rctx *markup.RenderContext 54 + logger *slog.Logger 54 55 } 55 56 56 57 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 64 65 65 66 p := &Pages{ 66 67 mu: sync.RWMutex{}, 67 - t: make(map[string]*template.Template), 68 + cache: NewTmplCache[string, *template.Template](), 68 69 dev: config.Core.Dev, 69 70 avatar: config.Avatar, 70 - embedFS: Files, 71 71 rctx: rctx, 72 72 resolver: res, 73 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 74 75 } 75 76 76 - // Initial load of all templates 77 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 78 82 79 83 return p 80 84 } 81 85 82 - func (p *Pages) loadAllTemplates() { 83 - templates := make(map[string]*template.Template) 84 - var fragmentPaths []string 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 85 89 86 - // Use embedded FS for initial loading 87 - // First, collect all fragment paths 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 96 + var fragmentPaths []string 88 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 89 98 if err != nil { 90 99 return err ··· 98 107 if !strings.Contains(path, "fragments/") { 99 108 return nil 100 109 } 101 - name := strings.TrimPrefix(path, "templates/") 102 - name = strings.TrimSuffix(name, ".html") 103 - tmpl, err := template.New(name). 104 - Funcs(p.funcMap()). 105 - ParseFS(p.embedFS, path) 106 - if err != nil { 107 - log.Fatalf("setting up fragment: %v", err) 108 - } 109 - templates[name] = tmpl 110 110 fragmentPaths = append(fragmentPaths, path) 111 - log.Printf("loaded fragment: %s", name) 112 111 return nil 113 112 }) 114 113 if err != nil { 115 - log.Fatalf("walking template dir for fragments: %v", err) 114 + return nil, err 116 115 } 117 116 118 - // Then walk through and setup the rest of the templates 119 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 120 - if err != nil { 121 - return err 122 - } 123 - if d.IsDir() { 124 - return nil 125 - } 126 - if !strings.HasSuffix(path, "html") { 127 - return nil 128 - } 129 - // Skip fragments as they've already been loaded 130 - if strings.Contains(path, "fragments/") { 131 - return nil 132 - } 133 - // Skip layouts 134 - if strings.Contains(path, "layouts/") { 135 - return nil 136 - } 137 - name := strings.TrimPrefix(path, "templates/") 138 - name = strings.TrimSuffix(name, ".html") 139 - // Add the page template on top of the base 140 - allPaths := []string{} 141 - allPaths = append(allPaths, "templates/layouts/*.html") 142 - allPaths = append(allPaths, fragmentPaths...) 143 - allPaths = append(allPaths, path) 144 - tmpl, err := template.New(name). 145 - Funcs(p.funcMap()). 146 - ParseFS(p.embedFS, allPaths...) 147 - if err != nil { 148 - return fmt.Errorf("setting up template: %w", err) 149 - } 150 - templates[name] = tmpl 151 - log.Printf("loaded template: %s", name) 152 - return nil 153 - }) 117 + return fragmentPaths, nil 118 + } 119 + 120 + // parse without memoization 121 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 122 + paths, err := p.fragmentPaths() 154 123 if err != nil { 155 - log.Fatalf("walking template dir: %v", err) 124 + return nil, err 156 125 } 157 - 158 - log.Printf("total templates loaded: %d", len(templates)) 159 - p.mu.Lock() 160 - defer p.mu.Unlock() 161 - p.t = templates 162 - } 163 - 164 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 165 - func (p *Pages) loadTemplateFromDisk(name string) error { 166 - if !p.dev { 167 - return nil 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 168 128 } 169 129 170 - log.Printf("reloading template from disk: %s", name) 171 - 172 - // Find all fragments first 173 - var fragmentPaths []string 174 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 175 - if err != nil { 176 - return err 177 - } 178 - if d.IsDir() { 179 - return nil 180 - } 181 - if !strings.HasSuffix(path, ".html") { 182 - return nil 183 - } 184 - if !strings.Contains(path, "fragments/") { 185 - return nil 186 - } 187 - fragmentPaths = append(fragmentPaths, path) 188 - return nil 189 - }) 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 190 135 if err != nil { 191 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 136 + return nil, err 192 137 } 193 138 194 - // Find the template path on disk 195 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 196 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 197 - return fmt.Errorf("template not found on disk: %s", name) 198 - } 139 + return parsed, nil 140 + } 199 141 200 - // Create a new template 201 - tmpl := template.New(name).Funcs(p.funcMap()) 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 202 144 203 - // Parse layouts 204 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 205 - layouts, err := filepath.Glob(layoutGlob) 145 + // never cache in dev mode 146 + if cached, exists := p.cache.Get(key); !p.dev && exists { 147 + return cached, nil 148 + } 149 + 150 + result, err := p.rawParse(stack...) 206 151 if err != nil { 207 - return fmt.Errorf("finding layout templates: %w", err) 152 + return nil, err 208 153 } 209 154 210 - // Create paths for parsing 211 - allFiles := append(layouts, fragmentPaths...) 212 - allFiles = append(allFiles, templatePath) 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 213 158 214 - // Parse all templates 215 - tmpl, err = tmpl.ParseFiles(allFiles...) 216 - if err != nil { 217 - return fmt.Errorf("parsing template files: %w", err) 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 218 163 } 164 + return p.parse(stack...) 165 + } 219 166 220 - // Update the template in the map 221 - p.mu.Lock() 222 - defer p.mu.Unlock() 223 - p.t[name] = tmpl 224 - log.Printf("template reloaded from disk: %s", name) 225 - return nil 167 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 168 + stack := []string{ 169 + "layouts/base", 170 + "layouts/repobase", 171 + top, 172 + } 173 + return p.parse(stack...) 226 174 } 227 175 228 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 229 - // In dev mode, reload the template from disk before executing 230 - if p.dev { 231 - if err := p.loadTemplateFromDisk(templateName); err != nil { 232 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 233 - // Continue with the existing template 234 - } 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 235 181 } 182 + return p.parse(stack...) 183 + } 236 184 237 - p.mu.RLock() 238 - defer p.mu.RUnlock() 239 - tmpl, exists := p.t[templateName] 240 - if !exists { 241 - return fmt.Errorf("template not found: %s", templateName) 185 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 186 + tpl, err := p.parse(name) 187 + if err != nil { 188 + return err 242 189 } 243 190 244 - if base == "" { 245 - return tmpl.Execute(w, params) 246 - } else { 247 - return tmpl.ExecuteTemplate(w, base, params) 248 - } 191 + return tpl.Execute(w, params) 249 192 } 250 193 251 194 func (p *Pages) execute(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "layouts/base", params) 253 - } 195 + tpl, err := p.parseBase(name) 196 + if err != nil { 197 + return err 198 + } 254 199 255 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "", params) 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 257 201 } 258 202 259 203 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 260 - return p.executeOrReload(name, w, "layouts/repobase", params) 204 + tpl, err := p.parseRepoBase(name) 205 + if err != nil { 206 + return err 207 + } 208 + 209 + return tpl.ExecuteTemplate(w, "layouts/base", params) 210 + } 211 + 212 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 213 + tpl, err := p.parseProfileBase(name) 214 + if err != nil { 215 + return err 216 + } 217 + 218 + return tpl.ExecuteTemplate(w, "layouts/base", params) 261 219 } 262 220 263 221 func (p *Pages) Favicon(w io.Writer) error { ··· 282 240 283 241 type TermsOfServiceParams struct { 284 242 LoggedInUser *oauth.User 243 + Content template.HTML 285 244 } 286 245 287 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 + filename := "terms.md" 248 + filePath := filepath.Join("legal", filename) 249 + markdownBytes, err := os.ReadFile(filePath) 250 + if err != nil { 251 + return fmt.Errorf("failed to read %s: %w", filename, err) 252 + } 253 + 254 + p.rctx.RendererType = markup.RendererTypeDefault 255 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 256 + sanitized := p.rctx.SanitizeDefault(htmlString) 257 + params.Content = template.HTML(sanitized) 258 + 288 259 return p.execute("legal/terms", w, params) 289 260 } 290 261 291 262 type PrivacyPolicyParams struct { 292 263 LoggedInUser *oauth.User 264 + Content template.HTML 293 265 } 294 266 295 267 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 + filename := "privacy.md" 269 + filePath := filepath.Join("legal", filename) 270 + markdownBytes, err := os.ReadFile(filePath) 271 + if err != nil { 272 + return fmt.Errorf("failed to read %s: %w", filename, err) 273 + } 274 + 275 + p.rctx.RendererType = markup.RendererTypeDefault 276 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 277 + sanitized := p.rctx.SanitizeDefault(htmlString) 278 + params.Content = template.HTML(sanitized) 279 + 296 280 return p.execute("legal/privacy", w, params) 297 281 } 298 282 ··· 338 322 return p.execute("user/settings/emails", w, params) 339 323 } 340 324 341 - type KnotBannerParams struct { 325 + type UpgradeBannerParams struct { 342 326 Registrations []db.Registration 327 + Spindles []db.Spindle 343 328 } 344 329 345 - func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 - return p.executePlain("knots/fragments/banner", w, params) 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 347 332 } 348 333 349 334 type KnotsParams struct { ··· 422 407 return p.execute("repo/fork", w, params) 423 408 } 424 409 425 - type ProfileHomePageParams struct { 410 + type ProfileCard struct { 411 + UserDid string 412 + UserHandle string 413 + FollowStatus db.FollowStatus 414 + Punchcard *db.Punchcard 415 + Profile *db.Profile 416 + Stats ProfileStats 417 + Active string 418 + } 419 + 420 + type ProfileStats struct { 421 + RepoCount int64 422 + StarredCount int64 423 + StringCount int64 424 + FollowersCount int64 425 + FollowingCount int64 426 + } 427 + 428 + func (p *ProfileCard) GetTabs() [][]any { 429 + tabs := [][]any{ 430 + {"overview", "overview", "square-chart-gantt", nil}, 431 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 432 + {"starred", "starred", "star", p.Stats.StarredCount}, 433 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 434 + } 435 + 436 + return tabs 437 + } 438 + 439 + type ProfileOverviewParams struct { 426 440 LoggedInUser *oauth.User 427 441 Repos []db.Repo 428 442 CollaboratingRepos []db.Repo 429 443 ProfileTimeline *db.ProfileTimeline 430 - Card ProfileCard 431 - Punchcard db.Punchcard 444 + Card *ProfileCard 445 + Active string 432 446 } 433 447 434 - type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount int 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 451 + } 440 452 441 - Profile *db.Profile 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 442 458 } 443 459 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 - return p.execute("user/profile", w, params) 460 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 461 + params.Active = "repos" 462 + return p.executeProfile("user/repos", w, params) 446 463 } 447 464 448 - type ReposPageParams struct { 465 + type ProfileStarredParams struct { 449 466 LoggedInUser *oauth.User 450 467 Repos []db.Repo 451 - Card ProfileCard 468 + Card *ProfileCard 469 + Active string 452 470 } 453 471 454 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 - return p.execute("user/repos", w, params) 472 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 473 + params.Active = "starred" 474 + return p.executeProfile("user/starred", w, params) 475 + } 476 + 477 + type ProfileStringsParams struct { 478 + LoggedInUser *oauth.User 479 + Strings []db.String 480 + Card *ProfileCard 481 + Active string 482 + } 483 + 484 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 485 + params.Active = "strings" 486 + return p.executeProfile("user/strings", w, params) 456 487 } 457 488 458 489 type FollowCard struct { 459 490 UserDid string 460 491 FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 492 + FollowersCount int64 493 + FollowingCount int64 463 494 Profile *db.Profile 464 495 } 465 496 466 - type FollowersPageParams struct { 497 + type ProfileFollowersParams struct { 467 498 LoggedInUser *oauth.User 468 499 Followers []FollowCard 469 - Card ProfileCard 500 + Card *ProfileCard 501 + Active string 470 502 } 471 503 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 474 507 } 475 508 476 - type FollowingPageParams struct { 509 + type ProfileFollowingParams struct { 477 510 LoggedInUser *oauth.User 478 511 Following []FollowCard 479 - Card ProfileCard 512 + Card *ProfileCard 513 + Active string 480 514 } 481 515 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 484 519 } 485 520 486 521 type FollowFragmentParams struct { ··· 553 588 VerifiedCommits commitverify.VerifiedCommits 554 589 Languages []types.RepoLanguageDetails 555 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 556 592 types.RepoIndexResponse 557 593 } 558 594 ··· 562 598 return p.executeRepo("repo/empty", w, params) 563 599 } 564 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 565 605 p.rctx.RepoInfo = params.RepoInfo 566 606 p.rctx.RepoInfo.Ref = params.Ref 567 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 649 689 650 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 651 691 params.Active = "overview" 652 - return p.execute("repo/tree", w, params) 692 + return p.executeRepo("repo/tree", w, params) 653 693 } 654 694 655 695 type RepoBranchesParams struct { ··· 700 740 ShowRendered bool 701 741 RenderToggle bool 702 742 RenderedContents template.HTML 703 - types.RepoBlobResponse 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 704 749 } 705 750 706 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 835 880 RepoInfo repoinfo.RepoInfo 836 881 Active string 837 882 Issue *db.Issue 838 - Comments []db.Comment 883 + CommentList []db.CommentListItem 839 884 IssueOwnerHandle string 840 885 841 886 OrderedReactionKinds []db.ReactionKind 842 887 Reactions map[db.ReactionKind]int 843 888 UserReacted map[db.ReactionKind]bool 889 + } 844 890 845 - State string 891 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 892 + params.Active = "issues" 893 + return p.executeRepo("repo/issues/issue", w, params) 894 + } 895 + 896 + type EditIssueParams struct { 897 + LoggedInUser *oauth.User 898 + RepoInfo repoinfo.RepoInfo 899 + Issue *db.Issue 900 + Action string 901 + } 902 + 903 + func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 904 + params.Action = "edit" 905 + return p.executePlain("repo/issues/fragments/putIssue", w, params) 846 906 } 847 907 848 908 type ThreadReactionFragmentParams struct { ··· 856 916 return p.executePlain("repo/fragments/reaction", w, params) 857 917 } 858 918 859 - func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 860 - params.Active = "issues" 861 - if params.Issue.Open { 862 - params.State = "open" 863 - } else { 864 - params.State = "closed" 865 - } 866 - return p.execute("repo/issues/issue", w, params) 867 - } 868 - 869 919 type RepoNewIssueParams struct { 870 920 LoggedInUser *oauth.User 871 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 872 923 Active string 924 + Action string 873 925 } 874 926 875 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 876 928 params.Active = "issues" 929 + params.Action = "create" 877 930 return p.executeRepo("repo/issues/new", w, params) 878 931 } 879 932 ··· 881 934 LoggedInUser *oauth.User 882 935 RepoInfo repoinfo.RepoInfo 883 936 Issue *db.Issue 884 - Comment *db.Comment 937 + Comment *db.IssueComment 885 938 } 886 939 887 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 888 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 889 942 } 890 943 891 - type SingleIssueCommentParams struct { 944 + type ReplyIssueCommentPlaceholderParams struct { 892 945 LoggedInUser *oauth.User 893 946 RepoInfo repoinfo.RepoInfo 894 947 Issue *db.Issue 895 - Comment *db.Comment 948 + Comment *db.IssueComment 896 949 } 897 950 898 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 899 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 952 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 953 + } 954 + 955 + type ReplyIssueCommentParams struct { 956 + LoggedInUser *oauth.User 957 + RepoInfo repoinfo.RepoInfo 958 + Issue *db.Issue 959 + Comment *db.IssueComment 960 + } 961 + 962 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 963 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 964 + } 965 + 966 + type IssueCommentBodyParams struct { 967 + LoggedInUser *oauth.User 968 + RepoInfo repoinfo.RepoInfo 969 + Issue *db.Issue 970 + Comment *db.IssueComment 971 + } 972 + 973 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 974 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 900 975 } 901 976 902 977 type RepoNewPullParams struct { ··· 1262 1337 return p.execute("strings/string", w, params) 1263 1338 } 1264 1339 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1265 1344 func (p *Pages) Static() http.Handler { 1266 1345 if p.dev { 1267 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1269 1348 1270 1349 sub, err := fs.Sub(Files, "static") 1271 1350 if err != nil { 1272 - log.Fatalf("no static dir found? that's crazy: %v", err) 1351 + p.logger.Error("no static dir found? that's crazy", "err", err) 1352 + panic(err) 1273 1353 } 1274 1354 // Custom handler to apply Cache-Control headers for font files 1275 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1292 1372 func CssContentHash() string { 1293 1373 cssFile, err := Files.Open("static/tw.css") 1294 1374 if err != nil { 1295 - log.Printf("Error opening CSS file: %v", err) 1375 + slog.Debug("Error opening CSS file", "err", err) 1296 1376 return "" 1297 1377 } 1298 1378 defer cssFile.Close() 1299 1379 1300 1380 hasher := sha256.New() 1301 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1302 - log.Printf("Error hashing CSS file: %v", err) 1382 + slog.Debug("Error hashing CSS file", "err", err) 1303 1383 return "" 1304 1384 } 1305 1385
+2 -7
appview/pages/repoinfo/repoinfo.go
··· 78 78 func (r RepoInfo) TabMetadata() map[string]any { 79 79 meta := make(map[string]any) 80 80 81 - if r.Stats.PullCount.Open > 0 { 82 - meta["pulls"] = r.Stats.PullCount.Open 83 - } 84 - 85 - if r.Stats.IssueCount.Open > 0 { 86 - meta["issues"] = r.Stats.IssueCount.Open 87 - } 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 88 83 89 84 // more stuff? 90 85
+38
appview/pages/templates/banner.html
··· 1 + {{ define "banner" }} 2 + <div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200"> 3 + <details class="group p-2"> 4 + <summary class="list-none cursor-pointer"> 5 + <div class="flex gap-4 items-center"> 6 + <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span> 8 + 9 + <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span> 10 + <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span> 11 + </div> 12 + </summary> 13 + 14 + {{ if .Registrations }} 15 + <ul class="list-disc mx-12 my-2"> 16 + {{range .Registrations}} 17 + <li>Knot: {{ .Domain }}</li> 18 + {{ end }} 19 + </ul> 20 + {{ end }} 21 + 22 + {{ if .Spindles }} 23 + <ul class="list-disc mx-12 my-2"> 24 + {{range .Spindles}} 25 + <li>Spindle: {{ .Instance }}</li> 26 + {{ end }} 27 + </ul> 28 + {{ end }} 29 + 30 + <div class="mx-6"> 31 + These services may not be fully accessible until upgraded. 32 + <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md"> 34 + Click to read the upgrade guide</a>. 35 + </div> 36 + </details> 37 + </div> 38 + {{ end }}
+1 -1
appview/pages/templates/errors/404.html
··· 17 17 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 18 </p> 19 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"> 20 + <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 go back 23 23 </a>
+4 -4
appview/pages/templates/errors/500.html
··· 8 8 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 - 11 + 12 12 <div class="space-y-4"> 13 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 14 500 &mdash; internal server error ··· 24 24 <p class="mt-1">Our team has been automatically notified about this error.</p> 25 25 </div> 26 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"> 27 + <button onclick="location.reload()" class="btn-create gap-2"> 28 28 {{ i "refresh-cw" "w-4 h-4" }} 29 29 try again 30 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"> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 32 {{ i "home" "w-4 h-4" }} 33 33 back to home 34 34 </a> ··· 36 36 </div> 37 37 </div> 38 38 </div> 39 - {{ end }} 39 + {{ end }}
+2 -2
appview/pages/templates/errors/503.html
··· 17 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 18 </p> 19 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"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 21 {{ i "refresh-cw" "w-4 h-4" }} 22 22 try again 23 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"> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 25 {{ i "arrow-left" "w-4 h-4" }} 26 26 back to timeline 27 27 </a>
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 18 </p> 19 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"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 21 {{ i "arrow-left" "w-4 h-4" }} 22 22 back to timeline 23 23 </a>
+8
appview/pages/templates/fragments/logotype.html
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + <span class="font-bold italic">tangled</span> 4 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 + alpha 6 + </span> 7 + <span> 8 + {{ end }}
-9
appview/pages/templates/knots/fragments/banner.html
··· 1 - {{ define "knots/fragments/banner" }} 2 - <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 - A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 - that you administer is presently read-only. Consider upgrading this knot to 5 - continue creating repositories on it. 6 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 - </div> 8 - {{ end }} 9 -
+2 -2
appview/pages/templates/knots/fragments/knotListing.html
··· 36 36 </span> 37 37 {{ template "knots/fragments/addMemberModal" . }} 38 38 {{ block "knotDeleteButton" . }} {{ end }} 39 - {{ else if .IsReadOnly }} 39 + {{ else if .IsNeedsUpgrade }} 40 40 <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 - {{ i "shield-alert" "w-4 h-4" }} read-only 41 + {{ i "shield-alert" "w-4 h-4" }} needs upgrade 42 42 </span> 43 43 {{ block "knotRetryButton" . }} {{ end }} 44 44 {{ block "knotDeleteButton" . }} {{ end }}
+12 -10
appview/pages/templates/knots/index.html
··· 1 1 {{ define "title" }}knots{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 19 {{ end }} 16 20 17 21 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Knots are lightweight headless servers that enable users to host Git repositories with ease. 21 - Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โ€œcommunityโ€ servers. 22 - When creating a repository, you can choose a knot to store it on. 23 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 24 - Checkout the documentation if you're interested in self-hosting. 25 - </a> 22 + <section class="rounded"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 25 + When creating a repository, you can choose a knot to store it on. 26 26 </p> 27 - </section> 27 + 28 + 29 + </section> 28 30 {{ end }} 29 31 30 32 {{ define "list" }}
+27 -12
appview/pages/templates/layouts/base.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 + <meta name="description" content="Social coding, but for real this time!"/> 10 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 11 - <script src="/static/htmx.min.js"></script> 12 - <script src="/static/htmx-ext-ws.min.js"></script> 9 + 10 + <script defer src="/static/htmx.min.js"></script> 11 + <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + 13 + <!-- preconnect to image cdn --> 14 + <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 + <link rel="preconnect" href="https://camo.tangled.sh" /> 16 + 17 + <!-- preload main font --> 18 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 + 13 20 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 14 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 22 {{ block "extrameta" . }}{{ end }} 16 23 </head> 17 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 25 {{ block "topbarLayout" . }} 19 - <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 - {{ template "layouts/topbar" . }} 26 + <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 27 + 28 + {{ if .LoggedInUser }} 29 + <div id="upgrade-banner" 30 + hx-get="/upgradeBanner" 31 + hx-trigger="load" 32 + hx-swap="innerHTML"> 33 + </div> 34 + {{ end }} 35 + {{ template "layouts/fragments/topbar" . }} 21 36 </header> 22 37 {{ end }} 23 38 24 39 {{ block "mainLayout" . }} 25 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 40 + <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 26 41 {{ block "contentLayout" . }} 27 42 <main class="col-span-1 md:col-span-8"> 28 43 {{ block "content" . }}{{ end }} ··· 38 53 {{ end }} 39 54 40 55 {{ block "footerLayout" . }} 41 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 - {{ template "layouts/footer" . }} 56 + <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 57 + {{ template "layouts/fragments/footer" . }} 43 58 </footer> 44 59 {{ end }} 45 60 </body>
-48
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 - tangled<sub>alpha</sub> 8 - </a> 9 - </div> 10 - 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 - </div> 20 - 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - </div> 27 - 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 - </div> 34 - 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 - </div> 40 - </div> 41 - 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 - </div> 45 - </div> 46 - </div> 47 - </div> 48 - {{ end }}
+48
appview/pages/templates/layouts/fragments/footer.html
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 + </div> 46 + </div> 47 + </div> 48 + {{ end }}
+78
appview/pages/templates/layouts/fragments/topbar.html
··· 1 + {{ define "layouts/fragments/topbar" }} 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 + <div class="flex justify-between p-0 items-center"> 4 + <div id="left-items"> 5 + <a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a> 6 + </div> 7 + 8 + <div id="right-items" class="flex items-center gap-2"> 9 + {{ with .LoggedInUser }} 10 + {{ block "newButton" . }} {{ end }} 11 + {{ block "dropDown" . }} {{ end }} 12 + {{ else }} 13 + <a href="/login">login</a> 14 + <span class="text-gray-500 dark:text-gray-400">or</span> 15 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 16 + join now {{ i "arrow-right" "size-4" }} 17 + </a> 18 + {{ end }} 19 + </div> 20 + </div> 21 + </nav> 22 + {{ end }} 23 + 24 + {{ define "newButton" }} 25 + <details class="relative inline-block text-left nav-dropdown"> 26 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 27 + {{ i "plus" "w-4 h-4" }} new 28 + </summary> 29 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 30 + <a href="/repo/new" class="flex items-center gap-2"> 31 + {{ i "book-plus" "w-4 h-4" }} 32 + new repository 33 + </a> 34 + <a href="/strings/new" class="flex items-center gap-2"> 35 + {{ i "line-squiggle" "w-4 h-4" }} 36 + new string 37 + </a> 38 + </div> 39 + </details> 40 + {{ end }} 41 + 42 + {{ define "dropDown" }} 43 + <details class="relative inline-block text-left nav-dropdown"> 44 + <summary 45 + class="cursor-pointer list-none flex items-center" 46 + > 47 + {{ $user := didOrHandle .Did .Handle }} 48 + {{ template "user/fragments/picHandle" $user }} 49 + </summary> 50 + <div 51 + class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 52 + > 53 + <a href="/{{ $user }}">profile</a> 54 + <a href="/{{ $user }}?tab=repos">repositories</a> 55 + <a href="/{{ $user }}?tab=strings">strings</a> 56 + <a href="/knots">knots</a> 57 + <a href="/spindles">spindles</a> 58 + <a href="/settings">settings</a> 59 + <a href="#" 60 + hx-post="/logout" 61 + hx-swap="none" 62 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 63 + logout 64 + </a> 65 + </div> 66 + </details> 67 + 68 + <script> 69 + document.addEventListener('click', function(event) { 70 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 71 + dropdowns.forEach(function(dropdown) { 72 + if (!dropdown.contains(event.target)) { 73 + dropdown.removeAttribute('open'); 74 + } 75 + }); 76 + }); 77 + </script> 78 + {{ end }}
+104
appview/pages/templates/layouts/profilebase.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + <div class="md:col-span-3 order-1 md:order-1"> 15 + <div class="flex flex-col gap-4"> 16 + {{ template "user/fragments/profileCard" .Card }} 17 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 18 + </div> 19 + </div> 20 + {{ block "profileContent" . }} {{ end }} 21 + </div> 22 + </section> 23 + {{ end }} 24 + 25 + {{ define "profileTabs" }} 26 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 27 + <div class="flex z-60"> 28 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 29 + {{ $tabs := .Card.GetTabs }} 30 + {{ $tabmeta := dict "x" "y" }} 31 + {{ range $item := $tabs }} 32 + {{ $key := index $item 0 }} 33 + {{ $value := index $item 1 }} 34 + {{ $icon := index $item 2 }} 35 + {{ $meta := index $item 3 }} 36 + <a 37 + href="?tab={{ $value }}" 38 + class="relative -mr-px group no-underline hover:no-underline" 39 + hx-boost="true"> 40 + <div 41 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 42 + {{ if eq $.Active $key }} 43 + {{ $activeTabStyles }} 44 + {{ else }} 45 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 46 + {{ end }} 47 + "> 48 + <span class="flex items-center justify-center"> 49 + {{ i $icon "w-4 h-4 mr-2" }} 50 + {{ $key }} 51 + {{ if $meta }} 52 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 53 + {{ end }} 54 + </span> 55 + </div> 56 + </a> 57 + {{ end }} 58 + </div> 59 + </nav> 60 + {{ end }} 61 + 62 + {{ define "punchcard" }} 63 + {{ $now := now }} 64 + <div> 65 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 66 + PUNCHCARD 67 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 68 + {{ .Total | int64 | commaFmt }} commits 69 + </span> 70 + </p> 71 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 72 + {{ range .Punches }} 73 + {{ $count := .Count }} 74 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 75 + {{ if lt $count 1 }} 76 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 77 + {{ else if lt $count 2 }} 78 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 79 + {{ else if lt $count 4 }} 80 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 81 + {{ else if lt $count 8 }} 82 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 83 + {{ else }} 84 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 85 + {{ end }} 86 + 87 + {{ if .Date.After $now }} 88 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 89 + {{ end }} 90 + <div class="w-full h-full flex justify-center items-center"> 91 + <div 92 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 93 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 94 + </div> 95 + </div> 96 + {{ end }} 97 + </div> 98 + </div> 99 + {{ end }} 100 + 101 + {{ define "layouts/profilebase" }} 102 + {{ template "layouts/base" . }} 103 + {{ end }} 104 +
+4 -8
appview/pages/templates/layouts/repobase.html
··· 42 42 </section> 43 43 44 44 <section 45 - class="w-full flex flex-col drop-shadow-sm" 45 + class="w-full flex flex-col" 46 46 > 47 47 <nav class="w-full pl-4 overflow-auto"> 48 48 <div class="flex z-60"> ··· 71 71 <span class="flex items-center justify-center"> 72 72 {{ i $icon "w-4 h-4 mr-2" }} 73 73 {{ $key }} 74 - {{ if not (isNil $meta) }} 75 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 74 + {{ if $meta }} 75 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 76 76 {{ end }} 77 77 </span> 78 78 </div> ··· 81 81 </div> 82 82 </nav> 83 83 <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 85 > 86 86 {{ block "repoContent" . }}{{ end }} 87 87 </section> 88 88 {{ block "repoAfter" . }}{{ end }} 89 89 </section> 90 90 {{ end }} 91 - 92 - {{ define "layouts/repobase" }} 93 - {{ template "layouts/base" . }} 94 - {{ end }}
-87
appview/pages/templates/layouts/topbar.html
··· 1 - {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="flex justify-between p-0 items-center"> 4 - <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - 10 - <div id="right-items" class="flex items-center gap-2"> 11 - {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ block "dropDown" . }} {{ end }} 14 - {{ else }} 15 - <a href="/login">login</a> 16 - <span class="text-gray-500 dark:text-gray-400">or</span> 17 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 - join now {{ i "arrow-right" "size-4" }} 19 - </a> 20 - {{ end }} 21 - </div> 22 - </div> 23 - </nav> 24 - {{ if .LoggedInUser }} 25 - <div id="upgrade-banner" 26 - hx-get="/knots/upgradeBanner" 27 - hx-trigger="load" 28 - hx-swap="innerHTML"> 29 - </div> 30 - {{ end }} 31 - {{ end }} 32 - 33 - {{ define "newButton" }} 34 - <details class="relative inline-block text-left nav-dropdown"> 35 - <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 - {{ i "plus" "w-4 h-4" }} new 37 - </summary> 38 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 - <a href="/repo/new" class="flex items-center gap-2"> 40 - {{ i "book-plus" "w-4 h-4" }} 41 - new repository 42 - </a> 43 - <a href="/strings/new" class="flex items-center gap-2"> 44 - {{ i "line-squiggle" "w-4 h-4" }} 45 - new string 46 - </a> 47 - </div> 48 - </details> 49 - {{ end }} 50 - 51 - {{ define "dropDown" }} 52 - <details class="relative inline-block text-left nav-dropdown"> 53 - <summary 54 - class="cursor-pointer list-none flex items-center" 55 - > 56 - {{ $user := didOrHandle .Did .Handle }} 57 - {{ template "user/fragments/picHandle" $user }} 58 - </summary> 59 - <div 60 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 61 - > 62 - <a href="/{{ $user }}">profile</a> 63 - <a href="/{{ $user }}?tab=repos">repositories</a> 64 - <a href="/strings/{{ $user }}">strings</a> 65 - <a href="/knots">knots</a> 66 - <a href="/spindles">spindles</a> 67 - <a href="/settings">settings</a> 68 - <a href="#" 69 - hx-post="/logout" 70 - hx-swap="none" 71 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 72 - logout 73 - </a> 74 - </div> 75 - </details> 76 - 77 - <script> 78 - document.addEventListener('click', function(event) { 79 - const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 - dropdowns.forEach(function(dropdown) { 81 - if (!dropdown.contains(event.target)) { 82 - dropdown.removeAttribute('open'); 83 - } 84 - }); 85 - }); 86 - </script> 87 - {{ end }}
+4 -126
appview/pages/templates/legal/privacy.html
··· 1 - {{ define "title" }} privacy policy {{ end }} 1 + {{ define "title" }}privacy policy{{ end }} 2 + 2 3 {{ define "content" }} 3 4 <div class="max-w-4xl mx-auto px-4 py-8"> 4 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 6 - <h1>Privacy Policy</h1> 7 - 8 - <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 - 10 - <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 - 12 - <h2>1. Information We Collect</h2> 13 - 14 - <h3>Account Information</h3> 15 - <p>When you create an account, we collect:</p> 16 - <ul> 17 - <li>Your chosen username</li> 18 - <li>Email address</li> 19 - <li>Profile information you choose to provide</li> 20 - <li>Authentication data</li> 21 - </ul> 22 - 23 - <h3>Content and Activity</h3> 24 - <p>We store:</p> 25 - <ul> 26 - <li>Code repositories and associated metadata</li> 27 - <li>Issues, pull requests, and comments</li> 28 - <li>Activity logs and usage patterns</li> 29 - <li>Public keys for authentication</li> 30 - </ul> 31 - 32 - <h2>2. Data Location and Hosting</h2> 33 - <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 - <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 - <p class="text-blue-700 dark:text-blue-300"> 36 - <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 - </p> 38 - <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 - <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 - <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 - <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 - </ul> 43 - </div> 44 - 45 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 - <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 - <p class="text-yellow-700 dark:text-yellow-300"> 48 - <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 - </p> 50 - </div> 51 - 52 - <h2>3. Third-Party Data Processors</h2> 53 - <p>We only share your data with the following third-party processors:</p> 54 - 55 - <h3>Resend (Email Services)</h3> 56 - <ul> 57 - <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 - <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 - <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 - </ul> 61 - 62 - <h3>Cloudflare (Image Caching)</h3> 63 - <ul> 64 - <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 - <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 - <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 - </ul> 68 - 69 - <h2>4. How We Use Your Information</h2> 70 - <p>We use your information to:</p> 71 - <ul> 72 - <li>Provide and maintain the Service</li> 73 - <li>Process your transactions and requests</li> 74 - <li>Send you technical notices and support messages</li> 75 - <li>Improve and develop new features</li> 76 - <li>Ensure security and prevent fraud</li> 77 - <li>Comply with legal obligations</li> 78 - </ul> 79 - 80 - <h2>5. Data Sharing and Disclosure</h2> 81 - <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 - <ul> 83 - <li>With the third-party processors listed above</li> 84 - <li>When required by law or legal process</li> 85 - <li>To protect our rights, property, or safety, or that of our users</li> 86 - <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 - </ul> 88 - 89 - <h2>6. Data Security</h2> 90 - <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 - 92 - <h2>7. Data Retention</h2> 93 - <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 - 95 - <h2>8. Your Rights</h2> 96 - <p>Under applicable data protection laws, you have the right to:</p> 97 - <ul> 98 - <li>Access your personal information</li> 99 - <li>Correct inaccurate information</li> 100 - <li>Request deletion of your information</li> 101 - <li>Object to processing of your information</li> 102 - <li>Data portability</li> 103 - <li>Withdraw consent (where applicable)</li> 104 - </ul> 105 - 106 - <h2>9. Cookies and Tracking</h2> 107 - <p>We use cookies and similar technologies to:</p> 108 - <ul> 109 - <li>Maintain your login session</li> 110 - <li>Remember your preferences</li> 111 - <li>Analyze usage patterns to improve the Service</li> 112 - </ul> 113 - <p>You can control cookie settings through your browser preferences.</p> 114 - 115 - <h2>10. Children's Privacy</h2> 116 - <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 - 118 - <h2>11. International Data Transfers</h2> 119 - <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 - 121 - <h2>12. Changes to This Privacy Policy</h2> 122 - <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 - 124 - <h2>13. Contact Information</h2> 125 - <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 126 - 127 - <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 128 - <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 - </div> 7 + {{ .Content }} 130 8 </div> 131 9 </div> 132 10 </div> 133 - {{ end }} 11 + {{ end }}
+2 -62
appview/pages/templates/legal/terms.html
··· 4 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - <h1>Terms of Service</h1> 8 - 9 - <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 - 11 - <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 - 13 - <h2>1. Acceptance of Terms</h2> 14 - <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 - 16 - <h2>2. Account Registration</h2> 17 - <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 - 19 - <h2>3. Account Termination</h2> 20 - <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 - <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 - <p class="text-red-700 dark:text-red-300"> 23 - <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 - </p> 25 - <p class="text-red-700 dark:text-red-300 mt-2"> 26 - Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 - </p> 28 - </div> 29 - 30 - <h2>4. Acceptable Use</h2> 31 - <p>You agree not to use the Service to:</p> 32 - <ul> 33 - <li>Violate any applicable laws or regulations</li> 34 - <li>Infringe upon the rights of others</li> 35 - <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 - <li>Engage in spam, phishing, or other deceptive practices</li> 37 - <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 - <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 - </ul> 40 - 41 - <h2>5. Content and Intellectual Property</h2> 42 - <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 - 44 - <h2>6. Privacy</h2> 45 - <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 - 47 - <h2>7. Disclaimers</h2> 48 - <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 - 50 - <h2>8. Limitation of Liability</h2> 51 - <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 - 53 - <h2>9. Indemnification</h2> 54 - <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 - 56 - <h2>10. Governing Law</h2> 57 - <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 - 59 - <h2>11. Changes to Terms</h2> 60 - <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 - 62 - <h2>12. Contact Information</h2> 63 - <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 64 - 65 - <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 66 - <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 - </div> 7 + {{ .Content }} 68 8 </div> 69 9 </div> 70 10 </div> 71 - {{ end }} 11 + {{ end }}
+2 -2
appview/pages/templates/repo/commit.html
··· 81 81 82 82 {{ define "topbarLayout" }} 83 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 - {{ template "layouts/topbar" . }} 84 + {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 ··· 106 106 107 107 {{ define "footerLayout" }} 108 108 <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 109 + {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }} 112 112
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 12 12 13 13 {{ define "topbarLayout" }} 14 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 15 + {{ template "layouts/fragments/topbar" . }} 16 16 </header> 17 17 {{ end }} 18 18 ··· 37 37 38 38 {{ define "footerLayout" }} 39 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 40 + {{ template "layouts/fragments/footer" . }} 41 41 </footer> 42 42 {{ end }} 43 43
+1 -1
appview/pages/templates/repo/fork.html
··· 19 19 class="mr-2" 20 20 id="domain-{{ . }}" 21 21 /> 22 - <span class="dark:text-white">{{ . }}</span> 22 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 23 </div> 24 24 {{ else }} 25 25 <p class="dark:text-white">No knots available.</p>
+6
appview/pages/templates/repo/fragments/diff.html
··· 11 11 {{ $last := sub (len $diff) 1 }} 12 12 13 13 <div class="flex flex-col gap-4"> 14 + {{ if eq (len $diff) 0 }} 15 + <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 16 + <p>No differences found between the selected revisions.</p> 17 + </div> 18 + {{ else }} 14 19 {{ range $idx, $hunk := $diff }} 15 20 {{ with $hunk }} 16 21 <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> ··· 49 54 </div> 50 55 </details> 51 56 {{ end }} 57 + {{ end }} 52 58 {{ end }} 53 59 </div> 54 60 {{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+6
appview/pages/templates/repo/fragments/languageBall.html
··· 1 + {{ define "repo/fragments/languageBall" }} 2 + <div 3 + class="size-2 rounded-full" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));" 5 + ></div> 6 + {{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 + {{ end }} 4 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 1 {{ define "repo/fragments/time" }} 6 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 3 {{ end }} 8 - 9 - {{ define "repo/fragments/shortTime" }} 10 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 - {{ end }} 12 - 13 - {{ define "repo/fragments/shortTimeAgo" }} 14 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 - {{ end }} 16 - 17 - {{ define "repo/fragments/duration" }} 18 - <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 - {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+25 -8
appview/pages/templates/repo/index.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t"> 38 + <details class="group -m-6 mb-4"> 39 + <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 + {{ range $value := .Languages }} 41 + <div 42 + title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 43 + style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 + ></div> 45 + {{ end }} 46 + </summary> 47 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 39 48 {{ range $value := .Languages }} 40 - <div 41 - title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 42 - class="h-[4px] rounded-full" 43 - style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 - ></div> 49 + <div 50 + class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 + > 52 + {{ template "repo/fragments/languageBall" $value.Name }} 53 + <div>{{ or $value.Name "Other" }} 54 + <span class="text-gray-500 dark:text-gray-400"> 55 + {{ if lt $value.Percentage 0.05 }} 56 + 0.1% 57 + {{ else }} 58 + {{ printf "%.1f" $value.Percentage }}% 59 + {{ end }} 60 + </span></div> 61 + </div> 45 62 {{ end }} 46 - </div> 63 + </div> 64 + </details> 47 65 {{ end }} 48 - 49 66 50 67 {{ define "branchSelector" }} 51 68 <div class="flex gap-2 items-center justify-between w-full">
+58
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 + {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-8"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListing" (list $ .) }} 5 + {{ end }} 6 + <div> 7 + {{ end }} 8 + 9 + {{ define "commentListing" }} 10 + {{ $root := index . 0 }} 11 + {{ $comment := index . 1 }} 12 + {{ $params := 13 + (dict 14 + "RepoInfo" $root.RepoInfo 15 + "LoggedInUser" $root.LoggedInUser 16 + "Issue" $root.Issue 17 + "Comment" $comment.Self) }} 18 + 19 + <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 20 + {{ template "topLevelComment" $params }} 21 + 22 + <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 23 + {{ range $index, $reply := $comment.Replies }} 24 + <div class="relative "> 25 + <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 27 + 28 + <div class="pl-2"> 29 + {{ 30 + template "replyComment" 31 + (dict 32 + "RepoInfo" $root.RepoInfo 33 + "LoggedInUser" $root.LoggedInUser 34 + "Issue" $root.Issue 35 + "Comment" $reply) 36 + }} 37 + </div> 38 + </div> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ define "topLevelComment" }} 47 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 + {{ template "repo/issues/fragments/issueCommentBody" . }} 50 + </div> 51 + {{ end }} 52 + 53 + {{ define "replyComment" }} 54 + <div class="p-4 w-full mx-auto overflow-hidden"> 55 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 + {{ template "repo/issues/fragments/issueCommentBody" . }} 57 + </div> 58 + {{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 7 9 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - author 13 - {{ end }} 14 - 15 - <span class="before:content-['ยท']"></span> 16 - <a 17 - href="#{{ .CommentId }}" 18 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 19 - id="{{ .CommentId }}"> 20 - {{ template "repo/fragments/time" .Created }} 21 - </a> 22 - 23 - <button 24 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 25 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 26 - hx-include="#edit-textarea-{{ .CommentId }}" 27 - hx-target="#comment-container-{{ .CommentId }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "w-4 h-4" }} 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </button> 32 - <button 33 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 35 - hx-target="#comment-container-{{ .CommentId }}" 36 - hx-swap="outerHTML"> 37 - {{ i "x" "w-4 h-4" }} 38 - </button> 39 - <span id="comment-{{.CommentId}}-status"></span> 40 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 41 13 42 - <div> 43 - <textarea 44 - id="edit-textarea-{{ .CommentId }}" 45 - name="body" 46 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 47 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 48 18 </div> 49 - {{ end }} 19 + {{ end }} 20 + 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 50 32 {{ end }} 51 33 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 6 - 7 - <!-- show user "hats" --> 8 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 9 - {{ if $isIssueAuthor }} 10 - <span class="before:content-['ยท']"></span> 11 - author 12 - {{ end }} 13 - 14 - <span class="before:content-['ยท']"></span> 15 - <a 16 - href="#{{ .CommentId }}" 17 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 18 - id="{{ .CommentId }}"> 19 - {{ if .Deleted }} 20 - deleted {{ template "repo/fragments/time" .Deleted }} 21 - {{ else if .Edited }} 22 - edited {{ template "repo/fragments/time" .Edited }} 23 - {{ else }} 24 - {{ template "repo/fragments/time" .Created }} 25 - {{ end }} 26 - </a> 27 - 28 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 29 - {{ if and $isCommentOwner (not .Deleted) }} 30 - <button 31 - class="btn px-2 py-1 text-sm" 32 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 33 - hx-swap="outerHTML" 34 - hx-target="#comment-container-{{.CommentId}}" 35 - > 36 - {{ i "pencil" "w-4 h-4" }} 37 - </button> 38 - <button 39 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 40 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 41 - hx-confirm="Are you sure you want to delete your comment?" 42 - hx-swap="outerHTML" 43 - hx-target="#comment-container-{{.CommentId}}" 44 - > 45 - {{ i "trash-2" "w-4 h-4" }} 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - {{ end }} 49 - 50 - </div> 51 - {{ if not .Deleted }} 52 - <div class="prose dark:prose-invert"> 53 - {{ .Body | markdown }} 54 - </div> 55 - {{ end }} 56 - </div> 57 - {{ end }} 58 - {{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
··· 1 + {{ define "repo/issues/fragments/issueCommentActions" }} 2 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 3 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 4 + <div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2"> 5 + {{ template "edit" . }} 6 + {{ template "delete" . }} 7 + </div> 8 + {{ end }} 9 + {{ end }} 10 + 11 + {{ define "edit" }} 12 + <a 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 14 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 + hx-swap="outerHTML" 16 + hx-target="#comment-body-{{.Comment.Id}}"> 17 + {{ i "pencil" "size-3" }} 18 + edit 19 + </a> 20 + {{ end }} 21 + 22 + {{ define "delete" }} 23 + <a 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 25 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 + hx-confirm="Are you sure you want to delete your comment?" 27 + hx-swap="outerHTML" 28 + hx-target="#comment-body-{{.Comment.Id}}" 29 + > 30 + {{ i "trash-2" "size-3" }} 31 + delete 32 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </a> 34 + {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 5 + {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 + {{ template "editIssueComment" . }} 9 + {{ template "deleteIssueComment" . }} 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ end }} 32 + </a> 33 + {{ end }} 34 + 35 + {{ define "editIssueComment" }} 36 + <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 + hx-swap="outerHTML" 40 + hx-target="#comment-body-{{.Comment.Id}}"> 41 + {{ i "pencil" "size-3" }} 42 + </a> 43 + {{ end }} 44 + 45 + {{ define "deleteIssueComment" }} 46 + <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 + hx-confirm="Are you sure you want to delete your comment?" 50 + hx-swap="outerHTML" 51 + hx-target="#comment-body-{{.Comment.Id}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 38 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 39 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 40 + {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 41 + {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }} 42 + <button 43 + id="close-button" 44 + type="button" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 84 + } 85 + }); 86 + </script> 87 + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 88 + <button 89 + type="button" 90 + class="btn flex items-center gap-2" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 143 + </div> 144 + {{ end }} 145 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 + {{ define "repo/issues/fragments/replyComment" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + id="reply-form-{{ .Comment.Id }}" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 + > 9 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 10 + <textarea 11 + id="reply-{{.Comment.Id}}-textarea" 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 21 + 22 + <input 23 + type="text" 24 + id="reply-to" 25 + name="reply-to" 26 + required 27 + value="{{ .Comment.AtUri }}" 28 + class="hidden" 29 + /> 30 + {{ template "replyActions" . }} 31 + </form> 32 + {{ end }} 33 + 34 + {{ define "replyActions" }} 35 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 36 + {{ template "cancel" . }} 37 + {{ template "reply" . }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ define "cancel" }} 42 + <button 43 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 44 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 45 + hx-target="#reply-form-{{ .Comment.Id }}" 46 + hx-swap="outerHTML"> 47 + {{ i "x" "size-4" }} 48 + cancel 49 + </button> 50 + {{ end }} 51 + 52 + {{ define "reply" }} 53 + <button 54 + id="reply-{{ .Comment.Id }}" 55 + type="submit" 56 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 57 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + reply 60 + </button> 61 + {{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 + {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full py-2 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+95 -202
appview/pages/templates/repo/issues/issue.html
··· 9 9 {{ end }} 10 10 11 11 {{ define "repoContent" }} 12 - <header class="pb-4"> 13 - <h1 class="text-2xl"> 14 - {{ .Issue.Title | description }} 15 - <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 - </h1> 17 - </header> 12 + <section id="issue-{{ .Issue.IssueId }}"> 13 + {{ template "issueHeader" .Issue }} 14 + {{ template "issueInfo" . }} 15 + {{ if .Issue.Body }} 16 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 + {{ end }} 18 + {{ template "issueReactions" . }} 19 + </section> 20 + {{ end }} 18 21 19 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 20 - {{ $icon := "ban" }} 21 - {{ if eq .State "open" }} 22 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 23 - {{ $icon = "circle-dot" }} 24 - {{ end }} 22 + {{ define "issueHeader" }} 23 + <header class="pb-2"> 24 + <h1 class="text-2xl"> 25 + {{ .Title | description }} 26 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 27 + </h1> 28 + </header> 29 + {{ end }} 25 30 26 - <section class="mt-2"> 27 - <div class="inline-flex items-center gap-2"> 28 - <div id="state" 29 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .State }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 - opened by 35 - {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandleLink" $owner }} 37 - <span class="select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" .Issue.Created }} 39 - </span> 40 - </div> 31 + {{ define "issueInfo" }} 32 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 33 + {{ $icon := "ban" }} 34 + {{ if eq .Issue.State "open" }} 35 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 36 + {{ $icon = "circle-dot" }} 37 + {{ end }} 38 + <div class="inline-flex items-center gap-2"> 39 + <div id="state" 40 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 41 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 42 + <span class="text-white">{{ .Issue.State }}</span> 43 + </div> 44 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 45 + opened by 46 + {{ template "user/fragments/picHandleLink" .Issue.Did }} 47 + <span class="select-none before:content-['\00B7']"></span> 48 + {{ if .Issue.Edited }} 49 + edited {{ template "repo/fragments/time" .Issue.Edited }} 50 + {{ else }} 51 + {{ template "repo/fragments/time" .Issue.Created }} 52 + {{ end }} 53 + </span> 41 54 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 55 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 56 + {{ template "issueActions" . }} 57 + {{ end }} 58 + </div> 59 + <div id="issue-actions-error" class="error"></div> 60 + {{ end }} 47 61 48 - <div class="flex items-center gap-2 mt-2"> 49 - {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 - {{ range $kind := .OrderedReactionKinds }} 51 - {{ 52 - template "repo/fragments/reaction" 53 - (dict 54 - "Kind" $kind 55 - "Count" (index $.Reactions $kind) 56 - "IsReacted" (index $.UserReacted $kind) 57 - "ThreadAt" $.Issue.AtUri) 58 - }} 59 - {{ end }} 60 - </div> 61 - </section> 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 62 65 {{ end }} 63 66 64 - {{ define "repoAfter" }} 65 - <section id="comments" class="my-2 mt-2 space-y-2 relative"> 66 - {{ range $index, $comment := .Comments }} 67 - <div 68 - id="comment-{{ .CommentId }}" 69 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 70 - {{ if gt $index 0 }} 71 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 72 - {{ end }} 73 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}} 74 - </div> 75 - {{ end }} 76 - </section> 67 + {{ define "editIssue" }} 68 + <a 69 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 70 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 71 + hx-swap="innerHTML" 72 + hx-target="#issue-{{.Issue.IssueId}}"> 73 + {{ i "pencil" "size-3" }} 74 + </a> 75 + {{ end }} 77 76 78 - {{ block "newComment" . }} {{ end }} 77 + {{ define "deleteIssue" }} 78 + <a 79 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 80 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 81 + hx-confirm="Are you sure you want to delete your issue?" 82 + hx-swap="none"> 83 + {{ i "trash-2" "size-3" }} 84 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </a> 86 + {{ end }} 79 87 88 + {{ define "issueReactions" }} 89 + <div class="flex items-center gap-2 mt-2"> 90 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 + {{ range $kind := .OrderedReactionKinds }} 92 + {{ 93 + template "repo/fragments/reaction" 94 + (dict 95 + "Kind" $kind 96 + "Count" (index $.Reactions $kind) 97 + "IsReacted" (index $.UserReacted $kind) 98 + "ThreadAt" $.Issue.AtUri) 99 + }} 100 + {{ end }} 101 + </div> 80 102 {{ end }} 81 103 82 - {{ define "newComment" }} 83 - {{ if .LoggedInUser }} 84 - <form 85 - id="comment-form" 86 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 87 - hx-on::after-request="if(event.detail.successful) this.reset()" 88 - > 89 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 90 - <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 91 - {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 92 - </div> 93 - <textarea 94 - id="comment-textarea" 95 - name="body" 96 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 97 - placeholder="Add to the discussion. Markdown is supported." 98 - onkeyup="updateCommentForm()" 99 - ></textarea> 100 - <div id="issue-comment"></div> 101 - <div id="issue-action" class="error"></div> 102 - </div> 103 - 104 - <div class="flex gap-2 mt-2"> 105 - <button 106 - id="comment-button" 107 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 108 - type="submit" 109 - hx-disabled-elt="#comment-button" 110 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group" 111 - disabled 112 - > 113 - {{ i "message-square-plus" "w-4 h-4" }} 114 - comment 115 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 116 - </button> 117 - 118 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 119 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 120 - {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 121 - {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }} 122 - <button 123 - id="close-button" 124 - type="button" 125 - class="btn flex items-center gap-2" 126 - hx-indicator="#close-spinner" 127 - hx-trigger="click" 128 - > 129 - {{ i "ban" "w-4 h-4" }} 130 - close 131 - <span id="close-spinner" class="group"> 132 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 133 - </span> 134 - </button> 135 - <div 136 - id="close-with-comment" 137 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 138 - hx-trigger="click from:#close-button" 139 - hx-disabled-elt="#close-with-comment" 140 - hx-target="#issue-comment" 141 - hx-indicator="#close-spinner" 142 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 143 - hx-swap="none" 144 - > 145 - </div> 146 - <div 147 - id="close-issue" 148 - hx-disabled-elt="#close-issue" 149 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 150 - hx-trigger="click from:#close-button" 151 - hx-target="#issue-action" 152 - hx-indicator="#close-spinner" 153 - hx-swap="none" 154 - > 155 - </div> 156 - <script> 157 - document.addEventListener('htmx:configRequest', function(evt) { 158 - if (evt.target.id === 'close-with-comment') { 159 - const commentText = document.getElementById('comment-textarea').value.trim(); 160 - if (commentText === '') { 161 - evt.detail.parameters = {}; 162 - evt.preventDefault(); 163 - } 164 - } 165 - }); 166 - </script> 167 - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} 168 - <button 169 - type="button" 170 - class="btn flex items-center gap-2" 171 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 172 - hx-indicator="#reopen-spinner" 173 - hx-swap="none" 174 - > 175 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 176 - reopen 177 - <span id="reopen-spinner" class="group"> 178 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 179 - </span> 180 - </button> 181 - {{ end }} 182 - 183 - <script> 184 - function updateCommentForm() { 185 - const textarea = document.getElementById('comment-textarea'); 186 - const commentButton = document.getElementById('comment-button'); 187 - const closeButton = document.getElementById('close-button'); 188 - 189 - if (textarea.value.trim() !== '') { 190 - commentButton.removeAttribute('disabled'); 191 - } else { 192 - commentButton.setAttribute('disabled', ''); 193 - } 104 + {{ define "repoAfter" }} 105 + <div class="flex flex-col gap-4 mt-4"> 106 + {{ 107 + template "repo/issues/fragments/commentList" 108 + (dict 109 + "RepoInfo" $.RepoInfo 110 + "LoggedInUser" $.LoggedInUser 111 + "Issue" $.Issue 112 + "CommentList" $.Issue.CommentList) 113 + }} 194 114 195 - if (closeButton) { 196 - if (textarea.value.trim() !== '') { 197 - closeButton.innerHTML = ` 198 - {{ i "ban" "w-4 h-4" }} 199 - <span>close with comment</span> 200 - <span id="close-spinner" class="group"> 201 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 202 - </span>`; 203 - } else { 204 - closeButton.innerHTML = ` 205 - {{ i "ban" "w-4 h-4" }} 206 - <span>close</span> 207 - <span id="close-spinner" class="group"> 208 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 209 - </span>`; 210 - } 211 - } 212 - } 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 117 + {{ end }} 213 118 214 - document.addEventListener('DOMContentLoaded', function() { 215 - updateCommentForm(); 216 - }); 217 - </script> 218 - </div> 219 - </form> 220 - {{ else }} 221 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 222 - <a href="/login" class="underline">login</a> to join the discussion 223 - </div> 224 - {{ end }} 225 - {{ end }}
+42 -44
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 61 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 66 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 69 - </span> 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 70 71 - <span class="before:content-['ยท']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 74 75 - <span class="before:content-['ยท']"> 76 - {{ $s := "s" }} 77 - {{ if eq .Metadata.CommentCount 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 81 - </span> 82 - </p> 75 + <span class="before:content-['ยท']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + </p> 83 + </div> 84 + {{ end }} 83 85 </div> 84 - {{ end }} 85 - </div> 86 - 87 - {{ block "pagination" . }} {{ end }} 88 - 86 + {{ block "pagination" . }} {{ end }} 89 87 {{ end }} 90 88 91 89 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="mt-6 space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn-create flex items-center gap-2"> 27 - {{ i "circle-plus" "w-4 h-4" }} 28 - create issue 29 - <span id="create-pull-spinner" class="group"> 30 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 - </span> 32 - </button> 33 - </div> 34 - </div> 35 - <div id="issues" class="error"></div> 36 - </form> 4 + {{ template "repo/issues/fragments/putIssue" . }} 37 5 {{ end }}
+60
appview/pages/templates/repo/needsUpgrade.html
··· 1 + {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 + {{ define "extrameta" }} 3 + {{ template "repo/fragments/meta" . }} 4 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 5 + {{ end }} 6 + {{ define "repoContent" }} 7 + <main> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600"> 10 + <!-- mimic the repo view here, placeholders are LLM generated --> 11 + <div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 12 + {{ $files := 13 + (list 14 + "src" 15 + "docs" 16 + "config" 17 + "lib" 18 + "index.html" 19 + "log.html" 20 + "needsUpgrade.html" 21 + "new.html" 22 + "tags.html" 23 + "tree.html") 24 + }} 25 + {{ range $files }} 26 + <span> 27 + {{ if (contains . ".") }} 28 + {{ i "file" "size-4 inline-flex" }} 29 + {{ else }} 30 + {{ i "folder" "size-4 inline-flex fill-current" }} 31 + {{ end }} 32 + 33 + {{ . }} 34 + </span> 35 + {{ end }} 36 + </div> 37 + <div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 38 + {{ $commits := 39 + (list 40 + "Fix authentication bug in login flow" 41 + "Add new dashboard widgets for metrics" 42 + "Implement real-time notifications system") 43 + }} 44 + {{ range $commits }} 45 + <div class="flex flex-col"> 46 + <span>{{ . }}</span> 47 + <span class="text-xs">{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + </div> 51 + </div> 52 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 53 + <div class="text-center"> 54 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 55 + The knot hosting this repository needs an upgrade. This repository is currently unavailable. 56 + </div> 57 + </div> 58 + </div> 59 + </main> 60 + {{ end }}
+1 -1
appview/pages/templates/repo/new.html
··· 49 49 class="mr-2" 50 50 id="domain-{{ . }}" 51 51 /> 52 - <span class="dark:text-white">{{ . }}</span> 52 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 53 </div> 54 54 {{ else }} 55 55 <p class="dark:text-white">No knots available.</p>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 52 </div> 53 53 {{ end }} 54 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 56 </div> 57 57 </div> 58 58 </a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 2 {{ $pull := index . 0 }} 3 3 {{ $pipeline := index . 1 }} 4 4 {{ with $pull }}
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
··· 30 30 31 31 {{ define "topbarLayout" }} 32 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/topbar" . }} 33 + {{ template "layouts/fragments/topbar" . }} 34 34 </header> 35 35 {{ end }} 36 36 ··· 55 55 56 56 {{ define "footerLayout" }} 57 57 <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 58 + {{ template "layouts/fragments/footer" . }} 59 59 </footer> 60 60 {{ end }} 61 61
+2 -2
appview/pages/templates/repo/pulls/patch.html
··· 36 36 37 37 {{ define "topbarLayout" }} 38 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/topbar" . }} 39 + {{ template "layouts/fragments/topbar" . }} 40 40 </header> 41 41 {{ end }} 42 42 ··· 61 61 62 62 {{ define "footerLayout" }} 63 63 <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 64 + {{ template "layouts/fragments/footer" . }} 65 65 </footer> 66 66 {{ end }} 67 67
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 144 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 145 145 <div class="flex gap-2 items-center px-6"> 146 146 <div class="flex-grow min-w-0 w-full py-2"> 147 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 148 </div> 149 149 </div> 150 150 </a>
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 30 30 {{ define "spindleRightSide" }} 31 31 <div id="right-side" class="flex gap-2"> 32 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .Verified }} 33 + 34 + {{ if .NeedsUpgrade }} 35 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span> 36 + {{ block "spindleRetryButton" . }} {{ end }} 37 + {{ else if .Verified }} 34 38 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 35 39 {{ template "spindles/fragments/addMemberModal" . }} 36 40 {{ else }} 37 41 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 38 42 {{ block "spindleRetryButton" . }} {{ end }} 39 43 {{ end }} 44 + 40 45 {{ block "spindleDeleteButton" . }} {{ end }} 41 46 </div> 42 47 {{ end }}
+10 -9
appview/pages/templates/spindles/index.html
··· 1 1 {{ define "title" }}spindles{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 19 {{ end }} 16 20 17 21 {{ define "about" }} 18 - <section class="rounded flex flex-col gap-2"> 19 - <p class="dark:text-gray-300"> 20 - Spindles are small CI runners. 21 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 22 - Checkout the documentation if you're interested in self-hosting. 23 - </a> 22 + <section class="rounded flex items-center gap-2"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Spindles are small CI runners. 24 25 </p> 25 - </section> 26 + </section> 26 27 {{ end }} 27 28 28 29 {{ define "list" }}
-4
appview/pages/templates/strings/put.html
··· 1 1 {{ define "title" }}publish a new string{{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 <div class="px-6 py-2 mb-4"> 9 5 {{ if eq .Action "new" }}
-4
appview/pages/templates/strings/string.html
··· 8 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 9 {{ end }} 10 10 11 - {{ define "topbar" }} 12 - {{ template "layouts/topbar" $ }} 13 - {{ end }} 14 - 15 11 {{ define "content" }} 16 12 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
-4
appview/pages/templates/strings/timeline.html
··· 1 1 {{ define "title" }} all strings {{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 {{ block "timeline" $ }}{{ end }} 9 5 {{ end }}
+34
appview/pages/templates/timeline/fragments/hero.html
··· 1 + {{ define "timeline/fragments/hero" }} 2 + <div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 + <div class="flex flex-col gap-6"> 4 + <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 + 6 + <p class="text-lg"> 7 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 + </p> 9 + <p class="text-lg"> 10 + we envision a place where developers have complete ownership of their 11 + code, open source communities can freely self-govern and most 12 + importantly, coding can be social and fun again. 13 + </p> 14 + 15 + <div class="flex gap-6 items-center"> 16 + <a href="/signup" class="no-underline hover:no-underline "> 17 + <button class="btn-create flex gap-2 px-4 items-center"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </button> 20 + </a> 21 + </div> 22 + </div> 23 + 24 + <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="block"> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 + </a> 28 + <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 + Monorepo for Tangled, built in the open with the community. 30 + </figcaption> 31 + </figure> 32 + </div> 33 + {{ end }} 34 +
+116
appview/pages/templates/timeline/fragments/timeline.html
··· 1 + {{ define "timeline/fragments/timeline" }} 2 + <div class="py-4"> 3 + <div class="px-6 pb-4"> 4 + <p class="text-xl font-bold dark:text-white">Timeline</p> 5 + </div> 6 + 7 + <div class="flex flex-col gap-4"> 8 + {{ range $i, $e := .Timeline }} 9 + <div class="relative"> 10 + {{ if ne $i 0 }} 11 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 12 + {{ end }} 13 + {{ with $e }} 14 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 + {{ if .Repo }} 16 + {{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }} 17 + {{ else if .Star }} 18 + {{ template "timeline/fragments/starEvent" (list $ .Star) }} 19 + {{ else if .Follow }} 20 + {{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }} 21 + {{ end }} 22 + </div> 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + {{ define "timeline/fragments/repoEvent" }} 31 + {{ $root := index . 0 }} 32 + {{ $repo := index . 1 }} 33 + {{ $source := index . 2 }} 34 + {{ $userHandle := resolve $repo.Did }} 35 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 36 + {{ template "user/fragments/picHandleLink" $repo.Did }} 37 + {{ with $source }} 38 + {{ $sourceDid := resolve .Did }} 39 + forked 40 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 41 + {{ $sourceDid }}/{{ .Name }} 42 + </a> 43 + to 44 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 45 + {{ else }} 46 + created 47 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 48 + {{ $repo.Name }} 49 + </a> 50 + {{ end }} 51 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 52 + </div> 53 + {{ with $repo }} 54 + {{ template "user/fragments/repoCard" (list $root . true) }} 55 + {{ end }} 56 + {{ end }} 57 + 58 + {{ define "timeline/fragments/starEvent" }} 59 + {{ $root := index . 0 }} 60 + {{ $star := index . 1 }} 61 + {{ with $star }} 62 + {{ $starrerHandle := resolve .StarredByDid }} 63 + {{ $repoOwnerHandle := resolve .Repo.Did }} 64 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 65 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 66 + starred 67 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 68 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 69 + </a> 70 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 71 + </div> 72 + {{ with .Repo }} 73 + {{ template "user/fragments/repoCard" (list $root . true) }} 74 + {{ end }} 75 + {{ end }} 76 + {{ end }} 77 + 78 + {{ define "timeline/fragments/followEvent" }} 79 + {{ $root := index . 0 }} 80 + {{ $follow := index . 1 }} 81 + {{ $profile := index . 2 }} 82 + {{ $stat := index . 3 }} 83 + 84 + {{ $userHandle := resolve $follow.UserDid }} 85 + {{ $subjectHandle := resolve $follow.SubjectDid }} 86 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 87 + {{ template "user/fragments/picHandleLink" $userHandle }} 88 + followed 89 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 90 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 91 + </div> 92 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 93 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 94 + <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 95 + </div> 96 + 97 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 98 + <a href="/{{ $subjectHandle }}"> 99 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 100 + </a> 101 + {{ with $profile }} 102 + {{ with .Description }} 103 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 104 + {{ end }} 105 + {{ end }} 106 + {{ with $stat }} 107 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 108 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 109 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 110 + <span class="select-none after:content-['ยท']"></span> 111 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 112 + </div> 113 + {{ end }} 114 + </div> 115 + </div> 116 + {{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
··· 1 + {{ define "timeline/fragments/trending" }} 2 + <div class="w-full md:mx-0 py-4"> 3 + <div class="px-6 pb-4"> 4 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 5 + Trending 6 + {{ i "trending-up" "size-4 flex-shrink-0" }} 7 + </h3> 8 + </div> 9 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 10 + {{ range $index, $repo := .Repos }} 11 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 12 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 13 + </div> 14 + {{ else }} 15 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 17 + No trending repositories this week 18 + </div> 19 + </div> 20 + {{ end }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + 25 +
+90
appview/pages/templates/timeline/home.html
··· 1 + {{ define "title" }}tangled &middot; tightly-knit social coding{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="flex flex-col gap-4"> 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ template "features" . }} 15 + {{ template "timeline/fragments/trending" . }} 16 + {{ template "timeline/fragments/timeline" . }} 17 + <div class="flex justify-end"> 18 + <a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400"> 19 + view more 20 + {{ i "arrow-right" "size-4" }} 21 + </a> 22 + </div> 23 + </div> 24 + {{ end }} 25 + 26 + 27 + {{ define "feature" }} 28 + {{ $info := index . 0 }} 29 + {{ $bullets := index . 1 }} 30 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 31 + <div class="flex-1"> 32 + <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 33 + <ul class="leading-normal"> 34 + {{ range $bullets }} 35 + <li><p>{{ escapeHtml . }}</p></li> 36 + {{ end }} 37 + </ul> 38 + </div> 39 + <div class="flex-shrink-0 w-96 md:w-1/3"> 40 + <a href="{{ $info.image }}"> 41 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 42 + </a> 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "features" }} 48 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 49 + {{ template "feature" (list 50 + (dict 51 + "title" "lightweight git repo hosting" 52 + "image" "https://assets.tangled.network/what-is-tangled-repo.png" 53 + "alt" "A repository hosted on Tangled" 54 + ) 55 + (list 56 + "Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations." 57 + "Add friends to your knot or invite collaborators to your repository." 58 + "Guarded by fine-grained role-based access control." 59 + "Use SSH to push and pull." 60 + ) 61 + ) }} 62 + 63 + {{ template "feature" (list 64 + (dict 65 + "title" "improved pull request model" 66 + "image" "https://assets.tangled.network/pulls.png" 67 + "alt" "Round-based pull requests." 68 + ) 69 + (list 70 + "An intuitive and effective round-based pull request flow, with inter-diffing between rounds." 71 + "Stacked pull requests using Jujutsu's change IDs." 72 + "Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes." 73 + ) 74 + ) }} 75 + 76 + {{ template "feature" (list 77 + (dict 78 + "title" "run pipelines using spindles" 79 + "image" "https://assets.tangled.network/pipelines.png" 80 + "alt" "CI pipeline running on spindle" 81 + ) 82 + (list 83 + "Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners." 84 + "Natively supports Nix for package management." 85 + "Easily extended to support different execution backends." 86 + ) 87 + ) }} 88 + </div> 89 + {{ end }} 90 +
+6 -171
appview/pages/templates/timeline/timeline.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 - {{ if .LoggedInUser }} 12 - {{ else }} 13 - {{ block "hero" $ }}{{ end }} 14 - {{ end }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 15 16 - {{ block "trending" $ }}{{ end }} 17 - {{ block "timeline" $ }}{{ end }} 18 - {{ end }} 19 - 20 - {{ define "hero" }} 21 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 22 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 23 - 24 - <p class="text-lg"> 25 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 26 - </p> 27 - <p class="text-lg"> 28 - we envision a place where developers have complete ownership of their 29 - code, open source communities can freely self-govern and most 30 - importantly, coding can be social and fun again. 31 - </p> 32 - 33 - <div class="flex gap-6 items-center"> 34 - <a href="/signup" class="no-underline hover:no-underline "> 35 - <button class="btn-create flex gap-2 px-4 items-center"> 36 - join now {{ i "arrow-right" "size-4" }} 37 - </button> 38 - </a> 39 - </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ define "trending" }} 44 - <div class="w-full md:mx-0 py-4"> 45 - <div class="px-6 pb-4"> 46 - <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 47 - Trending 48 - {{ i "trending-up" "size-4 flex-shrink-0" }} 49 - </h3> 50 - </div> 51 - <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 52 - {{ range $index, $repo := .Repos }} 53 - <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 54 - {{ template "user/fragments/repoCard" (list $ $repo true) }} 55 - </div> 56 - {{ else }} 57 - <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 58 - <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 59 - No trending repositories this week 60 - </div> 61 - </div> 62 - {{ end }} 63 - </div> 64 - </div> 65 - {{ end }} 66 - 67 - {{ define "timeline" }} 68 - <div class="py-4"> 69 - <div class="px-6 pb-4"> 70 - <p class="text-xl font-bold dark:text-white">Timeline</p> 71 - </div> 72 - 73 - <div class="flex flex-col gap-4"> 74 - {{ range $i, $e := .Timeline }} 75 - <div class="relative"> 76 - {{ if ne $i 0 }} 77 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 78 - {{ end }} 79 - {{ with $e }} 80 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 81 - {{ if .Repo }} 82 - {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 83 - {{ else if .Star }} 84 - {{ block "starEvent" (list $ .Star) }} {{ end }} 85 - {{ else if .Follow }} 86 - {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 87 - {{ end }} 88 - </div> 89 - {{ end }} 90 - </div> 91 - {{ end }} 92 - </div> 93 - </div> 94 - {{ end }} 95 - 96 - {{ define "repoEvent" }} 97 - {{ $root := index . 0 }} 98 - {{ $repo := index . 1 }} 99 - {{ $source := index . 2 }} 100 - {{ $userHandle := resolve $repo.Did }} 101 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 102 - {{ template "user/fragments/picHandleLink" $repo.Did }} 103 - {{ with $source }} 104 - {{ $sourceDid := resolve .Did }} 105 - forked 106 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 107 - {{ $sourceDid }}/{{ .Name }} 108 - </a> 109 - to 110 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 111 - {{ else }} 112 - created 113 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 114 - {{ $repo.Name }} 115 - </a> 116 - {{ end }} 117 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 118 - </div> 119 - {{ with $repo }} 120 - {{ template "user/fragments/repoCard" (list $root . true) }} 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "starEvent" }} 125 - {{ $root := index . 0 }} 126 - {{ $star := index . 1 }} 127 - {{ with $star }} 128 - {{ $starrerHandle := resolve .StarredByDid }} 129 - {{ $repoOwnerHandle := resolve .Repo.Did }} 130 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 131 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 132 - starred 133 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 134 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 135 - </a> 136 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 137 - </div> 138 - {{ with .Repo }} 139 - {{ template "user/fragments/repoCard" (list $root . true) }} 140 - {{ end }} 141 - {{ end }} 142 - {{ end }} 143 - 144 - 145 - {{ define "followEvent" }} 146 - {{ $root := index . 0 }} 147 - {{ $follow := index . 1 }} 148 - {{ $profile := index . 2 }} 149 - {{ $stat := index . 3 }} 150 - 151 - {{ $userHandle := resolve $follow.UserDid }} 152 - {{ $subjectHandle := resolve $follow.SubjectDid }} 153 - <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 154 - {{ template "user/fragments/picHandleLink" $userHandle }} 155 - followed 156 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 157 - <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 158 - </div> 159 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 160 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 161 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 162 - </div> 163 - 164 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 165 - <a href="/{{ $subjectHandle }}"> 166 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 167 - </a> 168 - {{ with $profile }} 169 - {{ with .Description }} 170 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 171 - {{ end }} 172 - {{ end }} 173 - {{ with $stat }} 174 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 - <span class="select-none after:content-['ยท']"></span> 178 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 179 - </div> 180 - {{ end }} 181 - </div> 182 - </div> 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 183 18 {{ end }}
+2 -4
appview/pages/templates/user/completeSignup.html
··· 29 29 </head> 30 30 <body class="flex items-center justify-center min-h-screen"> 31 31 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 35 - tangled 32 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 36 34 </h1> 37 35 <h2 class="text-center text-xl italic dark:text-white"> 38 36 tightly-knit social coding.
+4 -16
appview/pages/templates/user/followers.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 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> 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "followers" }}
+4 -16
appview/pages/templates/user/following.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 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> 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "following" }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/picHandle.html
··· 1 1 {{ define "user/fragments/picHandle" }} 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 - alt="{{ . }}" 4 + alt="" 5 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 6 /> 7 7 {{ . | truncateAt30 }}
+2 -4
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 2 {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 - <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"> 6 5 <div class="w-3/4 aspect-square relative"> ··· 85 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 86 85 </div> 87 86 </div> 88 - </div> 89 87 {{ end }} 90 88 91 89 {{ define "followerFollowing" }} ··· 94 92 {{ with $root }} 95 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 98 96 <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 100 98 </div> 101 99 {{ end }} 102 100 {{ end }}
+1 -2
appview/pages/templates/user/fragments/repoCard.html
··· 36 36 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 37 {{ with .Language }} 38 38 <div class="flex gap-2 items-center text-sm"> 39 - <div class="size-2 rounded-full" 40 - style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 39 + {{ template "repo/fragments/languageBall" . }} 41 40 <span>{{ . }}</span> 42 41 </div> 43 42 {{ end }}
+2 -2
appview/pages/templates/user/login.html
··· 13 13 </head> 14 14 <body class="flex items-center justify-center min-h-screen"> 15 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 17 - tangled 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 18 </h1> 19 19 <h2 class="text-center text-xl italic dark:text-white"> 20 20 tightly-knit social coding.
+269
appview/pages/templates/user/overview.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 5 + <div class="grid grid-cols-1 gap-4"> 6 + {{ block "ownRepos" . }}{{ end }} 7 + {{ block "collaboratingRepos" . }}{{ end }} 8 + </div> 9 + </div> 10 + <div class="md:col-span-4 order-3 md:order-3"> 11 + {{ block "profileTimeline" . }}{{ end }} 12 + </div> 13 + {{ end }} 14 + 15 + {{ define "profileTimeline" }} 16 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 + <div class="flex flex-col gap-4 relative"> 18 + {{ if .ProfileTimeline.IsEmpty }} 19 + <p class="dark:text-white">This user does not have any activity yet.</p> 20 + {{ end }} 21 + 22 + {{ with .ProfileTimeline }} 23 + {{ range $idx, $byMonth := .ByMonth }} 24 + {{ with $byMonth }} 25 + {{ if not .IsEmpty }} 26 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6"> 27 + <p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400"> 28 + {{ if eq $idx 0 }} 29 + this month 30 + {{ else }} 31 + {{$idx}} month{{if ne $idx 1}}s{{end}} ago 32 + {{ end }} 33 + </p> 34 + 35 + <div class="flex flex-col gap-1"> 36 + {{ block "repoEvents" .RepoEvents }} {{ end }} 37 + {{ block "issueEvents" .IssueEvents }} {{ end }} 38 + {{ block "pullEvents" .PullEvents }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }} 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "repoEvents" }} 49 + {{ if gt (len .) 0 }} 50 + <details> 51 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 52 + <div class="flex flex-wrap items-center gap-2"> 53 + {{ i "book-plus" "w-4 h-4" }} 54 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 55 + </div> 56 + </summary> 57 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 58 + {{ range . }} 59 + <div class="flex flex-wrap items-center justify-between gap-2"> 60 + <span class="flex items-center gap-2"> 61 + <span class="text-gray-500 dark:text-gray-400"> 62 + {{ if .Source }} 63 + {{ i "git-fork" "w-4 h-4" }} 64 + {{ else }} 65 + {{ i "book-plus" "w-4 h-4" }} 66 + {{ end }} 67 + </span> 68 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 69 + {{- .Repo.Name -}} 70 + </a> 71 + </span> 72 + 73 + {{ with .Repo.RepoStats }} 74 + {{ with .Language }} 75 + <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/languageBall" . }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{end }} 80 + {{end }} 81 + </div> 82 + {{ end }} 83 + </div> 84 + </details> 85 + {{ end }} 86 + {{ end }} 87 + 88 + {{ define "issueEvents" }} 89 + {{ $items := .Items }} 90 + {{ $stats := .Stats }} 91 + 92 + {{ if gt (len $items) 0 }} 93 + <details> 94 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 95 + <div class="flex flex-wrap items-center gap-2"> 96 + {{ i "circle-dot" "w-4 h-4" }} 97 + 98 + <div> 99 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 100 + </div> 101 + 102 + {{ if gt $stats.Open 0 }} 103 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 104 + {{$stats.Open}} open 105 + </span> 106 + {{ end }} 107 + 108 + {{ if gt $stats.Closed 0 }} 109 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 110 + {{$stats.Closed}} closed 111 + </span> 112 + {{ end }} 113 + 114 + </div> 115 + </summary> 116 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 117 + {{ range $items }} 118 + {{ $repoOwner := resolve .Repo.Did }} 119 + {{ $repoName := .Repo.Name }} 120 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 121 + 122 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 123 + {{ if .Open }} 124 + <span class="text-green-600 dark:text-green-500"> 125 + {{ i "circle-dot" "w-4 h-4" }} 126 + </span> 127 + {{ else }} 128 + <span class="text-gray-500 dark:text-gray-400"> 129 + {{ i "ban" "w-4 h-4" }} 130 + </span> 131 + {{ end }} 132 + <div class="flex-none min-w-8 text-right"> 133 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 134 + </div> 135 + <div class="break-words max-w-full"> 136 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 137 + {{ .Title -}} 138 + </a> 139 + on 140 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 141 + {{$repoUrl}} 142 + </a> 143 + </div> 144 + </div> 145 + {{ end }} 146 + </div> 147 + </details> 148 + {{ end }} 149 + {{ end }} 150 + 151 + {{ define "pullEvents" }} 152 + {{ $items := .Items }} 153 + {{ $stats := .Stats }} 154 + {{ if gt (len $items) 0 }} 155 + <details> 156 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 157 + <div class="flex flex-wrap items-center gap-2"> 158 + {{ i "git-pull-request" "w-4 h-4" }} 159 + 160 + <div> 161 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 162 + </div> 163 + 164 + {{ if gt $stats.Open 0 }} 165 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 166 + {{$stats.Open}} open 167 + </span> 168 + {{ end }} 169 + 170 + {{ if gt $stats.Merged 0 }} 171 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 172 + {{$stats.Merged}} merged 173 + </span> 174 + {{ end }} 175 + 176 + 177 + {{ if gt $stats.Closed 0 }} 178 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 179 + {{$stats.Closed}} closed 180 + </span> 181 + {{ end }} 182 + 183 + </div> 184 + </summary> 185 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 186 + {{ range $items }} 187 + {{ $repoOwner := resolve .Repo.Did }} 188 + {{ $repoName := .Repo.Name }} 189 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 190 + 191 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 192 + {{ if .State.IsOpen }} 193 + <span class="text-green-600 dark:text-green-500"> 194 + {{ i "git-pull-request" "w-4 h-4" }} 195 + </span> 196 + {{ else if .State.IsMerged }} 197 + <span class="text-purple-600 dark:text-purple-500"> 198 + {{ i "git-merge" "w-4 h-4" }} 199 + </span> 200 + {{ else }} 201 + <span class="text-gray-600 dark:text-gray-300"> 202 + {{ i "git-pull-request-closed" "w-4 h-4" }} 203 + </span> 204 + {{ end }} 205 + <div class="flex-none min-w-8 text-right"> 206 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 207 + </div> 208 + <div class="break-words max-w-full"> 209 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 210 + {{ .Title -}} 211 + </a> 212 + on 213 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 214 + {{$repoUrl}} 215 + </a> 216 + </div> 217 + </div> 218 + {{ end }} 219 + </div> 220 + </details> 221 + {{ end }} 222 + {{ end }} 223 + 224 + {{ define "ownRepos" }} 225 + <div> 226 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 228 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 + <span>PINNED REPOS</span> 230 + </a> 231 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 232 + <button 233 + hx-get="profile/edit-pins" 234 + hx-target="#all-repos" 235 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 236 + {{ i "pencil" "w-3 h-3" }} 237 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 238 + </button> 239 + {{ end }} 240 + </div> 241 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 242 + {{ range .Repos }} 243 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 244 + {{ template "user/fragments/repoCard" (list $ . false) }} 245 + </div> 246 + {{ else }} 247 + <p class="dark:text-white">This user does not have any pinned repos.</p> 248 + {{ end }} 249 + </div> 250 + </div> 251 + {{ end }} 252 + 253 + {{ define "collaboratingRepos" }} 254 + {{ if gt (len .CollaboratingRepos) 0 }} 255 + <div> 256 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 257 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 258 + {{ range .CollaboratingRepos }} 259 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 260 + {{ template "user/fragments/repoCard" (list $ . true) }} 261 + </div> 262 + {{ else }} 263 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 264 + {{ end }} 265 + </div> 266 + </div> 267 + {{ end }} 268 + {{ end }} 269 +
-318
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 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 - <div class="grid grid-cols-1 gap-4"> 14 - {{ template "user/fragments/profileCard" .Card }} 15 - {{ block "punchcard" .Punchcard }} {{ end }} 16 - </div> 17 - </div> 18 - <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 - <div class="grid grid-cols-1 gap-4"> 20 - {{ block "ownRepos" . }}{{ end }} 21 - {{ block "collaboratingRepos" . }}{{ end }} 22 - </div> 23 - </div> 24 - <div class="md:col-span-4 order-3 md:order-3"> 25 - {{ block "profileTimeline" . }}{{ end }} 26 - </div> 27 - </div> 28 - {{ end }} 29 - 30 - {{ define "profileTimeline" }} 31 - <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 32 - <div class="flex flex-col gap-4 relative"> 33 - {{ with .ProfileTimeline }} 34 - {{ range $idx, $byMonth := .ByMonth }} 35 - {{ with $byMonth }} 36 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 - {{ if eq $idx 0 }} 38 - 39 - {{ else }} 40 - {{ $s := "s" }} 41 - {{ if eq $idx 1 }} 42 - {{ $s = "" }} 43 - {{ end }} 44 - <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 - {{ end }} 46 - 47 - {{ if .IsEmpty }} 48 - <div class="text-gray-500 dark:text-gray-400"> 49 - No activity for this month 50 - </div> 51 - {{ else }} 52 - <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" .RepoEvents }} {{ end }} 54 - {{ block "issueEvents" .IssueEvents }} {{ end }} 55 - {{ block "pullEvents" .PullEvents }} {{ end }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - 60 - {{ end }} 61 - {{ else }} 62 - <p class="dark:text-white">This user does not have any activity yet.</p> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 - 68 - {{ define "repoEvents" }} 69 - {{ if gt (len .) 0 }} 70 - <details> 71 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 72 - <div class="flex flex-wrap items-center gap-2"> 73 - {{ i "book-plus" "w-4 h-4" }} 74 - created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 75 - </div> 76 - </summary> 77 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 78 - {{ range . }} 79 - <div class="flex flex-wrap items-center gap-2"> 80 - <span class="text-gray-500 dark:text-gray-400"> 81 - {{ if .Source }} 82 - {{ i "git-fork" "w-4 h-4" }} 83 - {{ else }} 84 - {{ i "book-plus" "w-4 h-4" }} 85 - {{ end }} 86 - </span> 87 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 88 - {{- .Repo.Name -}} 89 - </a> 90 - </div> 91 - {{ end }} 92 - </div> 93 - </details> 94 - {{ end }} 95 - {{ end }} 96 - 97 - {{ define "issueEvents" }} 98 - {{ $items := .Items }} 99 - {{ $stats := .Stats }} 100 - 101 - {{ if gt (len $items) 0 }} 102 - <details> 103 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 104 - <div class="flex flex-wrap items-center gap-2"> 105 - {{ i "circle-dot" "w-4 h-4" }} 106 - 107 - <div> 108 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 109 - </div> 110 - 111 - {{ if gt $stats.Open 0 }} 112 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 113 - {{$stats.Open}} open 114 - </span> 115 - {{ end }} 116 - 117 - {{ if gt $stats.Closed 0 }} 118 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 119 - {{$stats.Closed}} closed 120 - </span> 121 - {{ end }} 122 - 123 - </div> 124 - </summary> 125 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 126 - {{ range $items }} 127 - {{ $repoOwner := resolve .Metadata.Repo.Did }} 128 - {{ $repoName := .Metadata.Repo.Name }} 129 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 130 - 131 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 132 - {{ if .Open }} 133 - <span class="text-green-600 dark:text-green-500"> 134 - {{ i "circle-dot" "w-4 h-4" }} 135 - </span> 136 - {{ else }} 137 - <span class="text-gray-500 dark:text-gray-400"> 138 - {{ i "ban" "w-4 h-4" }} 139 - </span> 140 - {{ end }} 141 - <div class="flex-none min-w-8 text-right"> 142 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 143 - </div> 144 - <div class="break-words max-w-full"> 145 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 146 - {{ .Title -}} 147 - </a> 148 - on 149 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 150 - {{$repoUrl}} 151 - </a> 152 - </div> 153 - </div> 154 - {{ end }} 155 - </div> 156 - </details> 157 - {{ end }} 158 - {{ end }} 159 - 160 - {{ define "pullEvents" }} 161 - {{ $items := .Items }} 162 - {{ $stats := .Stats }} 163 - {{ if gt (len $items) 0 }} 164 - <details> 165 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 166 - <div class="flex flex-wrap items-center gap-2"> 167 - {{ i "git-pull-request" "w-4 h-4" }} 168 - 169 - <div> 170 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 171 - </div> 172 - 173 - {{ if gt $stats.Open 0 }} 174 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 175 - {{$stats.Open}} open 176 - </span> 177 - {{ end }} 178 - 179 - {{ if gt $stats.Merged 0 }} 180 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 181 - {{$stats.Merged}} merged 182 - </span> 183 - {{ end }} 184 - 185 - 186 - {{ if gt $stats.Closed 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 188 - {{$stats.Closed}} closed 189 - </span> 190 - {{ end }} 191 - 192 - </div> 193 - </summary> 194 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 195 - {{ range $items }} 196 - {{ $repoOwner := resolve .Repo.Did }} 197 - {{ $repoName := .Repo.Name }} 198 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 199 - 200 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 201 - {{ if .State.IsOpen }} 202 - <span class="text-green-600 dark:text-green-500"> 203 - {{ i "git-pull-request" "w-4 h-4" }} 204 - </span> 205 - {{ else if .State.IsMerged }} 206 - <span class="text-purple-600 dark:text-purple-500"> 207 - {{ i "git-merge" "w-4 h-4" }} 208 - </span> 209 - {{ else }} 210 - <span class="text-gray-600 dark:text-gray-300"> 211 - {{ i "git-pull-request-closed" "w-4 h-4" }} 212 - </span> 213 - {{ end }} 214 - <div class="flex-none min-w-8 text-right"> 215 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 216 - </div> 217 - <div class="break-words max-w-full"> 218 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 219 - {{ .Title -}} 220 - </a> 221 - on 222 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 223 - {{$repoUrl}} 224 - </a> 225 - </div> 226 - </div> 227 - {{ end }} 228 - </div> 229 - </details> 230 - {{ end }} 231 - {{ end }} 232 - 233 - {{ define "ownRepos" }} 234 - <div> 235 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 236 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 237 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 238 - <span>PINNED REPOS</span> 239 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 240 - view all {{ i "chevron-right" "w-4 h-4" }} 241 - </span> 242 - </a> 243 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 244 - <button 245 - hx-get="profile/edit-pins" 246 - hx-target="#all-repos" 247 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 248 - {{ i "pencil" "w-3 h-3" }} 249 - edit 250 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 251 - </button> 252 - {{ end }} 253 - </div> 254 - <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 255 - {{ range .Repos }} 256 - {{ template "user/fragments/repoCard" (list $ . false) }} 257 - {{ else }} 258 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 259 - {{ end }} 260 - </div> 261 - </div> 262 - {{ end }} 263 - 264 - {{ define "collaboratingRepos" }} 265 - {{ if gt (len .CollaboratingRepos) 0 }} 266 - <div> 267 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 268 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 269 - {{ range .CollaboratingRepos }} 270 - {{ template "user/fragments/repoCard" (list $ . true) }} 271 - {{ else }} 272 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 273 - {{ end }} 274 - </div> 275 - </div> 276 - {{ end }} 277 - {{ end }} 278 - 279 - {{ define "punchcard" }} 280 - {{ $now := now }} 281 - <div> 282 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 283 - PUNCHCARD 284 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 285 - {{ .Total | int64 | commaFmt }} commits 286 - </span> 287 - </p> 288 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 289 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 290 - {{ range .Punches }} 291 - {{ $count := .Count }} 292 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 293 - {{ if lt $count 1 }} 294 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 295 - {{ else if lt $count 2 }} 296 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 297 - {{ else if lt $count 4 }} 298 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 299 - {{ else if lt $count 8 }} 300 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 301 - {{ else }} 302 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 303 - {{ end }} 304 - 305 - {{ if .Date.After $now }} 306 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 307 - {{ end }} 308 - <div class="w-full h-full flex justify-center items-center"> 309 - <div 310 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 311 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 312 - </div> 313 - </div> 314 - {{ end }} 315 - </div> 316 - </div> 317 - </div> 318 - {{ end }}
+7 -18
appview/pages/templates/user/repos.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 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-repos" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 11 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 14 + </div> 26 15 {{ else }} 27 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 17 {{ end }}
+2 -2
appview/pages/templates/user/settings/emails.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 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"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+2 -2
appview/pages/templates/user/settings/keys.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 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"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+2 -2
appview/pages/templates/user/settings/profile.html
··· 4 4 <div class="p-6"> 5 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 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"> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 9 <div class="col-span-1"> 10 10 {{ template "user/settings/fragments/sidebar" . }} 11 11 </div>
+3 -1
appview/pages/templates/user/signup.html
··· 13 13 </head> 14 14 <body class="flex items-center justify-center min-h-screen"> 15 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 17 19 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 20 <form 19 21 class="mt-4 max-w-sm mx-auto"
+19
appview/pages/templates/user/starred.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+45
appview/pages/templates/user/strings.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['ยท']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+1 -1
appview/posthog/notifier.go
··· 58 58 59 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 60 err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 61 + DistinctId: issue.Did, 62 62 Event: "new_issue", 63 63 Properties: posthog.Properties{ 64 64 "repo_at": issue.RepoAt.String(),
+230 -85
appview/pulls/pulls.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 7 8 "log" ··· 21 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 23 "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 24 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 25 "tangled.sh/tangled.sh/core/patchutil" 26 26 "tangled.sh/tangled.sh/core/tid" 27 27 "tangled.sh/tangled.sh/core/types" ··· 99 99 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 100 resubmitResult := pages.Unknown 101 101 if user.Did == pull.OwnerDid { 102 - resubmitResult = s.resubmitCheck(f, pull, stack) 102 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 103 } 104 104 105 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 154 154 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 155 resubmitResult := pages.Unknown 156 156 if user != nil && user.Did == pull.OwnerDid { 157 - resubmitResult = s.resubmitCheck(f, pull, stack) 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 158 } 159 159 160 160 repoInfo := f.RepoInfo(user) ··· 282 282 return result 283 283 } 284 284 285 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 285 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 287 return pages.Unknown 288 288 } ··· 307 307 repoName = f.Name 308 308 } 309 309 310 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 311 - if err != nil { 312 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 313 - return pages.Unknown 310 + scheme := "http" 311 + if !s.config.Core.Dev { 312 + scheme = "https" 313 + } 314 + host := fmt.Sprintf("%s://%s", scheme, knot) 315 + xrpcc := &indigoxrpc.Client{ 316 + Host: host, 314 317 } 315 318 316 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 319 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 320 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 317 321 if err != nil { 322 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 323 + log.Println("failed to call XRPC repo.branches", xrpcerr) 324 + return pages.Unknown 325 + } 318 326 log.Println("failed to reach knotserver", err) 319 327 return pages.Unknown 320 328 } 321 329 330 + targetBranch := branchResp 331 + 322 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 323 333 324 334 if pull.IsStacked() && stack != nil { ··· 326 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 327 337 } 328 338 329 - if latestSourceRev != result.Branch.Hash { 339 + if latestSourceRev != targetBranch.Hash { 330 340 return pages.ShouldResubmit 331 341 } 332 342 ··· 678 688 679 689 switch r.Method { 680 690 case http.MethodGet: 681 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 691 + scheme := "http" 692 + if !s.config.Core.Dev { 693 + scheme = "https" 694 + } 695 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 696 + xrpcc := &indigoxrpc.Client{ 697 + Host: host, 698 + } 699 + 700 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 701 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 682 702 if err != nil { 683 - log.Printf("failed to create unsigned client for %s", f.Knot) 684 - s.pages.Error503(w) 703 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 704 + log.Println("failed to call XRPC repo.branches", xrpcerr) 705 + s.pages.Error503(w) 706 + return 707 + } 708 + log.Println("failed to fetch branches", err) 685 709 return 686 710 } 687 711 688 - result, err := us.Branches(f.OwnerDid(), f.Name) 689 - if err != nil { 690 - log.Println("failed to fetch branches", err) 712 + var result types.RepoBranchesResponse 713 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 714 + log.Println("failed to decode XRPC response", err) 715 + s.pages.Error503(w) 691 716 return 692 717 } 693 718 ··· 752 777 return 753 778 } 754 779 755 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 756 - if err != nil { 757 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 758 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 759 - return 760 - } 780 + // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 781 + // if err != nil { 782 + // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 783 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 784 + // return 785 + // } 761 786 762 - caps, err := us.Capabilities() 763 - if err != nil { 764 - log.Println("error fetching knot caps", f.Knot, err) 765 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 766 - return 787 + // TODO: make capabilities an xrpc call 788 + caps := struct { 789 + PullRequests struct { 790 + FormatPatch bool 791 + BranchSubmissions bool 792 + ForkSubmissions bool 793 + PatchSubmissions bool 794 + } 795 + }{ 796 + PullRequests: struct { 797 + FormatPatch bool 798 + BranchSubmissions bool 799 + ForkSubmissions bool 800 + PatchSubmissions bool 801 + }{ 802 + FormatPatch: true, 803 + BranchSubmissions: true, 804 + ForkSubmissions: true, 805 + PatchSubmissions: true, 806 + }, 767 807 } 808 + 809 + // caps, err := us.Capabilities() 810 + // if err != nil { 811 + // log.Println("error fetching knot caps", f.Knot, err) 812 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 813 + // return 814 + // } 768 815 769 816 if !caps.PullRequests.FormatPatch { 770 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 806 853 sourceBranch string, 807 854 isStacked bool, 808 855 ) { 809 - // Generate a patch using /compare 810 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 811 - if err != nil { 812 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 813 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 814 - return 856 + scheme := "http" 857 + if !s.config.Core.Dev { 858 + scheme = "https" 859 + } 860 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 861 + xrpcc := &indigoxrpc.Client{ 862 + Host: host, 815 863 } 816 864 817 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 818 867 if err != nil { 868 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 869 + log.Println("failed to call XRPC repo.compare", xrpcerr) 870 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 871 + return 872 + } 819 873 log.Println("failed to compare", err) 820 874 s.pages.Notice(w, "pull", err.Error()) 875 + return 876 + } 877 + 878 + var comparison types.RepoFormatPatchResponse 879 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 880 + log.Println("failed to decode XRPC compare response", err) 881 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 821 882 return 822 883 } 823 884 ··· 869 930 oauth.WithLxm(tangled.RepoHiddenRefNSID), 870 931 oauth.WithDev(s.config.Core.Dev), 871 932 ) 872 - if err != nil { 873 - log.Printf("failed to connect to knot server: %v", err) 874 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 875 - return 876 - } 877 - 878 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 879 - if err != nil { 880 - log.Println("failed to create unsigned client:", err) 881 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 882 - return 883 - } 884 933 885 934 resp, err := tangled.RepoHiddenRef( 886 935 r.Context(), ··· 911 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 912 961 // targetBranch: main (on repo-1) 913 962 // sourceBranch: feature-1 (on repo-fork) 914 - comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch) 963 + forkScheme := "http" 964 + if !s.config.Core.Dev { 965 + forkScheme = "https" 966 + } 967 + forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 968 + forkXrpcc := &indigoxrpc.Client{ 969 + Host: forkHost, 970 + } 971 + 972 + forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 973 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 915 974 if err != nil { 975 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 976 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 977 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 978 + return 979 + } 916 980 log.Println("failed to compare across branches", err) 917 981 s.pages.Notice(w, "pull", err.Error()) 918 982 return 919 983 } 920 984 985 + var comparison types.RepoFormatPatchResponse 986 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 987 + log.Println("failed to decode XRPC compare response for fork", err) 988 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 989 + return 990 + } 991 + 921 992 sourceRev := comparison.Rev2 922 993 patch := comparison.Patch 923 994 ··· 1211 1282 return 1212 1283 } 1213 1284 1214 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1285 + scheme := "http" 1286 + if !s.config.Core.Dev { 1287 + scheme = "https" 1288 + } 1289 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1290 + xrpcc := &indigoxrpc.Client{ 1291 + Host: host, 1292 + } 1293 + 1294 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1295 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1215 1296 if err != nil { 1216 - log.Printf("failed to create unsigned client for %s", f.Knot) 1217 - s.pages.Error503(w) 1297 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1298 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1299 + s.pages.Error503(w) 1300 + return 1301 + } 1302 + log.Println("failed to fetch branches", err) 1218 1303 return 1219 1304 } 1220 1305 1221 - result, err := us.Branches(f.OwnerDid(), f.Name) 1222 - if err != nil { 1223 - log.Println("failed to reach knotserver", err) 1306 + var result types.RepoBranchesResponse 1307 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1308 + log.Println("failed to decode XRPC response", err) 1309 + s.pages.Error503(w) 1224 1310 return 1225 1311 } 1226 1312 ··· 1284 1370 return 1285 1371 } 1286 1372 1287 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1373 + sourceScheme := "http" 1374 + if !s.config.Core.Dev { 1375 + sourceScheme = "https" 1376 + } 1377 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1378 + sourceXrpcc := &indigoxrpc.Client{ 1379 + Host: sourceHost, 1380 + } 1381 + 1382 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1383 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1288 1384 if err != nil { 1289 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1290 - s.pages.Error503(w) 1385 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1386 + log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1387 + s.pages.Error503(w) 1388 + return 1389 + } 1390 + log.Println("failed to fetch source branches", err) 1291 1391 return 1292 1392 } 1293 1393 1294 - sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name) 1295 - if err != nil { 1296 - log.Println("failed to reach knotserver for source branches", err) 1394 + // Decode source branches 1395 + var sourceBranches types.RepoBranchesResponse 1396 + if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1397 + log.Println("failed to decode source branches XRPC response", err) 1398 + s.pages.Error503(w) 1297 1399 return 1298 1400 } 1299 1401 1300 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1402 + targetScheme := "http" 1403 + if !s.config.Core.Dev { 1404 + targetScheme = "https" 1405 + } 1406 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1407 + targetXrpcc := &indigoxrpc.Client{ 1408 + Host: targetHost, 1409 + } 1410 + 1411 + targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1412 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1301 1413 if err != nil { 1302 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1303 - s.pages.Error503(w) 1414 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1415 + log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1416 + s.pages.Error503(w) 1417 + return 1418 + } 1419 + log.Println("failed to fetch target branches", err) 1304 1420 return 1305 1421 } 1306 1422 1307 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1308 - if err != nil { 1309 - log.Println("failed to reach knotserver for target branches", err) 1423 + // Decode target branches 1424 + var targetBranches types.RepoBranchesResponse 1425 + if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1426 + log.Println("failed to decode target branches XRPC response", err) 1427 + s.pages.Error503(w) 1310 1428 return 1311 1429 } 1312 1430 1313 - sourceBranches := sourceResult.Branches 1314 - sort.Slice(sourceBranches, func(i int, j int) bool { 1315 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1431 + sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1432 + return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1316 1433 }) 1317 1434 1318 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1319 1436 RepoInfo: f.RepoInfo(user), 1320 - SourceBranches: sourceBranches, 1321 - TargetBranches: targetResult.Branches, 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1322 1439 }) 1323 1440 } 1324 1441 ··· 1413 1530 return 1414 1531 } 1415 1532 1416 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1417 - if err != nil { 1418 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1419 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1420 - return 1533 + scheme := "http" 1534 + if !s.config.Core.Dev { 1535 + scheme = "https" 1536 + } 1537 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1538 + xrpcc := &indigoxrpc.Client{ 1539 + Host: host, 1421 1540 } 1422 1541 1423 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1542 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1543 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1424 1544 if err != nil { 1545 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1546 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1547 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1548 + return 1549 + } 1425 1550 log.Printf("compare request failed: %s", err) 1426 1551 s.pages.Notice(w, "resubmit-error", err.Error()) 1552 + return 1553 + } 1554 + 1555 + var comparison types.RepoFormatPatchResponse 1556 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1557 + log.Println("failed to decode XRPC compare response", err) 1558 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1427 1559 return 1428 1560 } 1429 1561 ··· 1463 1595 } 1464 1596 1465 1597 // extract patch by performing compare 1466 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1598 + forkScheme := "http" 1599 + if !s.config.Core.Dev { 1600 + forkScheme = "https" 1601 + } 1602 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1603 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1604 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1467 1605 if err != nil { 1468 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1606 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1607 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1608 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1609 + return 1610 + } 1611 + log.Printf("failed to compare branches: %s", err) 1612 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1613 + return 1614 + } 1615 + 1616 + var forkComparison types.RepoFormatPatchResponse 1617 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1618 + log.Println("failed to decode XRPC compare response for fork", err) 1469 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1470 1620 return 1471 1621 } ··· 1501 1651 return 1502 1652 } 1503 1653 1504 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1505 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1506 - if err != nil { 1507 - log.Printf("failed to compare branches: %s", err) 1508 - s.pages.Notice(w, "resubmit-error", err.Error()) 1509 - return 1510 - } 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1511 1656 1512 1657 sourceRev := comparison.Rev2 1513 1658 patch := comparison.Patch
+26 -8
appview/repo/artifact.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 5 + "encoding/json" 4 6 "fmt" 5 7 "log" 6 8 "net/http" ··· 9 11 10 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 15 "github.com/dustin/go-humanize" 13 16 "github.com/go-chi/chi/v5" 14 17 "github.com/go-git/go-git/v5/plumbing" ··· 17 20 "tangled.sh/tangled.sh/core/appview/db" 18 21 "tangled.sh/tangled.sh/core/appview/pages" 19 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/knotclient" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 21 24 "tangled.sh/tangled.sh/core/tid" 22 25 "tangled.sh/tangled.sh/core/types" 23 26 ) ··· 33 36 return 34 37 } 35 38 36 - tag, err := rp.resolveTag(f, tagParam) 39 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 37 40 if err != nil { 38 41 log.Println("failed to resolve tag", err) 39 42 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 140 143 return 141 144 } 142 145 143 - tag, err := rp.resolveTag(f, tagParam) 146 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 144 147 if err != nil { 145 148 log.Println("failed to resolve tag", err) 146 149 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 259 262 w.Write([]byte{}) 260 263 } 261 264 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 265 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 266 tagParam, err := url.QueryUnescape(tagParam) 264 267 if err != nil { 265 268 return nil, err 266 269 } 267 270 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 - if err != nil { 270 - return nil, err 271 + scheme := "http" 272 + if !rp.config.Core.Dev { 273 + scheme = "https" 274 + } 275 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 276 + xrpcc := &indigoxrpc.Client{ 277 + Host: host, 271 278 } 272 279 273 - result, err := us.Tags(f.OwnerDid(), f.Name) 280 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 281 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 274 282 if err != nil { 283 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 284 + log.Println("failed to call XRPC repo.tags", xrpcerr) 285 + return nil, xrpcerr 286 + } 275 287 log.Println("failed to reach knotserver", err) 288 + return nil, err 289 + } 290 + 291 + var result types.RepoTagsResponse 292 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 293 + log.Println("failed to decode XRPC tags response", err) 276 294 return nil, err 277 295 } 278 296
+7 -2
appview/repo/feed.go
··· 9 9 "time" 10 10 11 11 "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/pagination" 12 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 13 14 14 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 23 24 return nil, err 24 25 } 25 26 26 - issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 27 32 if err != nil { 28 33 return nil, err 29 34 } ··· 104 109 } 105 110 106 111 func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 107 - owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid) 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 108 113 if err != nil { 109 114 return nil, err 110 115 }
+207 -22
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "errors" 5 + "fmt" 4 6 "log" 5 7 "net/http" 6 8 "slices" 7 9 "sort" 8 10 "strings" 11 + "sync" 12 + "time" 9 13 14 + "context" 15 + "encoding/json" 16 + 17 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 + "github.com/go-git/go-git/v5/plumbing" 19 + "tangled.sh/tangled.sh/core/api/tangled" 10 20 "tangled.sh/tangled.sh/core/appview/commitverify" 11 21 "tangled.sh/tangled.sh/core/appview/db" 12 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/appview/pages/markup" 13 24 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 - "tangled.sh/tangled.sh/core/knotclient" 25 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 15 26 "tangled.sh/tangled.sh/core/types" 16 27 17 28 "github.com/go-chi/chi/v5" ··· 27 38 return 28 39 } 29 40 30 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 31 - if err != nil { 32 - log.Printf("failed to create unsigned client for %s", f.Knot) 33 - rp.pages.Error503(w) 34 - return 41 + scheme := "http" 42 + if !rp.config.Core.Dev { 43 + scheme = "https" 44 + } 45 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 46 + xrpcc := &indigoxrpc.Client{ 47 + Host: host, 35 48 } 36 49 37 - result, err := us.Index(f.OwnerDid(), f.Name, ref) 38 - if err != nil { 39 - rp.pages.Error503(w) 40 - log.Println("failed to reach knotserver", err) 41 - return 50 + user := rp.oauth.GetUser(r) 51 + repoInfo := f.RepoInfo(user) 52 + 53 + // Build index response from multiple XRPC calls 54 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 55 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 56 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 57 + log.Println("failed to call XRPC repo.index", err) 58 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 59 + LoggedInUser: user, 60 + NeedsKnotUpgrade: true, 61 + RepoInfo: repoInfo, 62 + }) 63 + return 64 + } else { 65 + rp.pages.Error503(w) 66 + log.Println("failed to build index response", err) 67 + return 68 + } 42 69 } 43 70 44 71 tagMap := make(map[string][]string) ··· 98 125 log.Println(err) 99 126 } 100 127 101 - user := rp.oauth.GetUser(r) 102 - repoInfo := f.RepoInfo(user) 103 - 104 128 // TODO: a bit dirty 105 - languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 129 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 106 130 if err != nil { 107 131 log.Printf("failed to compute language percentages: %s", err) 108 132 // non-fatal ··· 135 159 } 136 160 137 161 func (rp *Repo) getLanguageInfo( 162 + ctx context.Context, 138 163 f *reporesolver.ResolvedRepo, 139 - us *knotclient.UnsignedClient, 164 + xrpcc *indigoxrpc.Client, 140 165 currentRef string, 141 166 isDefaultRef bool, 142 167 ) ([]types.RepoLanguageDetails, error) { ··· 148 173 ) 149 174 150 175 if err != nil || langs == nil { 151 - // non-fatal, fetch langs from ks 152 - ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 176 + // non-fatal, fetch langs from ks via XRPC 177 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 178 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 153 179 if err != nil { 180 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 181 + log.Println("failed to call XRPC repo.languages", xrpcerr) 182 + return nil, xrpcerr 183 + } 154 184 return nil, err 155 185 } 156 - if ls == nil { 186 + 187 + if ls == nil || ls.Languages == nil { 157 188 return nil, nil 158 189 } 159 190 160 - for l, s := range ls.Languages { 191 + for _, lang := range ls.Languages { 161 192 langs = append(langs, db.RepoLanguage{ 162 193 RepoAt: f.RepoAt(), 163 194 Ref: currentRef, 164 195 IsDefaultRef: isDefaultRef, 165 - Language: l, 166 - Bytes: s, 196 + Language: lang.Name, 197 + Bytes: lang.Size, 167 198 }) 168 199 } 169 200 ··· 206 237 207 238 return languageStats, nil 208 239 } 240 + 241 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 242 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 243 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 244 + 245 + // first get branches to determine the ref if not specified 246 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 247 + if err != nil { 248 + return nil, err 249 + } 250 + 251 + var branchesResp types.RepoBranchesResponse 252 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 253 + return nil, err 254 + } 255 + 256 + // if no ref specified, use default branch or first available 257 + if ref == "" && len(branchesResp.Branches) > 0 { 258 + for _, branch := range branchesResp.Branches { 259 + if branch.IsDefault { 260 + ref = branch.Name 261 + break 262 + } 263 + } 264 + if ref == "" { 265 + ref = branchesResp.Branches[0].Name 266 + } 267 + } 268 + 269 + // check if repo is empty 270 + if len(branchesResp.Branches) == 0 { 271 + return &types.RepoIndexResponse{ 272 + IsEmpty: true, 273 + Branches: branchesResp.Branches, 274 + }, nil 275 + } 276 + 277 + // now run the remaining queries in parallel 278 + var wg sync.WaitGroup 279 + var errs error 280 + 281 + var ( 282 + tagsResp types.RepoTagsResponse 283 + treeResp *tangled.RepoTree_Output 284 + logResp types.RepoLogResponse 285 + readmeContent string 286 + readmeFileName string 287 + ) 288 + 289 + // tags 290 + wg.Add(1) 291 + go func() { 292 + defer wg.Done() 293 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 294 + if err != nil { 295 + errs = errors.Join(errs, err) 296 + return 297 + } 298 + 299 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 300 + errs = errors.Join(errs, err) 301 + } 302 + }() 303 + 304 + // tree/files 305 + wg.Add(1) 306 + go func() { 307 + defer wg.Done() 308 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 309 + if err != nil { 310 + errs = errors.Join(errs, err) 311 + return 312 + } 313 + treeResp = resp 314 + }() 315 + 316 + // commits 317 + wg.Add(1) 318 + go func() { 319 + defer wg.Done() 320 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 321 + if err != nil { 322 + errs = errors.Join(errs, err) 323 + return 324 + } 325 + 326 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 327 + errs = errors.Join(errs, err) 328 + } 329 + }() 330 + 331 + // readme content 332 + wg.Add(1) 333 + go func() { 334 + defer wg.Done() 335 + for _, filename := range markup.ReadmeFilenames { 336 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 + if err != nil { 338 + continue 339 + } 340 + 341 + if blobResp == nil { 342 + continue 343 + } 344 + 345 + readmeContent = blobResp.Content 346 + readmeFileName = filename 347 + break 348 + } 349 + }() 350 + 351 + wg.Wait() 352 + 353 + if errs != nil { 354 + return nil, errs 355 + } 356 + 357 + var files []types.NiceTree 358 + if treeResp != nil && treeResp.Files != nil { 359 + for _, file := range treeResp.Files { 360 + niceFile := types.NiceTree{ 361 + IsFile: file.Is_file, 362 + IsSubtree: file.Is_subtree, 363 + Name: file.Name, 364 + Mode: file.Mode, 365 + Size: file.Size, 366 + } 367 + if file.Last_commit != nil { 368 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 369 + niceFile.LastCommit = &types.LastCommitInfo{ 370 + Hash: plumbing.NewHash(file.Last_commit.Hash), 371 + Message: file.Last_commit.Message, 372 + When: when, 373 + } 374 + } 375 + files = append(files, niceFile) 376 + } 377 + } 378 + 379 + result := &types.RepoIndexResponse{ 380 + IsEmpty: false, 381 + Ref: ref, 382 + Readme: readmeContent, 383 + ReadmeFileName: readmeFileName, 384 + Commits: logResp.Commits, 385 + Description: logResp.Description, 386 + Files: files, 387 + Branches: branchesResp.Branches, 388 + Tags: tagsResp.Tags, 389 + TotalCommits: logResp.Total, 390 + } 391 + 392 + return result, nil 393 + }
+374 -144
appview/repo/repo.go
··· 19 19 20 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 23 "tangled.sh/tangled.sh/core/api/tangled" 23 24 "tangled.sh/tangled.sh/core/appview/commitverify" 24 25 "tangled.sh/tangled.sh/core/appview/config" ··· 31 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 33 "tangled.sh/tangled.sh/core/eventconsumer" 33 34 "tangled.sh/tangled.sh/core/idresolver" 34 - "tangled.sh/tangled.sh/core/knotclient" 35 35 "tangled.sh/tangled.sh/core/patchutil" 36 36 "tangled.sh/tangled.sh/core/rbac" 37 37 "tangled.sh/tangled.sh/core/tid" ··· 92 92 return 93 93 } 94 94 95 - var uri string 96 - if rp.config.Core.Dev { 97 - uri = "http" 98 - } else { 99 - uri = "https" 95 + scheme := "http" 96 + if !rp.config.Core.Dev { 97 + scheme = "https" 98 + } 99 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 100 + xrpcc := &indigoxrpc.Client{ 101 + Host: host, 102 + } 103 + 104 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 105 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo) 106 + if err != nil { 107 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 108 + log.Println("failed to call XRPC repo.archive", xrpcerr) 109 + rp.pages.Error503(w) 110 + return 111 + } 112 + rp.pages.Error404(w) 113 + return 100 114 } 101 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 115 + 116 + // Set headers for file download 117 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam) 118 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 119 + w.Header().Set("Content-Type", "application/gzip") 120 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 102 121 103 - http.Redirect(w, r, url, http.StatusFound) 122 + // Write the archive data directly 123 + w.Write(archiveBytes) 104 124 } 105 125 106 126 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 120 140 121 141 ref := chi.URLParam(r, "ref") 122 142 123 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 143 + scheme := "http" 144 + if !rp.config.Core.Dev { 145 + scheme = "https" 146 + } 147 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 148 + xrpcc := &indigoxrpc.Client{ 149 + Host: host, 150 + } 151 + 152 + limit := int64(60) 153 + cursor := "" 154 + if page > 1 { 155 + // Convert page number to cursor (offset) 156 + offset := (page - 1) * int(limit) 157 + cursor = strconv.Itoa(offset) 158 + } 159 + 160 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 161 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 124 162 if err != nil { 125 - log.Println("failed to create unsigned client", err) 163 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 164 + log.Println("failed to call XRPC repo.log", xrpcerr) 165 + rp.pages.Error503(w) 166 + return 167 + } 168 + rp.pages.Error404(w) 126 169 return 127 170 } 128 171 129 - repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 - if err != nil { 172 + var xrpcResp types.RepoLogResponse 173 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 174 + log.Println("failed to decode XRPC response", err) 131 175 rp.pages.Error503(w) 132 - log.Println("failed to reach knotserver", err) 133 176 return 134 177 } 135 178 136 - tagResult, err := us.Tags(f.OwnerDid(), f.Name) 179 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 137 180 if err != nil { 138 - rp.pages.Error503(w) 139 - log.Println("failed to reach knotserver", err) 140 - return 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + log.Println("failed to call XRPC repo.tags", xrpcerr) 183 + rp.pages.Error503(w) 184 + return 185 + } 141 186 } 142 187 143 188 tagMap := make(map[string][]string) 144 - for _, tag := range tagResult.Tags { 145 - hash := tag.Hash 146 - if tag.Tag != nil { 147 - hash = tag.Tag.Target.String() 189 + if tagBytes != nil { 190 + var tagResp types.RepoTagsResponse 191 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 192 + for _, tag := range tagResp.Tags { 193 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 194 + } 148 195 } 149 - tagMap[hash] = append(tagMap[hash], tag.Name) 150 196 } 151 197 152 - branchResult, err := us.Branches(f.OwnerDid(), f.Name) 198 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 153 199 if err != nil { 154 - rp.pages.Error503(w) 155 - log.Println("failed to reach knotserver", err) 156 - return 200 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 201 + log.Println("failed to call XRPC repo.branches", xrpcerr) 202 + rp.pages.Error503(w) 203 + return 204 + } 157 205 } 158 206 159 - for _, branch := range branchResult.Branches { 160 - hash := branch.Hash 161 - tagMap[hash] = append(tagMap[hash], branch.Name) 207 + if branchBytes != nil { 208 + var branchResp types.RepoBranchesResponse 209 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 210 + for _, branch := range branchResp.Branches { 211 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 212 + } 213 + } 162 214 } 163 215 164 216 user := rp.oauth.GetUser(r) 165 217 166 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 218 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 167 219 if err != nil { 168 220 log.Println("failed to fetch email to did mapping", err) 169 221 } 170 222 171 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 223 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 172 224 if err != nil { 173 225 log.Println(err) 174 226 } ··· 176 228 repoInfo := f.RepoInfo(user) 177 229 178 230 var shas []string 179 - for _, c := range repolog.Commits { 231 + for _, c := range xrpcResp.Commits { 180 232 shas = append(shas, c.Hash.String()) 181 233 } 182 234 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 189 241 LoggedInUser: user, 190 242 TagMap: tagMap, 191 243 RepoInfo: repoInfo, 192 - RepoLogResponse: *repolog, 244 + RepoLogResponse: xrpcResp, 193 245 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 194 246 VerifiedCommits: vc, 195 247 Pipelines: pipelines, ··· 301 353 return 302 354 } 303 355 ref := chi.URLParam(r, "ref") 304 - protocol := "http" 305 - if !rp.config.Core.Dev { 306 - protocol = "https" 307 - } 308 356 309 357 var diffOpts types.DiffOpts 310 358 if d := r.URL.Query().Get("diff"); d == "split" { ··· 316 364 return 317 365 } 318 366 319 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref)) 320 - if err != nil { 321 - rp.pages.Error503(w) 322 - log.Println("failed to reach knotserver", err) 323 - return 367 + scheme := "http" 368 + if !rp.config.Core.Dev { 369 + scheme = "https" 370 + } 371 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 372 + xrpcc := &indigoxrpc.Client{ 373 + Host: host, 324 374 } 325 375 326 - body, err := io.ReadAll(resp.Body) 376 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 377 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 327 378 if err != nil { 328 - log.Printf("Error reading response body: %v", err) 379 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 380 + log.Println("failed to call XRPC repo.diff", xrpcerr) 381 + rp.pages.Error503(w) 382 + return 383 + } 384 + rp.pages.Error404(w) 329 385 return 330 386 } 331 387 332 388 var result types.RepoCommitResponse 333 - err = json.Unmarshal(body, &result) 334 - if err != nil { 335 - log.Println("failed to parse response:", err) 389 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 390 + log.Println("failed to decode XRPC response", err) 391 + rp.pages.Error503(w) 336 392 return 337 393 } 338 394 ··· 378 434 379 435 ref := chi.URLParam(r, "ref") 380 436 treePath := chi.URLParam(r, "*") 381 - protocol := "http" 382 - if !rp.config.Core.Dev { 383 - protocol = "https" 384 - } 385 437 386 438 // if the tree path has a trailing slash, let's strip it 387 439 // so we don't 404 388 440 treePath = strings.TrimSuffix(treePath, "/") 389 441 390 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 442 + scheme := "http" 443 + if !rp.config.Core.Dev { 444 + scheme = "https" 445 + } 446 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 447 + xrpcc := &indigoxrpc.Client{ 448 + Host: host, 449 + } 450 + 451 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 452 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 391 453 if err != nil { 392 - rp.pages.Error503(w) 393 - log.Println("failed to reach knotserver", err) 454 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 455 + log.Println("failed to call XRPC repo.tree", xrpcerr) 456 + rp.pages.Error503(w) 457 + return 458 + } 459 + rp.pages.Error404(w) 394 460 return 395 461 } 396 462 397 - // uhhh so knotserver returns a 500 if the entry isn't found in 398 - // the requested tree path, so let's stick to not-OK here. 399 - // we can fix this once we build out the xrpc apis for these operations. 400 - if resp.StatusCode != http.StatusOK { 401 - rp.pages.Error404(w) 402 - return 463 + // Convert XRPC response to internal types.RepoTreeResponse 464 + files := make([]types.NiceTree, len(xrpcResp.Files)) 465 + for i, xrpcFile := range xrpcResp.Files { 466 + file := types.NiceTree{ 467 + Name: xrpcFile.Name, 468 + Mode: xrpcFile.Mode, 469 + Size: int64(xrpcFile.Size), 470 + IsFile: xrpcFile.Is_file, 471 + IsSubtree: xrpcFile.Is_subtree, 472 + } 473 + 474 + // Convert last commit info if present 475 + if xrpcFile.Last_commit != nil { 476 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 477 + file.LastCommit = &types.LastCommitInfo{ 478 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 479 + Message: xrpcFile.Last_commit.Message, 480 + When: commitWhen, 481 + } 482 + } 483 + 484 + files[i] = file 403 485 } 404 486 405 - body, err := io.ReadAll(resp.Body) 406 - if err != nil { 407 - log.Printf("Error reading response body: %v", err) 408 - return 487 + result := types.RepoTreeResponse{ 488 + Ref: xrpcResp.Ref, 489 + Files: files, 409 490 } 410 491 411 - var result types.RepoTreeResponse 412 - err = json.Unmarshal(body, &result) 413 - if err != nil { 414 - log.Println("failed to parse response:", err) 415 - return 492 + if xrpcResp.Parent != nil { 493 + result.Parent = *xrpcResp.Parent 494 + } 495 + if xrpcResp.Dotdot != nil { 496 + result.DotDot = *xrpcResp.Dotdot 416 497 } 417 498 418 499 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, ··· 451 532 return 452 533 } 453 534 454 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 535 + scheme := "http" 536 + if !rp.config.Core.Dev { 537 + scheme = "https" 538 + } 539 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 540 + xrpcc := &indigoxrpc.Client{ 541 + Host: host, 542 + } 543 + 544 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 545 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 455 546 if err != nil { 456 - log.Println("failed to create unsigned client", err) 547 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 548 + log.Println("failed to call XRPC repo.tags", xrpcerr) 549 + rp.pages.Error503(w) 550 + return 551 + } 552 + rp.pages.Error404(w) 457 553 return 458 554 } 459 555 460 - result, err := us.Tags(f.OwnerDid(), f.Name) 461 - if err != nil { 556 + var result types.RepoTagsResponse 557 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 558 + log.Println("failed to decode XRPC response", err) 462 559 rp.pages.Error503(w) 463 - log.Println("failed to reach knotserver", err) 464 560 return 465 561 } 466 562 ··· 496 592 rp.pages.RepoTags(w, pages.RepoTagsParams{ 497 593 LoggedInUser: user, 498 594 RepoInfo: f.RepoInfo(user), 499 - RepoTagsResponse: *result, 595 + RepoTagsResponse: result, 500 596 ArtifactMap: artifactMap, 501 597 DanglingArtifacts: danglingArtifacts, 502 598 }) ··· 509 605 return 510 606 } 511 607 512 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 608 + scheme := "http" 609 + if !rp.config.Core.Dev { 610 + scheme = "https" 611 + } 612 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 613 + xrpcc := &indigoxrpc.Client{ 614 + Host: host, 615 + } 616 + 617 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 618 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 513 619 if err != nil { 514 - log.Println("failed to create unsigned client", err) 620 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 621 + log.Println("failed to call XRPC repo.branches", xrpcerr) 622 + rp.pages.Error503(w) 623 + return 624 + } 625 + rp.pages.Error404(w) 515 626 return 516 627 } 517 628 518 - result, err := us.Branches(f.OwnerDid(), f.Name) 519 - if err != nil { 629 + var result types.RepoBranchesResponse 630 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 631 + log.Println("failed to decode XRPC response", err) 520 632 rp.pages.Error503(w) 521 - log.Println("failed to reach knotserver", err) 522 633 return 523 634 } 524 635 ··· 528 639 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 529 640 LoggedInUser: user, 530 641 RepoInfo: f.RepoInfo(user), 531 - RepoBranchesResponse: *result, 642 + RepoBranchesResponse: result, 532 643 }) 533 644 } 534 645 ··· 541 652 542 653 ref := chi.URLParam(r, "ref") 543 654 filePath := chi.URLParam(r, "*") 544 - protocol := "http" 655 + 656 + scheme := "http" 545 657 if !rp.config.Core.Dev { 546 - protocol = "https" 658 + scheme = "https" 547 659 } 548 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)) 549 - if err != nil { 550 - rp.pages.Error503(w) 551 - log.Println("failed to reach knotserver", err) 552 - return 660 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 661 + xrpcc := &indigoxrpc.Client{ 662 + Host: host, 553 663 } 554 664 555 - if resp.StatusCode == http.StatusNotFound { 665 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 666 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 667 + if err != nil { 668 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 669 + log.Println("failed to call XRPC repo.blob", xrpcerr) 670 + rp.pages.Error503(w) 671 + return 672 + } 556 673 rp.pages.Error404(w) 557 674 return 558 675 } 559 676 560 - body, err := io.ReadAll(resp.Body) 561 - if err != nil { 562 - log.Printf("Error reading response body: %v", err) 563 - return 564 - } 565 - 566 - var result types.RepoBlobResponse 567 - err = json.Unmarshal(body, &result) 568 - if err != nil { 569 - log.Println("failed to parse response:", err) 570 - return 571 - } 677 + // Use XRPC response directly instead of converting to internal types 572 678 573 679 var breadcrumbs [][]string 574 680 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) ··· 581 687 showRendered := false 582 688 renderToggle := false 583 689 584 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 690 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 585 691 renderToggle = true 586 692 showRendered = r.URL.Query().Get("code") != "true" 587 693 } ··· 591 697 var isVideo bool 592 698 var contentSrc string 593 699 594 - if result.IsBinary { 595 - ext := strings.ToLower(filepath.Ext(result.Path)) 700 + if resp.IsBinary != nil && *resp.IsBinary { 701 + ext := strings.ToLower(filepath.Ext(resp.Path)) 596 702 switch ext { 597 703 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 704 isImage = true ··· 602 708 unsupported = true 603 709 } 604 710 605 - // fetch the actual binary content like in RepoBlobRaw 711 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 712 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 713 + blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 714 + scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath)) 606 715 607 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 716 contentSrc = blobURL 609 717 if !rp.config.Core.Dev { 610 718 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 719 } 612 720 } 613 721 722 + lines := 0 723 + if resp.IsBinary == nil || !*resp.IsBinary { 724 + lines = strings.Count(resp.Content, "\n") + 1 725 + } 726 + 727 + var sizeHint uint64 728 + if resp.Size != nil { 729 + sizeHint = uint64(*resp.Size) 730 + } else { 731 + sizeHint = uint64(len(resp.Content)) 732 + } 733 + 614 734 user := rp.oauth.GetUser(r) 735 + 736 + // Determine if content is binary (dereference pointer) 737 + isBinary := false 738 + if resp.IsBinary != nil { 739 + isBinary = *resp.IsBinary 740 + } 741 + 615 742 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 616 - LoggedInUser: user, 617 - RepoInfo: f.RepoInfo(user), 618 - RepoBlobResponse: result, 619 - BreadCrumbs: breadcrumbs, 620 - ShowRendered: showRendered, 621 - RenderToggle: renderToggle, 622 - Unsupported: unsupported, 623 - IsImage: isImage, 624 - IsVideo: isVideo, 625 - ContentSrc: contentSrc, 743 + LoggedInUser: user, 744 + RepoInfo: f.RepoInfo(user), 745 + BreadCrumbs: breadcrumbs, 746 + ShowRendered: showRendered, 747 + RenderToggle: renderToggle, 748 + Unsupported: unsupported, 749 + IsImage: isImage, 750 + IsVideo: isVideo, 751 + ContentSrc: contentSrc, 752 + RepoBlob_Output: resp, 753 + Contents: resp.Content, 754 + Lines: lines, 755 + SizeHint: sizeHint, 756 + IsBinary: isBinary, 626 757 }) 627 758 } 628 759 ··· 637 768 ref := chi.URLParam(r, "ref") 638 769 filePath := chi.URLParam(r, "*") 639 770 640 - protocol := "http" 771 + scheme := "http" 641 772 if !rp.config.Core.Dev { 642 - protocol = "https" 773 + scheme = "https" 643 774 } 644 775 645 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 776 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 777 + blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true", 778 + scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath)) 646 779 647 780 req, err := http.NewRequest("GET", blobURL, nil) 648 781 if err != nil { ··· 685 818 return 686 819 } 687 820 688 - if strings.Contains(contentType, "text/plain") { 821 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 822 + // serve all textual content as text/plain 689 823 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 824 w.Write(body) 691 825 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 826 + // serve images and videos with their original content type 692 827 w.Header().Set("Content-Type", contentType) 693 828 w.Write(body) 694 829 } else { ··· 698 833 } 699 834 } 700 835 836 + // isTextualMimeType returns true if the MIME type represents textual content 837 + // that should be served as text/plain 838 + func isTextualMimeType(mimeType string) bool { 839 + textualTypes := []string{ 840 + "application/json", 841 + "application/xml", 842 + "application/yaml", 843 + "application/x-yaml", 844 + "application/toml", 845 + "application/javascript", 846 + "application/ecmascript", 847 + "message/", 848 + } 849 + 850 + return slices.Contains(textualTypes, mimeType) 851 + } 852 + 701 853 // modify the spindle configured for this repo 702 854 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 703 855 user := rp.oauth.GetUser(r) ··· 1201 1353 f, err := rp.repoResolver.Resolve(r) 1202 1354 user := rp.oauth.GetUser(r) 1203 1355 1204 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1356 + scheme := "http" 1357 + if !rp.config.Core.Dev { 1358 + scheme = "https" 1359 + } 1360 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1361 + xrpcc := &indigoxrpc.Client{ 1362 + Host: host, 1363 + } 1364 + 1365 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1366 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1205 1367 if err != nil { 1206 - log.Println("failed to create unsigned client", err) 1368 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1369 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1370 + rp.pages.Error503(w) 1371 + return 1372 + } 1373 + rp.pages.Error503(w) 1207 1374 return 1208 1375 } 1209 1376 1210 - result, err := us.Branches(f.OwnerDid(), f.Name) 1211 - if err != nil { 1377 + var result types.RepoBranchesResponse 1378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1379 + log.Println("failed to decode XRPC response", err) 1212 1380 rp.pages.Error503(w) 1213 - log.Println("failed to reach knotserver", err) 1214 1381 return 1215 1382 } 1216 1383 ··· 1581 1748 return 1582 1749 } 1583 1750 1584 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1751 + scheme := "http" 1752 + if !rp.config.Core.Dev { 1753 + scheme = "https" 1754 + } 1755 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1756 + xrpcc := &indigoxrpc.Client{ 1757 + Host: host, 1758 + } 1759 + 1760 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1761 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1585 1762 if err != nil { 1586 - log.Printf("failed to create unsigned client for %s", f.Knot) 1587 - rp.pages.Error503(w) 1763 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1764 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1765 + rp.pages.Error503(w) 1766 + return 1767 + } 1768 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1588 1769 return 1589 1770 } 1590 1771 1591 - result, err := us.Branches(f.OwnerDid(), f.Name) 1592 - if err != nil { 1772 + var branchResult types.RepoBranchesResponse 1773 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1774 + log.Println("failed to decode XRPC branches response", err) 1593 1775 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1594 - log.Println("failed to reach knotserver", err) 1595 1776 return 1596 1777 } 1597 - branches := result.Branches 1778 + branches := branchResult.Branches 1598 1779 1599 1780 sortBranches(branches) 1600 1781 ··· 1618 1799 head = queryHead 1619 1800 } 1620 1801 1621 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1802 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1622 1803 if err != nil { 1804 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1805 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1806 + rp.pages.Error503(w) 1807 + return 1808 + } 1623 1809 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 - log.Println("failed to reach knotserver", err) 1810 + return 1811 + } 1812 + 1813 + var tags types.RepoTagsResponse 1814 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1815 + log.Println("failed to decode XRPC tags response", err) 1816 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1625 1817 return 1626 1818 } 1627 1819 ··· 1673 1865 return 1674 1866 } 1675 1867 1676 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1868 + scheme := "http" 1869 + if !rp.config.Core.Dev { 1870 + scheme = "https" 1871 + } 1872 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1873 + xrpcc := &indigoxrpc.Client{ 1874 + Host: host, 1875 + } 1876 + 1877 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1878 + 1879 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1677 1880 if err != nil { 1678 - log.Printf("failed to create unsigned client for %s", f.Knot) 1679 - rp.pages.Error503(w) 1881 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1882 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1883 + rp.pages.Error503(w) 1884 + return 1885 + } 1886 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1680 1887 return 1681 1888 } 1682 1889 1683 - branches, err := us.Branches(f.OwnerDid(), f.Name) 1684 - if err != nil { 1890 + var branches types.RepoBranchesResponse 1891 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1892 + log.Println("failed to decode XRPC branches response", err) 1685 1893 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1686 - log.Println("failed to reach knotserver", err) 1687 1894 return 1688 1895 } 1689 1896 1690 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1897 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1691 1898 if err != nil { 1899 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1900 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1901 + rp.pages.Error503(w) 1902 + return 1903 + } 1692 1904 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1693 - log.Println("failed to reach knotserver", err) 1694 1905 return 1695 1906 } 1696 1907 1697 - formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1908 + var tags types.RepoTagsResponse 1909 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1910 + log.Println("failed to decode XRPC tags response", err) 1911 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1912 + return 1913 + } 1914 + 1915 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1698 1916 if err != nil { 1917 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1918 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1919 + rp.pages.Error503(w) 1920 + return 1921 + } 1699 1922 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1700 - log.Println("failed to compare", err) 1701 1923 return 1702 1924 } 1925 + 1926 + var formatPatch types.RepoFormatPatchResponse 1927 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1928 + log.Println("failed to decode XRPC compare response", err) 1929 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1930 + return 1931 + } 1932 + 1703 1933 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1704 1934 1705 1935 repoinfo := f.RepoInfo(user)
+11 -27
appview/serververify/verify.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 7 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 12 10 "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 13 12 "tangled.sh/tangled.sh/core/rbac" 14 13 ) 15 14 ··· 24 23 scheme = "http" 25 24 } 26 25 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 40 29 } 41 30 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 45 34 } 46 35 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 36 + return res.Owner, nil 53 37 } 54 38 55 39 type OwnerMismatch struct { ··· 65 49 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 50 observedOwner, err := fetchOwner(ctx, domain, dev) 67 51 if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 52 + return err 69 53 } 70 54 71 55 if observedOwner != expectedOwner {
+4 -3
appview/spindles/spindles.go
··· 16 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 17 "tangled.sh/tangled.sh/core/appview/pages" 18 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 19 20 "tangled.sh/tangled.sh/core/idresolver" 20 21 "tangled.sh/tangled.sh/core/rbac" 21 22 "tangled.sh/tangled.sh/core/tid" ··· 404 405 if err != nil { 405 406 l.Error("verification failed", "err", err) 406 407 407 - if errors.Is(err, serververify.FetchError) { 408 - s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 408 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 409 + s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 409 410 return 410 411 } 411 412 ··· 442 443 } 443 444 444 445 w.Header().Set("HX-Reswap", "outerHTML") 445 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 446 447 } 447 448 448 449 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
+197 -137
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 24 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 24 tabVal := r.URL.Query().Get("tab") 26 25 switch tabVal { 27 - case "": 28 - s.profileHomePage(w, r) 29 26 case "repos": 30 27 s.reposPage(w, r) 31 28 case "followers": 32 29 s.followersPage(w, r) 33 30 case "following": 34 31 s.followingPage(w, r) 32 + case "starred": 33 + s.starredPage(w, r) 34 + case "strings": 35 + s.stringsPage(w, r) 36 + default: 37 + s.profileOverview(w, r) 35 38 } 36 39 } 37 40 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 { 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 45 42 didOrHandle := chi.URLParam(r, "user") 46 43 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 44 + return nil, fmt.Errorf("empty DID or handle") 49 45 } 50 46 51 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 48 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 49 + return nil, fmt.Errorf("failed to resolve ID") 56 50 } 57 51 did := ident.DID.String() 58 52 59 53 profile, err := db.GetProfile(s.db, did) 60 54 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 55 + return nil, fmt.Errorf("failed to get profile: %w", err) 56 + } 57 + 58 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 59 + if err != nil { 60 + return nil, fmt.Errorf("failed to get repo count: %w", err) 61 + } 62 + 63 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get string count: %w", err) 66 + } 67 + 68 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 + if err != nil { 70 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 64 71 } 65 72 66 73 followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 74 if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 69 76 } 70 77 71 78 loggedInUser := s.oauth.GetUser(r) ··· 74 81 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 82 } 76 83 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, 84 + now := time.Now() 85 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 86 + punchcard, err := db.MakePunchcard( 87 + s.db, 88 + db.FilterEq("did", did), 89 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 90 + db.FilterLte("date", now.Format(time.DateOnly)), 91 + ) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 94 + } 95 + 96 + return &pages.ProfileCard{ 97 + UserDid: did, 98 + UserHandle: ident.Handle.String(), 99 + Profile: profile, 100 + FollowStatus: followStatus, 101 + Stats: pages.ProfileStats{ 102 + RepoCount: repoCount, 103 + StringCount: stringCount, 104 + StarredCount: starredCount, 85 105 FollowersCount: followStats.Followers, 86 106 FollowingCount: followStats.Following, 87 107 }, 88 - } 108 + Punchcard: punchcard, 109 + }, nil 89 110 } 90 111 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 112 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 113 + l := s.logger.With("handler", "profileHomePage") 114 + 115 + profile, err := s.profile(r) 116 + if err != nil { 117 + l.Error("failed to build profile card", "err", err) 118 + s.pages.Error500(w) 94 119 return 95 120 } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 96 122 97 - id := pageWithProfile.Id 98 123 repos, err := db.GetRepos( 99 124 s.db, 100 125 0, 101 - db.FilterEq("did", id.DID), 126 + db.FilterEq("did", profile.UserDid), 102 127 ) 103 128 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 129 + l.Error("failed to fetch repos", "err", err) 105 130 } 106 131 107 - profile := pageWithProfile.Card.Profile 108 132 // filter out ones that are pinned 109 133 pinnedRepos := []db.Repo{} 110 134 for i, r := range repos { 111 135 // if this is a pinned repo, add it 112 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 113 137 pinnedRepos = append(pinnedRepos, r) 114 138 } 115 139 116 140 // if there are no saved pins, add the first 4 repos 117 - if profile.IsPinnedReposEmpty() && i < 4 { 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 118 142 pinnedRepos = append(pinnedRepos, r) 119 143 } 120 144 } 121 145 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 123 147 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 148 + l.Error("failed to fetch collaborating repos", "err", err) 125 149 } 126 150 127 151 pinnedCollaboratingRepos := []db.Repo{} 128 152 for _, r := range collaboratingRepos { 129 153 // if this is a pinned repo, add it 130 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 131 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 132 156 } 133 157 } 134 158 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 136 160 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 161 + l.Error("failed to create timeline", "err", err) 138 162 } 139 163 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 164 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 165 + LoggedInUser: s.oauth.GetUser(r), 166 + Card: profile, 167 + Repos: pinnedRepos, 168 + CollaboratingRepos: pinnedCollaboratingRepos, 169 + ProfileTimeline: timeline, 170 + }) 171 + } 172 + 173 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 174 + l := s.logger.With("handler", "reposPage") 175 + 176 + profile, err := s.profile(r) 177 + if err != nil { 178 + l.Error("failed to build profile card", "err", err) 179 + s.pages.Error500(w) 180 + return 143 181 } 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 - } 157 - } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 158 183 159 - now := time.Now() 160 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 - punchcard, err := db.MakePunchcard( 184 + repos, err := db.GetRepos( 162 185 s.db, 163 - db.FilterEq("did", id.DID), 164 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 - db.FilterLte("date", now.Format(time.DateOnly)), 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 166 188 ) 167 189 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 169 193 } 170 194 171 - s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 - LoggedInUser: pageWithProfile.LoggedInUser, 173 - Repos: pinnedRepos, 174 - CollaboratingRepos: pinnedCollaboratingRepos, 175 - Card: pageWithProfile.Card, 176 - Punchcard: punchcard, 177 - ProfileTimeline: timeline, 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 178 199 }) 179 200 } 180 201 181 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 202 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 203 + l := s.logger.With("handler", "starredPage") 204 + 205 + profile, err := s.profile(r) 206 + if err != nil { 207 + l.Error("failed to build profile card", "err", err) 208 + s.pages.Error500(w) 184 209 return 185 210 } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 186 212 187 - id := pageWithProfile.Id 213 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 214 + if err != nil { 215 + l.Error("failed to get stars", "err", err) 216 + s.pages.Error500(w) 217 + return 218 + } 219 + var repoAts []string 220 + for _, s := range stars { 221 + repoAts = append(repoAts, string(s.RepoAt)) 222 + } 223 + 188 224 repos, err := db.GetRepos( 189 225 s.db, 190 226 0, 191 - db.FilterEq("did", id.DID), 227 + db.FilterIn("at_uri", repoAts), 192 228 ) 193 229 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 195 233 } 196 234 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 199 237 Repos: repos, 200 - Card: pageWithProfile.Card, 238 + Card: profile, 239 + }) 240 + } 241 + 242 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 243 + l := s.logger.With("handler", "stringsPage") 244 + 245 + profile, err := s.profile(r) 246 + if err != nil { 247 + l.Error("failed to build profile card", "err", err) 248 + s.pages.Error500(w) 249 + return 250 + } 251 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 252 + 253 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 254 + if err != nil { 255 + l.Error("failed to get strings", "err", err) 256 + s.pages.Error500(w) 257 + return 258 + } 259 + 260 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 261 + LoggedInUser: s.oauth.GetUser(r), 262 + Strings: strings, 263 + Card: profile, 201 264 }) 202 265 } 203 266 204 267 type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 208 270 } 209 271 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 272 + func (s *State) followPage( 273 + r *http.Request, 274 + fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 + extractDid func(db.Follow) string, 276 + ) (*FollowsPageParams, error) { 277 + l := s.logger.With("handler", "reposPage") 278 + 279 + profile, err := s.profile(r) 280 + if err != nil { 281 + return nil, err 214 282 } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 215 284 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 285 + loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 218 289 219 - follows, err := fetchFollows(s.db, id.DID.String()) 290 + follows, err := fetchFollows(s.db, profile.UserDid) 220 291 if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 223 294 } 224 295 225 296 if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 297 + return &params, nil 231 298 } 232 299 233 300 followDids := make([]string, 0, len(follows)) ··· 237 304 238 305 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 306 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 242 309 } 243 310 244 311 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 246 313 log.Printf("getting follow counts for %s: %s", followDids, err) 247 314 } 248 315 249 - var loggedInUserFollowing map[string]struct{} 316 + loggedInUserFollowing := make(map[string]struct{}) 250 317 if loggedInUser != nil { 251 318 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 319 if err != nil { 253 - return FollowsPageParams{}, err 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 254 322 } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 260 326 } 261 327 } 262 328 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 - } 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 269 332 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 - } 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 276 337 } 338 + 277 339 var profile *db.Profile 278 340 if p, exists := profiles[did]; exists { 279 341 profile = p ··· 281 343 profile = &db.Profile{} 282 344 profile.Did = did 283 345 } 284 - followCards = append(followCards, pages.FollowCard{ 346 + followCards[i] = pages.FollowCard{ 285 347 UserDid: did, 286 348 FollowStatus: followStatus, 287 349 FollowersCount: followStats.Followers, 288 350 FollowingCount: followStats.Following, 289 351 Profile: profile, 290 - }) 352 + } 291 353 } 292 354 293 - return FollowsPageParams{ 294 - LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 355 + params.Follows = followCards 356 + 357 + return &params, nil 298 358 } 299 359 300 360 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 }) 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 362 if err != nil { 303 363 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 364 return 305 365 } 306 366 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 309 369 Followers: followPage.Follows, 310 370 Card: followPage.Card, 311 371 }) 312 372 } 313 373 314 374 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 }) 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 376 if err != nil { 317 377 s.pages.Notice(w, "all-following", "Failed to load following") 318 378 return 319 379 } 320 380 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 323 383 Following: followPage.Follows, 324 384 Card: followPage.Card, 325 385 }) ··· 408 468 409 469 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 470 for _, issue := range issues { 411 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 412 472 if err != nil { 413 473 return err 414 474 } ··· 440 500 441 501 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 502 return &feeds.Item{ 443 - Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 444 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 503 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 445 505 Created: issue.Created, 446 506 Author: author, 447 507 } ··· 642 702 log.Printf("getting profile data for %s: %s", user.Did, err) 643 703 } 644 704 645 - repos, err := db.GetAllReposByDid(s.db, user.Did) 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 646 706 if err != nil { 647 707 log.Printf("getting repos for %s: %s", user.Did, err) 648 708 }
+4 -2
appview/state/router.go
··· 111 111 112 112 r.Handle("/static/*", s.pages.Static()) 113 113 114 - r.Get("/", s.Timeline) 114 + r.Get("/", s.HomeOrTimeline) 115 + r.Get("/timeline", s.Timeline) 116 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 115 117 116 118 r.Route("/repo", func(r chi.Router) { 117 119 r.Route("/new", func(r chi.Router) { ··· 230 232 } 231 233 232 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 233 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 234 236 return issues.Router(mw) 235 237 } 236 238
+76 -4
appview/state/state.go
··· 28 28 "tangled.sh/tangled.sh/core/appview/pages" 29 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 31 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 33 "tangled.sh/tangled.sh/core/eventconsumer" 33 34 "tangled.sh/tangled.sh/core/idresolver" ··· 53 54 knotstream *eventconsumer.Consumer 54 55 spindlestream *eventconsumer.Consumer 55 56 logger *slog.Logger 57 + validator *validator.Validator 56 58 } 57 59 58 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 73 75 } 74 76 75 77 pgs := pages.NewPages(config, res) 76 - 77 78 cache := cache.New(config.Redis.Addr) 78 79 sess := session.New(cache) 79 - 80 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 81 82 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 83 84 if err != nil { ··· 121 122 IdResolver: res, 122 123 Config: config, 123 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 124 126 } 125 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 126 128 if err != nil { ··· 160 162 knotstream, 161 163 spindlestream, 162 164 slog.Default(), 165 + validator, 163 166 } 164 167 165 168 return state, nil 166 169 } 167 170 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 174 + } 175 + 168 176 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 169 177 w.Header().Set("Content-Type", "image/svg+xml") 170 178 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 190 198 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 191 199 LoggedInUser: user, 192 200 }) 201 + } 202 + 203 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 + if s.oauth.GetUser(r) != nil { 205 + s.Timeline(w, r) 206 + return 207 + } 208 + s.Home(w, r) 193 209 } 194 210 195 211 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 196 212 user := s.oauth.GetUser(r) 197 213 198 - timeline, err := db.MakeTimeline(s.db) 214 + timeline, err := db.MakeTimeline(s.db, 50) 199 215 if err != nil { 200 216 log.Println(err) 201 217 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 215 231 }) 216 232 } 217 233 234 + func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 235 + user := s.oauth.GetUser(r) 236 + l := s.logger.With("handler", "UpgradeBanner") 237 + l = l.With("did", user.Did) 238 + l = l.With("handle", user.Handle) 239 + 240 + regs, err := db.GetRegistrations( 241 + s.db, 242 + db.FilterEq("did", user.Did), 243 + db.FilterEq("needs_upgrade", 1), 244 + ) 245 + if err != nil { 246 + l.Error("non-fatal: failed to get registrations", "err", err) 247 + } 248 + 249 + spindles, err := db.GetSpindles( 250 + s.db, 251 + db.FilterEq("owner", user.Did), 252 + db.FilterEq("needs_upgrade", 1), 253 + ) 254 + if err != nil { 255 + l.Error("non-fatal: failed to get spindles", "err", err) 256 + } 257 + 258 + if regs == nil && spindles == nil { 259 + return 260 + } 261 + 262 + s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 263 + Registrations: regs, 264 + Spindles: spindles, 265 + }) 266 + } 267 + 268 + func (s *State) Home(w http.ResponseWriter, r *http.Request) { 269 + timeline, err := db.MakeTimeline(s.db, 5) 270 + if err != nil { 271 + log.Println(err) 272 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 273 + return 274 + } 275 + 276 + repos, err := db.GetTopStarredReposLastWeek(s.db) 277 + if err != nil { 278 + log.Println(err) 279 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 280 + return 281 + } 282 + 283 + s.pages.Home(w, pages.TimelineParams{ 284 + LoggedInUser: nil, 285 + Timeline: timeline, 286 + Repos: repos, 287 + }) 288 + } 289 + 218 290 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 219 291 user := chi.URLParam(r, "user") 220 292 user = strings.TrimPrefix(user, "@") ··· 243 315 244 316 for _, k := range pubKeys { 245 317 key := strings.TrimRight(k.Key, "\n") 246 - w.Write([]byte(fmt.Sprintln(key))) 318 + fmt.Fprintln(w, key) 247 319 } 248 320 } 249 321
+1 -59
appview/strings/strings.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "path" 8 - "slices" 9 8 "strconv" 10 9 "time" 11 10 ··· 161 160 } 162 161 163 162 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 - l := s.Logger.With("handler", "dashboard") 165 - 166 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 - if !ok { 168 - l.Error("malformed middleware") 169 - w.WriteHeader(http.StatusInternalServerError) 170 - return 171 - } 172 - l = l.With("did", id.DID, "handle", id.Handle) 173 - 174 - all, err := db.GetStrings( 175 - s.Db, 176 - 0, 177 - db.FilterEq("did", id.DID), 178 - ) 179 - if err != nil { 180 - l.Error("failed to fetch strings", "err", err) 181 - w.WriteHeader(http.StatusInternalServerError) 182 - return 183 - } 184 - 185 - slices.SortFunc(all, func(a, b db.String) int { 186 - if a.Created.After(b.Created) { 187 - return -1 188 - } else { 189 - return 1 190 - } 191 - }) 192 - 193 - profile, err := db.GetProfile(s.Db, id.DID.String()) 194 - if err != nil { 195 - l.Error("failed to fetch user profile", "err", err) 196 - w.WriteHeader(http.StatusInternalServerError) 197 - return 198 - } 199 - loggedInUser := s.OAuth.GetUser(r) 200 - followStatus := db.IsNotFollowing 201 - if loggedInUser != nil { 202 - followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 - } 204 - 205 - followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 - if err != nil { 207 - l.Error("failed to get follow stats", "err", err) 208 - } 209 - 210 - s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 - LoggedInUser: s.OAuth.GetUser(r), 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, 219 - }, 220 - Strings: all, 221 - }) 163 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 222 164 } 223 165 224 166 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+53
appview/validator/issue.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.sh/tangled.sh/core/appview/db" 8 + ) 9 + 10 + func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + // if comments have parents, only ingest ones that are 1 level deep 12 + if comment.ReplyTo != nil { 13 + parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 14 + if err != nil { 15 + return fmt.Errorf("failed to fetch parent comment: %w", err) 16 + } 17 + if len(parents) != 1 { 18 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 19 + } 20 + 21 + // depth check 22 + parent := parents[0] 23 + if parent.ReplyTo != nil { 24 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 25 + } 26 + } 27 + 28 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 29 + return fmt.Errorf("body is empty after HTML sanitization") 30 + } 31 + 32 + return nil 33 + } 34 + 35 + func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + if issue.Title == "" { 37 + return fmt.Errorf("issue title is empty") 38 + } 39 + 40 + if issue.Body == "" { 41 + return fmt.Errorf("issue body is empty") 42 + } 43 + 44 + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 45 + return fmt.Errorf("title is empty after HTML sanitization") 46 + } 47 + 48 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 49 + return fmt.Errorf("body is empty after HTML sanitization") 50 + } 51 + 52 + return nil 53 + }
+18
appview/validator/validator.go
··· 1 + package validator 2 + 3 + import ( 4 + "tangled.sh/tangled.sh/core/appview/db" 5 + "tangled.sh/tangled.sh/core/appview/pages/markup" 6 + ) 7 + 8 + type Validator struct { 9 + db *db.DB 10 + sanitizer markup.Sanitizer 11 + } 12 + 13 + func New(db *db.DB) *Validator { 14 + return &Validator{ 15 + db: db, 16 + sanitizer: markup.NewSanitizer(), 17 + } 18 + }
+11 -5
appview/xrpcclient/xrpc.go
··· 4 4 "bytes" 5 5 "context" 6 6 "errors" 7 - "fmt" 8 7 "io" 9 8 "net/http" 10 9 ··· 12 11 "github.com/bluesky-social/indigo/xrpc" 13 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 13 oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 + ) 15 + 16 + var ( 17 + ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") 18 + ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") 19 + ErrXrpcFailed = errors.New("xrpc request failed") 20 + ErrXrpcInvalid = errors.New("invalid xrpc request") 15 21 ) 16 22 17 23 type Client struct { ··· 115 121 116 122 var xrpcerr *indigoxrpc.Error 117 123 if ok := errors.As(err, &xrpcerr); !ok { 118 - return fmt.Errorf("Recieved invalid XRPC error response.") 124 + return ErrXrpcInvalid 119 125 } 120 126 121 127 switch xrpcerr.StatusCode { 122 128 case http.StatusNotFound: 123 - return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 129 + return ErrXrpcUnsupported 124 130 case http.StatusUnauthorized: 125 - return fmt.Errorf("Unauthorized XRPC request.") 131 + return ErrXrpcUnauthorized 126 132 default: 127 - return fmt.Errorf("Failed to perform operation. Try again later.") 133 + return ErrXrpcFailed 128 134 } 129 135 }
+3
cmd/appview/main.go
··· 23 23 } 24 24 25 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 26 29 27 30 if err != nil { 28 31 log.Fatal(err)
+53 -12
docs/hacking.md
··· 48 48 redis-server 49 49 ``` 50 50 51 - ## running a knot 51 + ## running knots and spindles 52 52 53 53 An end-to-end knot setup requires setting up a machine with 54 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, grab your DID from http://localhost:3000/settings. 59 - Then, set `TANGLED_VM_KNOT_OWNER` and 60 - `TANGLED_VM_SPINDLE_OWNER` to your DID. 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 60 + 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 + 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 61 90 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 64 96 65 - You can now start a lightweight NixOS VM like so: 97 + </details> 98 + 99 + To begin, grab your DID from http://localhost:3000/settings. 100 + Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 66 103 67 104 ```bash 68 105 nix run --impure .#vm ··· 74 111 with `ssh` exposed on port 2222. 75 112 76 113 Once the services are running, head to 77 - http://localhost:3000/knots and hit verify (and similarly, 78 - http://localhost:3000/spindles to verify your spindle). It 79 - should verify the ownership of the services instantly if 80 - everything went smoothly. 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 81 117 82 118 You can push repositories to this VM with this ssh config 83 119 block on your main machine: ··· 97 133 git push local-dev main 98 134 ``` 99 135 100 - ## running a spindle 136 + ### running a spindle 101 137 102 138 The above VM should already be running a spindle on 103 139 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 119 155 # litecli has a nicer REPL interface: 120 156 litecli /var/lib/spindle/spindle.db 121 157 ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
-35
docs/migrations/knot-1.7.0.md
··· 1 - # Upgrading from v1.7.0 2 - 3 - After v1.7.0, knot secrets have been deprecated. You no 4 - longer need a secret from the appview to run a knot. All 5 - authorized commands to knots are managed via [Inter-Service 6 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 7 - Knots will be read-only until upgraded. 8 - 9 - Upgrading is quite easy, in essence: 10 - 11 - - `KNOT_SERVER_SECRET` is no more, you can remove this 12 - environment variable entirely 13 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 14 - your DID. You can find your DID in the 15 - [settings](https://tangled.sh/settings) page. 16 - - Restart your knot once you have replaced the environment 17 - variable 18 - - Head to the [knot dashboard](https://tangled.sh/knots) and 19 - hit the "retry" button to verify your knot. This simply 20 - writes a `sh.tangled.knot` record to your PDS. 21 - 22 - ## Nix 23 - 24 - If you use the nix module, simply bump the flake to the 25 - latest revision, and change your config block like so: 26 - 27 - ```diff 28 - services.tangled-knot = { 29 - enable = true; 30 - server = { 31 - - secretFile = /path/to/secret; 32 - + owner = "did:plc:foo"; 33 - }; 34 - }; 35 - ```
+60
docs/migrations.md
··· 1 + # Migrations 2 + 3 + This document is laid out in reverse-chronological order. 4 + Newer migration guides are listed first, and older guides 5 + are further down the page. 6 + 7 + ## Upgrading from v1.8.x 8 + 9 + After v1.8.2, the HTTP API for knot and spindles have been 10 + deprecated and replaced with XRPC. Repositories on outdated 11 + knots will not be viewable from the appview. Upgrading is 12 + straightforward however. 13 + 14 + For knots: 15 + 16 + - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.sh/knots) and 18 + hit the "retry" button to verify your knot 19 + 20 + For spindles: 21 + 22 + - Upgrade to latest tag (v1.9.0 or above) 23 + - Head to the [spindle 24 + dashboard](https://tangled.sh/spindles) and hit the 25 + "retry" button to verify your spindle 26 + 27 + ## Upgrading from v1.7.x 28 + 29 + After v1.7.0, knot secrets have been deprecated. You no 30 + longer need a secret from the appview to run a knot. All 31 + authorized commands to knots are managed via [Inter-Service 32 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 + Knots will be read-only until upgraded. 34 + 35 + Upgrading is quite easy, in essence: 36 + 37 + - `KNOT_SERVER_SECRET` is no more, you can remove this 38 + environment variable entirely 39 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 + your DID. You can find your DID in the 41 + [settings](https://tangled.sh/settings) page. 42 + - Restart your knot once you have replaced the environment 43 + variable 44 + - Head to the [knot dashboard](https://tangled.sh/knots) and 45 + hit the "retry" button to verify your knot. This simply 46 + writes a `sh.tangled.knot` record to your PDS. 47 + 48 + If you use the nix module, simply bump the flake to the 49 + latest revision, and change your config block like so: 50 + 51 + ```diff 52 + services.tangled-knot = { 53 + enable = true; 54 + server = { 55 + - secretFile = /path/to/secret; 56 + + owner = "did:plc:foo"; 57 + }; 58 + }; 59 + ``` 60 +
+1 -1
input.css
··· 90 90 } 91 91 92 92 label { 93 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 94 } 95 95 input { 96 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
-285
knotclient/unsigned.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "strconv" 12 - "time" 13 - 14 - "tangled.sh/tangled.sh/core/types" 15 - ) 16 - 17 - type UnsignedClient struct { 18 - Url *url.URL 19 - client *http.Client 20 - } 21 - 22 - func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 23 - client := &http.Client{ 24 - Timeout: 5 * time.Second, 25 - } 26 - 27 - scheme := "https" 28 - if dev { 29 - scheme = "http" 30 - } 31 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 32 - if err != nil { 33 - return nil, err 34 - } 35 - 36 - unsignedClient := &UnsignedClient{ 37 - client: client, 38 - Url: url, 39 - } 40 - 41 - return unsignedClient, nil 42 - } 43 - 44 - func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) { 45 - reqUrl := us.Url.JoinPath(endpoint) 46 - 47 - // add query parameters 48 - if query != nil { 49 - reqUrl.RawQuery = query.Encode() 50 - } 51 - 52 - return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body)) 53 - } 54 - 55 - func do[T any](us *UnsignedClient, req *http.Request) (*T, error) { 56 - resp, err := us.client.Do(req) 57 - if err != nil { 58 - return nil, err 59 - } 60 - defer resp.Body.Close() 61 - 62 - body, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - log.Printf("Error reading response body: %v", err) 65 - return nil, err 66 - } 67 - 68 - var result T 69 - err = json.Unmarshal(body, &result) 70 - if err != nil { 71 - log.Printf("Error unmarshalling response body: %v", err) 72 - return nil, err 73 - } 74 - 75 - return &result, nil 76 - } 77 - 78 - func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) { 79 - const ( 80 - Method = "GET" 81 - ) 82 - 83 - endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 84 - if ref == "" { 85 - endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 86 - } 87 - 88 - req, err := us.newRequest(Method, endpoint, nil, nil) 89 - if err != nil { 90 - return nil, err 91 - } 92 - 93 - return do[types.RepoIndexResponse](us, req) 94 - } 95 - 96 - func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) { 97 - const ( 98 - Method = "GET" 99 - ) 100 - 101 - endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref)) 102 - 103 - query := url.Values{} 104 - query.Add("page", strconv.Itoa(page)) 105 - query.Add("per_page", strconv.Itoa(60)) 106 - 107 - req, err := us.newRequest(Method, endpoint, query, nil) 108 - if err != nil { 109 - return nil, err 110 - } 111 - 112 - return do[types.RepoLogResponse](us, req) 113 - } 114 - 115 - func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) { 116 - const ( 117 - Method = "GET" 118 - ) 119 - 120 - endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 121 - 122 - req, err := us.newRequest(Method, endpoint, nil, nil) 123 - if err != nil { 124 - return nil, err 125 - } 126 - 127 - return do[types.RepoBranchesResponse](us, req) 128 - } 129 - 130 - func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 131 - const ( 132 - Method = "GET" 133 - ) 134 - 135 - endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName) 136 - 137 - req, err := us.newRequest(Method, endpoint, nil, nil) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - return do[types.RepoTagsResponse](us, req) 143 - } 144 - 145 - func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) { 146 - const ( 147 - Method = "GET" 148 - ) 149 - 150 - endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch)) 151 - 152 - req, err := us.newRequest(Method, endpoint, nil, nil) 153 - if err != nil { 154 - return nil, err 155 - } 156 - 157 - return do[types.RepoBranchResponse](us, req) 158 - } 159 - 160 - func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) { 161 - const ( 162 - Method = "GET" 163 - ) 164 - 165 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 166 - 167 - req, err := us.newRequest(Method, endpoint, nil, nil) 168 - if err != nil { 169 - return nil, err 170 - } 171 - 172 - resp, err := us.client.Do(req) 173 - if err != nil { 174 - return nil, err 175 - } 176 - defer resp.Body.Close() 177 - 178 - var defaultBranch types.RepoDefaultBranchResponse 179 - if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil { 180 - return nil, err 181 - } 182 - 183 - return &defaultBranch, nil 184 - } 185 - 186 - func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 187 - const ( 188 - Method = "GET" 189 - Endpoint = "/capabilities" 190 - ) 191 - 192 - req, err := us.newRequest(Method, Endpoint, nil, nil) 193 - if err != nil { 194 - return nil, err 195 - } 196 - 197 - resp, err := us.client.Do(req) 198 - if err != nil { 199 - return nil, err 200 - } 201 - defer resp.Body.Close() 202 - 203 - var capabilities types.Capabilities 204 - if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 205 - return nil, err 206 - } 207 - 208 - return &capabilities, nil 209 - } 210 - 211 - func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 212 - const ( 213 - Method = "GET" 214 - ) 215 - 216 - endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 217 - 218 - req, err := us.newRequest(Method, endpoint, nil, nil) 219 - if err != nil { 220 - return nil, fmt.Errorf("Failed to create request.") 221 - } 222 - 223 - compareResp, err := us.client.Do(req) 224 - if err != nil { 225 - return nil, fmt.Errorf("Failed to create request.") 226 - } 227 - defer compareResp.Body.Close() 228 - 229 - switch compareResp.StatusCode { 230 - case 404: 231 - case 400: 232 - return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 233 - } 234 - 235 - respBody, err := io.ReadAll(compareResp.Body) 236 - if err != nil { 237 - log.Println("failed to compare across branches") 238 - return nil, fmt.Errorf("Failed to compare branches.") 239 - } 240 - defer compareResp.Body.Close() 241 - 242 - var formatPatchResponse types.RepoFormatPatchResponse 243 - err = json.Unmarshal(respBody, &formatPatchResponse) 244 - if err != nil { 245 - log.Println("failed to unmarshal format-patch response", err) 246 - return nil, fmt.Errorf("failed to compare branches.") 247 - } 248 - 249 - return &formatPatchResponse, nil 250 - } 251 - 252 - func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 253 - const ( 254 - Method = "GET" 255 - ) 256 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 257 - 258 - req, err := s.newRequest(Method, endpoint, nil, nil) 259 - if err != nil { 260 - return nil, err 261 - } 262 - 263 - resp, err := s.client.Do(req) 264 - if err != nil { 265 - return nil, err 266 - } 267 - 268 - var result types.RepoLanguageResponse 269 - if resp.StatusCode != http.StatusOK { 270 - log.Println("failed to calculate languages", resp.Status) 271 - return &types.RepoLanguageResponse{}, nil 272 - } 273 - 274 - body, err := io.ReadAll(resp.Body) 275 - if err != nil { 276 - return nil, err 277 - } 278 - 279 - err = json.Unmarshal(body, &result) 280 - if err != nil { 281 - return nil, err 282 - } 283 - 284 - return &result, nil 285 - }
+7
knotserver/config/config.go
··· 27 27 Dev bool `env:"DEV, default=false"` 28 28 } 29 29 30 + type Git struct { 31 + // user name & email used as committer 32 + UserName string `env:"USER_NAME, default=Tangled"` 33 + UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"` 34 + } 35 + 30 36 func (s Server) Did() syntax.DID { 31 37 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 38 } ··· 34 40 type Config struct { 35 41 Repo Repo `env:",prefix=KNOT_REPO_"` 36 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 37 44 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 38 45 } 39 46
+40
knotserver/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 + "strconv" 4 5 "time" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 100 101 return keys, nil 101 102 } 103 + 104 + func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { 105 + var keys []PublicKey 106 + 107 + offset := 0 108 + if cursor != "" { 109 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 110 + offset = o 111 + } 112 + } 113 + 114 + query := `select key, did, created from public_keys order by created desc limit ? offset ?` 115 + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results 116 + if err != nil { 117 + return nil, "", err 118 + } 119 + defer rows.Close() 120 + 121 + for rows.Next() { 122 + var publicKey PublicKey 123 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 124 + return nil, "", err 125 + } 126 + keys = append(keys, publicKey) 127 + } 128 + 129 + if err := rows.Err(); err != nil { 130 + return nil, "", err 131 + } 132 + 133 + // check if there are more results for pagination 134 + var nextCursor string 135 + if len(keys) > limit { 136 + keys = keys[:limit] // remove the extra item 137 + nextCursor = strconv.Itoa(offset + limit) 138 + } 139 + 140 + return keys, nextCursor, nil 141 + }
+2 -2
knotserver/events.go
··· 15 15 WriteBufferSize: 1024, 16 16 } 17 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 19 l := h.l.With("handler", "OpLog") 20 20 l.Debug("received new connection") 21 21 ··· 83 83 } 84 84 } 85 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 87 events, err := h.db.GetEvents(*cursor) 88 88 if err != nil { 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-48
knotserver/file.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "net/http" 8 - "strings" 9 - 10 - "tangled.sh/tangled.sh/core/types" 11 - ) 12 - 13 - func countLines(r io.Reader) (int, error) { 14 - buf := make([]byte, 32*1024) 15 - bufLen := 0 16 - count := 0 17 - nl := []byte{'\n'} 18 - 19 - for { 20 - c, err := r.Read(buf) 21 - if c > 0 { 22 - bufLen += c 23 - } 24 - count += bytes.Count(buf[:c], nl) 25 - 26 - switch { 27 - case err == io.EOF: 28 - /* handle last line not having a newline at the end */ 29 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 30 - count++ 31 - } 32 - return count, nil 33 - case err != nil: 34 - return 0, err 35 - } 36 - } 37 - } 38 - 39 - func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) { 40 - lc, err := countLines(strings.NewReader(resp.Contents)) 41 - if err != nil { 42 - // Non-fatal, we'll just skip showing line numbers in the template. 43 - l.Warn("counting lines", "error", err) 44 - } 45 - 46 - resp.Lines = lc 47 - writeJSON(w, resp) 48 - }
+58 -72
knotserver/git/merge.go
··· 12 12 "github.com/dgraph-io/ristretto" 13 13 "github.com/go-git/go-git/v5" 14 14 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 15 ) 17 16 18 17 type MergeCheckCache struct { ··· 86 85 87 86 // MergeOptions specifies the configuration for a merge operation 88 87 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 88 + CommitMessage string 89 + CommitBody string 90 + AuthorName string 91 + AuthorEmail string 92 + CommitterName string 93 + CommitterEmail string 94 + FormatPatch bool 94 95 } 95 96 96 97 func (e ErrMerge) Error() string { ··· 143 144 return tmpDir, nil 144 145 } 145 146 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 147 148 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 149 149 150 - if checkOnly { 151 - cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 152 - } else { 153 - // if patch is a format-patch, apply using 'git am' 154 - if opts.FormatPatch { 155 - amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 156 - amCmd.Stderr = &stderr 157 - if err := amCmd.Run(); err != nil { 158 - return fmt.Errorf("patch application failed: %s", stderr.String()) 159 - } 160 - return nil 150 + cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 151 + cmd.Stderr = &stderr 152 + 153 + if err := cmd.Run(); err != nil { 154 + conflicts := parseGitApplyErrors(stderr.String()) 155 + return &ErrMerge{ 156 + Message: "patch cannot be applied cleanly", 157 + Conflicts: conflicts, 158 + HasConflict: len(conflicts) > 0, 159 + OtherError: err, 161 160 } 162 - 163 - // else, apply using 'git apply' and commit it manually 164 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 165 - if opts != nil { 166 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 167 - applyCmd.Stderr = &stderr 168 - if err := applyCmd.Run(); err != nil { 169 - return fmt.Errorf("patch application failed: %s", stderr.String()) 170 - } 161 + } 162 + return nil 163 + } 171 164 172 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 173 - if err := stageCmd.Run(); err != nil { 174 - return fmt.Errorf("failed to stage changes: %w", err) 175 - } 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 + var stderr bytes.Buffer 167 + var cmd *exec.Cmd 176 168 177 - commitArgs := []string{"-C", tmpDir, "commit"} 169 + // configure default git user before merge 170 + exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 + exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 178 173 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 174 + // if patch is a format-patch, apply using 'git am' 175 + if opts.FormatPatch { 176 + cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 + } else { 178 + // else, apply using 'git apply' and commit it manually 179 + applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 + applyCmd.Stderr = &stderr 181 + if err := applyCmd.Run(); err != nil { 182 + return fmt.Errorf("patch application failed: %s", stderr.String()) 183 + } 182 184 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 185 + stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 + if err := stageCmd.Run(); err != nil { 187 + return fmt.Errorf("failed to stage changes: %w", err) 188 + } 186 189 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 190 + commitArgs := []string{"-C", tmpDir, "commit"} 190 191 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 194 195 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 + if authorName != "" && authorEmail != "" { 197 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 + } 199 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 200 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 202 201 - cmd = exec.Command("git", commitArgs...) 202 - } else { 203 - // If no commit message specified, use git-am which automatically creates a commit 204 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 205 } 206 + 207 + cmd = exec.Command("git", commitArgs...) 206 208 } 207 209 208 210 cmd.Stderr = &stderr 209 211 210 212 if err := cmd.Run(); err != nil { 211 - if checkOnly { 212 - conflicts := parseGitApplyErrors(stderr.String()) 213 - return &ErrMerge{ 214 - Message: "patch cannot be applied cleanly", 215 - Conflicts: conflicts, 216 - HasConflict: len(conflicts) > 0, 217 - OtherError: err, 218 - } 219 - } 220 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 214 } 222 215 ··· 227 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 221 return val 229 222 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 223 234 224 patchFile, err := g.createTempFileWithPatch(patchData) 235 225 if err != nil { ··· 249 239 } 250 240 defer os.RemoveAll(tmpDir) 251 241 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 242 + result := g.checkPatch(tmpDir, patchFile) 253 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 244 return result 255 245 } 256 246 257 - func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 258 - return g.MergeWithOptions(patchData, targetBranch, nil) 259 - } 260 - 261 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error { 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 262 248 patchFile, err := g.createTempFileWithPatch(patchData) 263 249 if err != nil { 264 250 return &ErrMerge{ ··· 277 263 } 278 264 defer os.RemoveAll(tmpDir) 279 265 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 281 267 return err 282 268 } 283 269
+4 -4
knotserver/git.go
··· 13 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 56 } 57 57 } 58 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 105 } 106 106 } 107 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 118 d.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8")
-1069
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/sha256" 7 - "encoding/json" 8 - "errors" 9 - "fmt" 10 - "log" 11 - "net/http" 12 - "net/url" 13 - "path/filepath" 14 - "strconv" 15 - "strings" 16 - "sync" 17 - "time" 18 - 19 - securejoin "github.com/cyphar/filepath-securejoin" 20 - "github.com/gliderlabs/ssh" 21 - "github.com/go-chi/chi/v5" 22 - "github.com/go-git/go-git/v5/plumbing" 23 - "github.com/go-git/go-git/v5/plumbing/object" 24 - "tangled.sh/tangled.sh/core/knotserver/db" 25 - "tangled.sh/tangled.sh/core/knotserver/git" 26 - "tangled.sh/tangled.sh/core/types" 27 - ) 28 - 29 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 30 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 31 - } 32 - 33 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 34 - w.Header().Set("Content-Type", "application/json") 35 - 36 - capabilities := map[string]any{ 37 - "pull_requests": map[string]any{ 38 - "format_patch": true, 39 - "patch_submissions": true, 40 - "branch_submissions": true, 41 - "fork_submissions": true, 42 - }, 43 - "xrpc": true, 44 - } 45 - 46 - jsonData, err := json.Marshal(capabilities) 47 - if err != nil { 48 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 49 - return 50 - } 51 - 52 - w.Write(jsonData) 53 - } 54 - 55 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 56 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 57 - l := h.l.With("path", path, "handler", "RepoIndex") 58 - ref := chi.URLParam(r, "ref") 59 - ref, _ = url.PathUnescape(ref) 60 - 61 - gr, err := git.Open(path, ref) 62 - if err != nil { 63 - plain, err2 := git.PlainOpen(path) 64 - if err2 != nil { 65 - l.Error("opening repo", "error", err2.Error()) 66 - notFound(w) 67 - return 68 - } 69 - branches, _ := plain.Branches() 70 - 71 - log.Println(err) 72 - 73 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 74 - resp := types.RepoIndexResponse{ 75 - IsEmpty: true, 76 - Branches: branches, 77 - } 78 - writeJSON(w, resp) 79 - return 80 - } else { 81 - l.Error("opening repo", "error", err.Error()) 82 - notFound(w) 83 - return 84 - } 85 - } 86 - 87 - var ( 88 - commits []*object.Commit 89 - total int 90 - branches []types.Branch 91 - files []types.NiceTree 92 - tags []object.Tag 93 - ) 94 - 95 - var wg sync.WaitGroup 96 - errorsCh := make(chan error, 5) 97 - 98 - wg.Add(1) 99 - go func() { 100 - defer wg.Done() 101 - cs, err := gr.Commits(0, 60) 102 - if err != nil { 103 - errorsCh <- fmt.Errorf("commits: %w", err) 104 - return 105 - } 106 - commits = cs 107 - }() 108 - 109 - wg.Add(1) 110 - go func() { 111 - defer wg.Done() 112 - t, err := gr.TotalCommits() 113 - if err != nil { 114 - errorsCh <- fmt.Errorf("calculating total: %w", err) 115 - return 116 - } 117 - total = t 118 - }() 119 - 120 - wg.Add(1) 121 - go func() { 122 - defer wg.Done() 123 - bs, err := gr.Branches() 124 - if err != nil { 125 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 126 - return 127 - } 128 - branches = bs 129 - }() 130 - 131 - wg.Add(1) 132 - go func() { 133 - defer wg.Done() 134 - ts, err := gr.Tags() 135 - if err != nil { 136 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 137 - return 138 - } 139 - tags = ts 140 - }() 141 - 142 - wg.Add(1) 143 - go func() { 144 - defer wg.Done() 145 - fs, err := gr.FileTree(r.Context(), "") 146 - if err != nil { 147 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 148 - return 149 - } 150 - files = fs 151 - }() 152 - 153 - wg.Wait() 154 - close(errorsCh) 155 - 156 - // show any errors 157 - for err := range errorsCh { 158 - l.Error("loading repo", "error", err.Error()) 159 - writeError(w, err.Error(), http.StatusInternalServerError) 160 - return 161 - } 162 - 163 - rtags := []*types.TagReference{} 164 - for _, tag := range tags { 165 - var target *object.Tag 166 - if tag.Target != plumbing.ZeroHash { 167 - target = &tag 168 - } 169 - tr := types.TagReference{ 170 - Tag: target, 171 - } 172 - 173 - tr.Reference = types.Reference{ 174 - Name: tag.Name, 175 - Hash: tag.Hash.String(), 176 - } 177 - 178 - if tag.Message != "" { 179 - tr.Message = tag.Message 180 - } 181 - 182 - rtags = append(rtags, &tr) 183 - } 184 - 185 - var readmeContent string 186 - var readmeFile string 187 - for _, readme := range h.c.Repo.Readme { 188 - content, _ := gr.FileContent(readme) 189 - if len(content) > 0 { 190 - readmeContent = string(content) 191 - readmeFile = readme 192 - } 193 - } 194 - 195 - if ref == "" { 196 - mainBranch, err := gr.FindMainBranch() 197 - if err != nil { 198 - writeError(w, err.Error(), http.StatusInternalServerError) 199 - l.Error("finding main branch", "error", err.Error()) 200 - return 201 - } 202 - ref = mainBranch 203 - } 204 - 205 - resp := types.RepoIndexResponse{ 206 - IsEmpty: false, 207 - Ref: ref, 208 - Commits: commits, 209 - Description: getDescription(path), 210 - Readme: readmeContent, 211 - ReadmeFileName: readmeFile, 212 - Files: files, 213 - Branches: branches, 214 - Tags: rtags, 215 - TotalCommits: total, 216 - } 217 - 218 - writeJSON(w, resp) 219 - } 220 - 221 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 222 - treePath := chi.URLParam(r, "*") 223 - ref := chi.URLParam(r, "ref") 224 - ref, _ = url.PathUnescape(ref) 225 - 226 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 227 - 228 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 229 - gr, err := git.Open(path, ref) 230 - if err != nil { 231 - notFound(w) 232 - return 233 - } 234 - 235 - files, err := gr.FileTree(r.Context(), treePath) 236 - if err != nil { 237 - writeError(w, err.Error(), http.StatusInternalServerError) 238 - l.Error("file tree", "error", err.Error()) 239 - return 240 - } 241 - 242 - resp := types.RepoTreeResponse{ 243 - Ref: ref, 244 - Parent: treePath, 245 - Description: getDescription(path), 246 - DotDot: filepath.Dir(treePath), 247 - Files: files, 248 - } 249 - 250 - writeJSON(w, resp) 251 - } 252 - 253 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 254 - treePath := chi.URLParam(r, "*") 255 - ref := chi.URLParam(r, "ref") 256 - ref, _ = url.PathUnescape(ref) 257 - 258 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 259 - 260 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 261 - gr, err := git.Open(path, ref) 262 - if err != nil { 263 - notFound(w) 264 - return 265 - } 266 - 267 - contents, err := gr.RawContent(treePath) 268 - if err != nil { 269 - writeError(w, err.Error(), http.StatusBadRequest) 270 - l.Error("file content", "error", err.Error()) 271 - return 272 - } 273 - 274 - mimeType := http.DetectContentType(contents) 275 - 276 - // exception for svg 277 - if filepath.Ext(treePath) == ".svg" { 278 - mimeType = "image/svg+xml" 279 - } 280 - 281 - contentHash := sha256.Sum256(contents) 282 - eTag := fmt.Sprintf("\"%x\"", contentHash) 283 - 284 - // allow image, video, and text/plain files to be served directly 285 - switch { 286 - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 287 - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 288 - w.WriteHeader(http.StatusNotModified) 289 - return 290 - } 291 - w.Header().Set("ETag", eTag) 292 - 293 - case strings.HasPrefix(mimeType, "text/plain"): 294 - w.Header().Set("Cache-Control", "public, no-cache") 295 - 296 - default: 297 - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) 298 - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) 299 - return 300 - } 301 - 302 - w.Header().Set("Content-Type", mimeType) 303 - w.Write(contents) 304 - } 305 - 306 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 307 - treePath := chi.URLParam(r, "*") 308 - ref := chi.URLParam(r, "ref") 309 - ref, _ = url.PathUnescape(ref) 310 - 311 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 312 - 313 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 314 - gr, err := git.Open(path, ref) 315 - if err != nil { 316 - notFound(w) 317 - return 318 - } 319 - 320 - var isBinaryFile bool = false 321 - contents, err := gr.FileContent(treePath) 322 - if errors.Is(err, git.ErrBinaryFile) { 323 - isBinaryFile = true 324 - } else if errors.Is(err, object.ErrFileNotFound) { 325 - notFound(w) 326 - return 327 - } else if err != nil { 328 - writeError(w, err.Error(), http.StatusInternalServerError) 329 - return 330 - } 331 - 332 - bytes := []byte(contents) 333 - // safe := string(sanitize(bytes)) 334 - sizeHint := len(bytes) 335 - 336 - resp := types.RepoBlobResponse{ 337 - Ref: ref, 338 - Contents: string(bytes), 339 - Path: treePath, 340 - IsBinary: isBinaryFile, 341 - SizeHint: uint64(sizeHint), 342 - } 343 - 344 - h.showFile(resp, w, l) 345 - } 346 - 347 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 348 - name := chi.URLParam(r, "name") 349 - file := chi.URLParam(r, "file") 350 - 351 - l := h.l.With("handler", "Archive", "name", name, "file", file) 352 - 353 - // TODO: extend this to add more files compression (e.g.: xz) 354 - if !strings.HasSuffix(file, ".tar.gz") { 355 - notFound(w) 356 - return 357 - } 358 - 359 - ref := strings.TrimSuffix(file, ".tar.gz") 360 - 361 - unescapedRef, err := url.PathUnescape(ref) 362 - if err != nil { 363 - notFound(w) 364 - return 365 - } 366 - 367 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 368 - 369 - // This allows the browser to use a proper name for the file when 370 - // downloading 371 - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 372 - setContentDisposition(w, filename) 373 - setGZipMIME(w) 374 - 375 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 376 - gr, err := git.Open(path, unescapedRef) 377 - if err != nil { 378 - notFound(w) 379 - return 380 - } 381 - 382 - gw := gzip.NewWriter(w) 383 - defer gw.Close() 384 - 385 - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 386 - err = gr.WriteTar(gw, prefix) 387 - if err != nil { 388 - // once we start writing to the body we can't report error anymore 389 - // so we are only left with printing the error. 390 - l.Error("writing tar file", "error", err.Error()) 391 - return 392 - } 393 - 394 - err = gw.Flush() 395 - if err != nil { 396 - // once we start writing to the body we can't report error anymore 397 - // so we are only left with printing the error. 398 - l.Error("flushing?", "error", err.Error()) 399 - return 400 - } 401 - } 402 - 403 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 404 - ref := chi.URLParam(r, "ref") 405 - ref, _ = url.PathUnescape(ref) 406 - 407 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 408 - 409 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 410 - 411 - gr, err := git.Open(path, ref) 412 - if err != nil { 413 - notFound(w) 414 - return 415 - } 416 - 417 - // Get page parameters 418 - page := 1 419 - pageSize := 30 420 - 421 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 422 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 423 - page = p 424 - } 425 - } 426 - 427 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 428 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 429 - pageSize = ps 430 - } 431 - } 432 - 433 - // convert to offset/limit 434 - offset := (page - 1) * pageSize 435 - limit := pageSize 436 - 437 - commits, err := gr.Commits(offset, limit) 438 - if err != nil { 439 - writeError(w, err.Error(), http.StatusInternalServerError) 440 - l.Error("fetching commits", "error", err.Error()) 441 - return 442 - } 443 - 444 - total := len(commits) 445 - 446 - resp := types.RepoLogResponse{ 447 - Commits: commits, 448 - Ref: ref, 449 - Description: getDescription(path), 450 - Log: true, 451 - Total: total, 452 - Page: page, 453 - PerPage: pageSize, 454 - } 455 - 456 - writeJSON(w, resp) 457 - } 458 - 459 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 460 - ref := chi.URLParam(r, "ref") 461 - ref, _ = url.PathUnescape(ref) 462 - 463 - l := h.l.With("handler", "Diff", "ref", ref) 464 - 465 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 466 - gr, err := git.Open(path, ref) 467 - if err != nil { 468 - notFound(w) 469 - return 470 - } 471 - 472 - diff, err := gr.Diff() 473 - if err != nil { 474 - writeError(w, err.Error(), http.StatusInternalServerError) 475 - l.Error("getting diff", "error", err.Error()) 476 - return 477 - } 478 - 479 - resp := types.RepoCommitResponse{ 480 - Ref: ref, 481 - Diff: diff, 482 - } 483 - 484 - writeJSON(w, resp) 485 - } 486 - 487 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 488 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 - l := h.l.With("handler", "Refs") 490 - 491 - gr, err := git.Open(path, "") 492 - if err != nil { 493 - notFound(w) 494 - return 495 - } 496 - 497 - tags, err := gr.Tags() 498 - if err != nil { 499 - // Non-fatal, we *should* have at least one branch to show. 500 - l.Warn("getting tags", "error", err.Error()) 501 - } 502 - 503 - rtags := []*types.TagReference{} 504 - for _, tag := range tags { 505 - var target *object.Tag 506 - if tag.Target != plumbing.ZeroHash { 507 - target = &tag 508 - } 509 - tr := types.TagReference{ 510 - Tag: target, 511 - } 512 - 513 - tr.Reference = types.Reference{ 514 - Name: tag.Name, 515 - Hash: tag.Hash.String(), 516 - } 517 - 518 - if tag.Message != "" { 519 - tr.Message = tag.Message 520 - } 521 - 522 - rtags = append(rtags, &tr) 523 - } 524 - 525 - resp := types.RepoTagsResponse{ 526 - Tags: rtags, 527 - } 528 - 529 - writeJSON(w, resp) 530 - } 531 - 532 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 533 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 534 - 535 - gr, err := git.PlainOpen(path) 536 - if err != nil { 537 - notFound(w) 538 - return 539 - } 540 - 541 - branches, _ := gr.Branches() 542 - 543 - resp := types.RepoBranchesResponse{ 544 - Branches: branches, 545 - } 546 - 547 - writeJSON(w, resp) 548 - } 549 - 550 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 551 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 552 - branchName := chi.URLParam(r, "branch") 553 - branchName, _ = url.PathUnescape(branchName) 554 - 555 - l := h.l.With("handler", "Branch") 556 - 557 - gr, err := git.PlainOpen(path) 558 - if err != nil { 559 - notFound(w) 560 - return 561 - } 562 - 563 - ref, err := gr.Branch(branchName) 564 - if err != nil { 565 - l.Error("getting branch", "error", err.Error()) 566 - writeError(w, err.Error(), http.StatusInternalServerError) 567 - return 568 - } 569 - 570 - commit, err := gr.Commit(ref.Hash()) 571 - if err != nil { 572 - l.Error("getting commit object", "error", err.Error()) 573 - writeError(w, err.Error(), http.StatusInternalServerError) 574 - return 575 - } 576 - 577 - defaultBranch, err := gr.FindMainBranch() 578 - isDefault := false 579 - if err != nil { 580 - l.Error("getting default branch", "error", err.Error()) 581 - // do not quit though 582 - } else if defaultBranch == branchName { 583 - isDefault = true 584 - } 585 - 586 - resp := types.RepoBranchResponse{ 587 - Branch: types.Branch{ 588 - Reference: types.Reference{ 589 - Name: ref.Name().Short(), 590 - Hash: ref.Hash().String(), 591 - }, 592 - Commit: commit, 593 - IsDefault: isDefault, 594 - }, 595 - } 596 - 597 - writeJSON(w, resp) 598 - } 599 - 600 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 601 - l := h.l.With("handler", "Keys") 602 - 603 - switch r.Method { 604 - case http.MethodGet: 605 - keys, err := h.db.GetAllPublicKeys() 606 - if err != nil { 607 - writeError(w, err.Error(), http.StatusInternalServerError) 608 - l.Error("getting public keys", "error", err.Error()) 609 - return 610 - } 611 - 612 - data := make([]map[string]any, 0) 613 - for _, key := range keys { 614 - j := key.JSON() 615 - data = append(data, j) 616 - } 617 - writeJSON(w, data) 618 - return 619 - 620 - case http.MethodPut: 621 - pk := db.PublicKey{} 622 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 623 - writeError(w, "invalid request body", http.StatusBadRequest) 624 - return 625 - } 626 - 627 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 628 - if err != nil { 629 - writeError(w, "invalid pubkey", http.StatusBadRequest) 630 - } 631 - 632 - if err := h.db.AddPublicKey(pk); err != nil { 633 - writeError(w, err.Error(), http.StatusInternalServerError) 634 - l.Error("adding public key", "error", err.Error()) 635 - return 636 - } 637 - 638 - w.WriteHeader(http.StatusNoContent) 639 - return 640 - } 641 - } 642 - 643 - // func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 644 - // l := h.l.With("handler", "RepoForkSync") 645 - // 646 - // data := struct { 647 - // Did string `json:"did"` 648 - // Source string `json:"source"` 649 - // Name string `json:"name,omitempty"` 650 - // HiddenRef string `json:"hiddenref"` 651 - // }{} 652 - // 653 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 654 - // writeError(w, "invalid request body", http.StatusBadRequest) 655 - // return 656 - // } 657 - // 658 - // did := data.Did 659 - // source := data.Source 660 - // 661 - // if did == "" || source == "" { 662 - // l.Error("invalid request body, empty did or name") 663 - // w.WriteHeader(http.StatusBadRequest) 664 - // return 665 - // } 666 - // 667 - // var name string 668 - // if data.Name != "" { 669 - // name = data.Name 670 - // } else { 671 - // name = filepath.Base(source) 672 - // } 673 - // 674 - // branch := chi.URLParam(r, "branch") 675 - // branch, _ = url.PathUnescape(branch) 676 - // 677 - // relativeRepoPath := filepath.Join(did, name) 678 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 679 - // 680 - // gr, err := git.PlainOpen(repoPath) 681 - // if err != nil { 682 - // log.Println(err) 683 - // notFound(w) 684 - // return 685 - // } 686 - // 687 - // forkCommit, err := gr.ResolveRevision(branch) 688 - // if err != nil { 689 - // l.Error("error resolving ref revision", "msg", err.Error()) 690 - // writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 691 - // return 692 - // } 693 - // 694 - // sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 695 - // if err != nil { 696 - // l.Error("error resolving hidden ref revision", "msg", err.Error()) 697 - // writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 698 - // return 699 - // } 700 - // 701 - // status := types.UpToDate 702 - // if forkCommit.Hash.String() != sourceCommit.Hash.String() { 703 - // isAncestor, err := forkCommit.IsAncestor(sourceCommit) 704 - // if err != nil { 705 - // log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 706 - // return 707 - // } 708 - // 709 - // if isAncestor { 710 - // status = types.FastForwardable 711 - // } else { 712 - // status = types.Conflict 713 - // } 714 - // } 715 - // 716 - // w.Header().Set("Content-Type", "application/json") 717 - // json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 718 - // } 719 - 720 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 721 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 722 - ref := chi.URLParam(r, "ref") 723 - ref, _ = url.PathUnescape(ref) 724 - 725 - l := h.l.With("handler", "RepoLanguages") 726 - 727 - gr, err := git.Open(repoPath, ref) 728 - if err != nil { 729 - l.Error("opening repo", "error", err.Error()) 730 - notFound(w) 731 - return 732 - } 733 - 734 - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 735 - defer cancel() 736 - 737 - sizes, err := gr.AnalyzeLanguages(ctx) 738 - if err != nil { 739 - l.Error("failed to analyze languages", "error", err.Error()) 740 - writeError(w, err.Error(), http.StatusNoContent) 741 - return 742 - } 743 - 744 - resp := types.RepoLanguageResponse{Languages: sizes} 745 - 746 - writeJSON(w, resp) 747 - } 748 - 749 - // func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 750 - // l := h.l.With("handler", "RepoForkSync") 751 - // 752 - // data := struct { 753 - // Did string `json:"did"` 754 - // Source string `json:"source"` 755 - // Name string `json:"name,omitempty"` 756 - // }{} 757 - // 758 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 759 - // writeError(w, "invalid request body", http.StatusBadRequest) 760 - // return 761 - // } 762 - // 763 - // did := data.Did 764 - // source := data.Source 765 - // 766 - // if did == "" || source == "" { 767 - // l.Error("invalid request body, empty did or name") 768 - // w.WriteHeader(http.StatusBadRequest) 769 - // return 770 - // } 771 - // 772 - // var name string 773 - // if data.Name != "" { 774 - // name = data.Name 775 - // } else { 776 - // name = filepath.Base(source) 777 - // } 778 - // 779 - // branch := chi.URLParam(r, "branch") 780 - // branch, _ = url.PathUnescape(branch) 781 - // 782 - // relativeRepoPath := filepath.Join(did, name) 783 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 784 - // 785 - // gr, err := git.Open(repoPath, branch) 786 - // if err != nil { 787 - // log.Println(err) 788 - // notFound(w) 789 - // return 790 - // } 791 - // 792 - // err = gr.Sync() 793 - // if err != nil { 794 - // l.Error("error syncing repo fork", "error", err.Error()) 795 - // writeError(w, err.Error(), http.StatusInternalServerError) 796 - // return 797 - // } 798 - // 799 - // w.WriteHeader(http.StatusNoContent) 800 - // } 801 - 802 - // func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 803 - // l := h.l.With("handler", "RepoFork") 804 - // 805 - // data := struct { 806 - // Did string `json:"did"` 807 - // Source string `json:"source"` 808 - // Name string `json:"name,omitempty"` 809 - // }{} 810 - // 811 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 812 - // writeError(w, "invalid request body", http.StatusBadRequest) 813 - // return 814 - // } 815 - // 816 - // did := data.Did 817 - // source := data.Source 818 - // 819 - // if did == "" || source == "" { 820 - // l.Error("invalid request body, empty did or name") 821 - // w.WriteHeader(http.StatusBadRequest) 822 - // return 823 - // } 824 - // 825 - // var name string 826 - // if data.Name != "" { 827 - // name = data.Name 828 - // } else { 829 - // name = filepath.Base(source) 830 - // } 831 - // 832 - // relativeRepoPath := filepath.Join(did, name) 833 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 834 - // 835 - // err := git.Fork(repoPath, source) 836 - // if err != nil { 837 - // l.Error("forking repo", "error", err.Error()) 838 - // writeError(w, err.Error(), http.StatusInternalServerError) 839 - // return 840 - // } 841 - // 842 - // // add perms for this user to access the repo 843 - // err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) 844 - // if err != nil { 845 - // l.Error("adding repo permissions", "error", err.Error()) 846 - // writeError(w, err.Error(), http.StatusInternalServerError) 847 - // return 848 - // } 849 - // 850 - // hook.SetupRepo( 851 - // hook.Config( 852 - // hook.WithScanPath(h.c.Repo.ScanPath), 853 - // hook.WithInternalApi(h.c.Server.InternalListenAddr), 854 - // ), 855 - // repoPath, 856 - // ) 857 - // 858 - // w.WriteHeader(http.StatusNoContent) 859 - // } 860 - 861 - // func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 862 - // l := h.l.With("handler", "RemoveRepo") 863 - // 864 - // data := struct { 865 - // Did string `json:"did"` 866 - // Name string `json:"name"` 867 - // }{} 868 - // 869 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 870 - // writeError(w, "invalid request body", http.StatusBadRequest) 871 - // return 872 - // } 873 - // 874 - // did := data.Did 875 - // name := data.Name 876 - // 877 - // if did == "" || name == "" { 878 - // l.Error("invalid request body, empty did or name") 879 - // w.WriteHeader(http.StatusBadRequest) 880 - // return 881 - // } 882 - // 883 - // relativeRepoPath := filepath.Join(did, name) 884 - // repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 885 - // err := os.RemoveAll(repoPath) 886 - // if err != nil { 887 - // l.Error("removing repo", "error", err.Error()) 888 - // writeError(w, err.Error(), http.StatusInternalServerError) 889 - // return 890 - // } 891 - // 892 - // w.WriteHeader(http.StatusNoContent) 893 - // 894 - // } 895 - 896 - // func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 897 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 898 - // 899 - // data := types.MergeRequest{} 900 - // 901 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 902 - // writeError(w, err.Error(), http.StatusBadRequest) 903 - // h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 904 - // return 905 - // } 906 - // 907 - // mo := &git.MergeOptions{ 908 - // AuthorName: data.AuthorName, 909 - // AuthorEmail: data.AuthorEmail, 910 - // CommitBody: data.CommitBody, 911 - // CommitMessage: data.CommitMessage, 912 - // } 913 - // 914 - // patch := data.Patch 915 - // branch := data.Branch 916 - // gr, err := git.Open(path, branch) 917 - // if err != nil { 918 - // notFound(w) 919 - // return 920 - // } 921 - // 922 - // mo.FormatPatch = patchutil.IsFormatPatch(patch) 923 - // 924 - // if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 925 - // var mergeErr *git.ErrMerge 926 - // if errors.As(err, &mergeErr) { 927 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 928 - // for i, conflict := range mergeErr.Conflicts { 929 - // conflicts[i] = types.ConflictInfo{ 930 - // Filename: conflict.Filename, 931 - // Reason: conflict.Reason, 932 - // } 933 - // } 934 - // response := types.MergeCheckResponse{ 935 - // IsConflicted: true, 936 - // Conflicts: conflicts, 937 - // Message: mergeErr.Message, 938 - // } 939 - // writeConflict(w, response) 940 - // h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 941 - // } else { 942 - // writeError(w, err.Error(), http.StatusBadRequest) 943 - // h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 944 - // } 945 - // return 946 - // } 947 - // 948 - // w.WriteHeader(http.StatusOK) 949 - // } 950 - 951 - // func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 952 - // path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 953 - // 954 - // var data struct { 955 - // Patch string `json:"patch"` 956 - // Branch string `json:"branch"` 957 - // } 958 - // 959 - // if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 960 - // writeError(w, err.Error(), http.StatusBadRequest) 961 - // h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 962 - // return 963 - // } 964 - // 965 - // patch := data.Patch 966 - // branch := data.Branch 967 - // gr, err := git.Open(path, branch) 968 - // if err != nil { 969 - // notFound(w) 970 - // return 971 - // } 972 - // 973 - // err = gr.MergeCheck([]byte(patch), branch) 974 - // if err == nil { 975 - // response := types.MergeCheckResponse{ 976 - // IsConflicted: false, 977 - // } 978 - // writeJSON(w, response) 979 - // return 980 - // } 981 - // 982 - // var mergeErr *git.ErrMerge 983 - // if errors.As(err, &mergeErr) { 984 - // conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 985 - // for i, conflict := range mergeErr.Conflicts { 986 - // conflicts[i] = types.ConflictInfo{ 987 - // Filename: conflict.Filename, 988 - // Reason: conflict.Reason, 989 - // } 990 - // } 991 - // response := types.MergeCheckResponse{ 992 - // IsConflicted: true, 993 - // Conflicts: conflicts, 994 - // Message: mergeErr.Message, 995 - // } 996 - // writeConflict(w, response) 997 - // h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 998 - // return 999 - // } 1000 - // writeError(w, err.Error(), http.StatusInternalServerError) 1001 - // h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1002 - // } 1003 - 1004 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1005 - rev1 := chi.URLParam(r, "rev1") 1006 - rev1, _ = url.PathUnescape(rev1) 1007 - 1008 - rev2 := chi.URLParam(r, "rev2") 1009 - rev2, _ = url.PathUnescape(rev2) 1010 - 1011 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1012 - 1013 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1014 - gr, err := git.PlainOpen(path) 1015 - if err != nil { 1016 - notFound(w) 1017 - return 1018 - } 1019 - 1020 - commit1, err := gr.ResolveRevision(rev1) 1021 - if err != nil { 1022 - l.Error("error resolving revision 1", "msg", err.Error()) 1023 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1024 - return 1025 - } 1026 - 1027 - commit2, err := gr.ResolveRevision(rev2) 1028 - if err != nil { 1029 - l.Error("error resolving revision 2", "msg", err.Error()) 1030 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1031 - return 1032 - } 1033 - 1034 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1035 - if err != nil { 1036 - l.Error("error comparing revisions", "msg", err.Error()) 1037 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1038 - return 1039 - } 1040 - 1041 - writeJSON(w, types.RepoFormatPatchResponse{ 1042 - Rev1: commit1.Hash.String(), 1043 - Rev2: commit2.Hash.String(), 1044 - FormatPatch: formatPatch, 1045 - Patch: rawPatch, 1046 - }) 1047 - } 1048 - 1049 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1050 - l := h.l.With("handler", "DefaultBranch") 1051 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1052 - 1053 - gr, err := git.Open(path, "") 1054 - if err != nil { 1055 - notFound(w) 1056 - return 1057 - } 1058 - 1059 - branch, err := gr.FindMainBranch() 1060 - if err != nil { 1061 - writeError(w, err.Error(), http.StatusInternalServerError) 1062 - l.Error("getting default branch", "error", err.Error()) 1063 - return 1064 - } 1065 - 1066 - writeJSON(w, types.RepoDefaultBranchResponse{ 1067 - Branch: branch, 1068 - }) 1069 - }
+11 -6
knotserver/ingester.go
··· 24 24 "tangled.sh/tangled.sh/core/workflow" 25 25 ) 26 26 27 - func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 28 l := log.FromContext(ctx) 29 29 raw := json.RawMessage(event.Commit.Record) 30 30 did := event.Did ··· 46 46 return nil 47 47 } 48 48 49 - func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 50 l := log.FromContext(ctx) 51 51 raw := json.RawMessage(event.Commit.Record) 52 52 did := event.Did ··· 86 86 return nil 87 87 } 88 88 89 - func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 90 raw := json.RawMessage(event.Commit.Record) 91 91 did := event.Did 92 92 ··· 98 98 l := log.FromContext(ctx) 99 99 l = l.With("handler", "processPull") 100 100 l = l.With("did", did) 101 + 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 104 + } 105 + 101 106 l = l.With("target_repo", record.Target.Repo) 102 107 l = l.With("target_branch", record.Target.Branch) 103 108 ··· 214 219 } 215 220 216 221 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 218 223 raw := json.RawMessage(event.Commit.Record) 219 224 did := event.Did 220 225 ··· 275 280 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 276 281 } 277 282 278 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 279 284 l := log.FromContext(ctx) 280 285 281 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 318 323 return nil 319 324 } 320 325 321 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 322 327 if event.Kind != models.EventKindCommit { 323 328 return nil 324 329 }
+152
knotserver/router.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.sh/tangled.sh/core/idresolver" 11 + "tangled.sh/tangled.sh/core/jetstream" 12 + "tangled.sh/tangled.sh/core/knotserver/config" 13 + "tangled.sh/tangled.sh/core/knotserver/db" 14 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 15 + tlog "tangled.sh/tangled.sh/core/log" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + ) 20 + 21 + type Knot struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 + } 30 + 31 + 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) { 32 + r := chi.NewRouter() 33 + 34 + h := Knot{ 35 + c: c, 36 + db: db, 37 + e: e, 38 + l: l, 39 + jc: jc, 40 + n: n, 41 + resolver: idresolver.DefaultResolver(), 42 + } 43 + 44 + err := e.AddKnot(rbac.ThisServer) 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 47 + } 48 + 49 + // configure owner 50 + if err = h.configureOwner(); err != nil { 51 + return nil, err 52 + } 53 + h.l.Info("owner set", "did", h.c.Server.Owner) 54 + h.jc.AddDid(h.c.Server.Owner) 55 + 56 + // configure known-dids in jetstream consumer 57 + dids, err := h.db.GetAllDids() 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get all dids: %w", err) 60 + } 61 + for _, d := range dids { 62 + jc.AddDid(d) 63 + } 64 + 65 + err = h.jc.StartJetstream(ctx, h.processMessages) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 + } 69 + 70 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 + }) 73 + 74 + r.Route("/{did}", func(r chi.Router) { 75 + r.Route("/{name}", func(r chi.Router) { 76 + // routes for git operations 77 + r.Get("/info/refs", h.InfoRefs) 78 + r.Post("/git-upload-pack", h.UploadPack) 79 + r.Post("/git-receive-pack", h.ReceivePack) 80 + }) 81 + }) 82 + 83 + // xrpc apis 84 + r.Mount("/xrpc", h.XrpcRouter()) 85 + 86 + // Socket that streams git oplogs 87 + r.Get("/events", h.Events) 88 + 89 + return r, nil 90 + } 91 + 92 + func (h *Knot) XrpcRouter() http.Handler { 93 + logger := tlog.New("knots") 94 + 95 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 + 97 + xrpc := &xrpc.Xrpc{ 98 + Config: h.c, 99 + Db: h.db, 100 + Ingester: h.jc, 101 + Enforcer: h.e, 102 + Logger: logger, 103 + Notifier: h.n, 104 + Resolver: h.resolver, 105 + ServiceAuth: serviceAuth, 106 + } 107 + return xrpc.Router() 108 + } 109 + 110 + func (h *Knot) configureOwner() error { 111 + cfgOwner := h.c.Server.Owner 112 + 113 + rbacDomain := "thisserver" 114 + 115 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 116 + if err != nil { 117 + return err 118 + } 119 + 120 + switch len(existing) { 121 + case 0: 122 + // no owner configured, continue 123 + case 1: 124 + // find existing owner 125 + existingOwner := existing[0] 126 + 127 + // no ownership change, this is okay 128 + if existingOwner == h.c.Server.Owner { 129 + break 130 + } 131 + 132 + // remove existing owner 133 + if err = h.db.RemoveDid(existingOwner); err != nil { 134 + return err 135 + } 136 + if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 137 + return err 138 + } 139 + 140 + default: 141 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 142 + } 143 + 144 + if err = h.db.AddDid(cfgOwner); err != nil { 145 + return fmt.Errorf("failed to add owner to DB: %w", err) 146 + } 147 + if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 148 + return fmt.Errorf("failed to add owner to RBAC: %w", err) 149 + } 150 + 151 + return nil 152 + }
-217
knotserver/routes.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - "net/http" 8 - "runtime/debug" 9 - 10 - "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 16 - tlog "tangled.sh/tangled.sh/core/log" 17 - "tangled.sh/tangled.sh/core/notifier" 18 - "tangled.sh/tangled.sh/core/rbac" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 - ) 21 - 22 - type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 - resolver *idresolver.Resolver 30 - } 31 - 32 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 33 - r := chi.NewRouter() 34 - 35 - h := Handle{ 36 - c: c, 37 - db: db, 38 - e: e, 39 - l: l, 40 - jc: jc, 41 - n: n, 42 - resolver: idresolver.DefaultResolver(), 43 - } 44 - 45 - err := e.AddKnot(rbac.ThisServer) 46 - if err != nil { 47 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 48 - } 49 - 50 - // configure owner 51 - if err = h.configureOwner(); err != nil { 52 - return nil, err 53 - } 54 - h.l.Info("owner set", "did", h.c.Server.Owner) 55 - h.jc.AddDid(h.c.Server.Owner) 56 - 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) 64 - } 65 - 66 - err = h.jc.StartJetstream(ctx, h.processMessages) 67 - if err != nil { 68 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 69 - } 70 - 71 - r.Get("/", h.Index) 72 - r.Get("/capabilities", h.Capabilities) 73 - r.Get("/version", h.Version) 74 - r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { 75 - w.Write([]byte(h.c.Server.Owner)) 76 - }) 77 - r.Route("/{did}", func(r chi.Router) { 78 - // Repo routes 79 - r.Route("/{name}", func(r chi.Router) { 80 - 81 - r.Route("/languages", func(r chi.Router) { 82 - r.Get("/", h.RepoLanguages) 83 - r.Get("/{ref}", h.RepoLanguages) 84 - }) 85 - 86 - r.Get("/", h.RepoIndex) 87 - r.Get("/info/refs", h.InfoRefs) 88 - r.Post("/git-upload-pack", h.UploadPack) 89 - r.Post("/git-receive-pack", h.ReceivePack) 90 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 91 - 92 - r.Route("/tree/{ref}", func(r chi.Router) { 93 - r.Get("/", h.RepoIndex) 94 - r.Get("/*", h.RepoTree) 95 - }) 96 - 97 - r.Route("/blob/{ref}", func(r chi.Router) { 98 - r.Get("/*", h.Blob) 99 - }) 100 - 101 - r.Route("/raw/{ref}", func(r chi.Router) { 102 - r.Get("/*", h.BlobRaw) 103 - }) 104 - 105 - r.Get("/log/{ref}", h.Log) 106 - r.Get("/archive/{file}", h.Archive) 107 - r.Get("/commit/{ref}", h.Diff) 108 - r.Get("/tags", h.Tags) 109 - r.Route("/branches", func(r chi.Router) { 110 - r.Get("/", h.Branches) 111 - r.Get("/{branch}", h.Branch) 112 - r.Get("/default", h.DefaultBranch) 113 - }) 114 - }) 115 - }) 116 - 117 - // xrpc apis 118 - r.Mount("/xrpc", h.XrpcRouter()) 119 - 120 - // Socket that streams git oplogs 121 - r.Get("/events", h.Events) 122 - 123 - // All public keys on the knot. 124 - r.Get("/keys", h.Keys) 125 - 126 - return r, nil 127 - } 128 - 129 - func (h *Handle) XrpcRouter() http.Handler { 130 - logger := tlog.New("knots") 131 - 132 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 133 - 134 - xrpc := &xrpc.Xrpc{ 135 - Config: h.c, 136 - Db: h.db, 137 - Ingester: h.jc, 138 - Enforcer: h.e, 139 - Logger: logger, 140 - Notifier: h.n, 141 - Resolver: h.resolver, 142 - ServiceAuth: serviceAuth, 143 - } 144 - return xrpc.Router() 145 - } 146 - 147 - // version is set during build time. 148 - var version string 149 - 150 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 151 - if version == "" { 152 - info, ok := debug.ReadBuildInfo() 153 - if !ok { 154 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 155 - return 156 - } 157 - 158 - var modVer string 159 - for _, mod := range info.Deps { 160 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 161 - version = mod.Version 162 - break 163 - } 164 - } 165 - 166 - if modVer == "" { 167 - version = "unknown" 168 - } 169 - } 170 - 171 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 172 - fmt.Fprintf(w, "knotserver/%s", version) 173 - } 174 - 175 - func (h *Handle) configureOwner() error { 176 - cfgOwner := h.c.Server.Owner 177 - 178 - rbacDomain := "thisserver" 179 - 180 - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 181 - if err != nil { 182 - return err 183 - } 184 - 185 - switch len(existing) { 186 - case 0: 187 - // no owner configured, continue 188 - case 1: 189 - // find existing owner 190 - existingOwner := existing[0] 191 - 192 - // no ownership change, this is okay 193 - if existingOwner == h.c.Server.Owner { 194 - break 195 - } 196 - 197 - // remove existing owner 198 - if err = h.db.RemoveDid(existingOwner); err != nil { 199 - return err 200 - } 201 - if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 202 - return err 203 - } 204 - 205 - default: 206 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 207 - } 208 - 209 - if err = h.db.AddDid(cfgOwner); err != nil { 210 - return fmt.Errorf("failed to add owner to DB: %w", err) 211 - } 212 - if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 213 - return fmt.Errorf("failed to add owner to RBAC: %w", err) 214 - } 215 - 216 - return nil 217 - }
+16 -13
knotserver/server.go
··· 22 22 Usage: "run a knot server", 23 23 Action: Run, 24 24 Description: ` 25 - Environment variables: 26 - KNOT_SERVER_SECRET (required) 27 - KNOT_SERVER_HOSTNAME (required) 28 - KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 29 - KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 30 - KNOT_SERVER_DB_PATH (default: knotserver.db) 31 - KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 32 - KNOT_SERVER_DEV (default: false) 33 - KNOT_REPO_SCAN_PATH (default: /home/git) 34 - KNOT_REPO_README (comma-separated list) 35 - KNOT_REPO_MAIN_BRANCH (default: main) 36 - APPVIEW_ENDPOINT (default: https://tangled.sh) 37 - `, 25 + Environment variables: 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_HOSTNAME (required) 30 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 31 + KNOT_SERVER_OWNER (required) 32 + KNOT_SERVER_LOG_DIDS (default: true) 33 + KNOT_SERVER_DEV (default: false) 34 + KNOT_REPO_SCAN_PATH (default: /home/git) 35 + KNOT_REPO_README (comma-separated list) 36 + KNOT_REPO_MAIN_BRANCH (default: main) 37 + KNOT_GIT_USER_NAME (default: Tangled) 38 + KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh) 39 + APPVIEW_ENDPOINT (default: https://tangled.sh) 40 + `, 38 41 } 39 42 } 40 43
+58
knotserver/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 13 + cursor := r.URL.Query().Get("cursor") 14 + 15 + limit := 100 // default 16 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 17 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 18 + limit = l 19 + } 20 + } 21 + 22 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 23 + if err != nil { 24 + x.Logger.Error("failed to get public keys", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to retrieve public keys"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 33 + for _, key := range keys { 34 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 35 + Did: key.Did, 36 + Key: key.Key, 37 + CreatedAt: key.CreatedAt, 38 + }) 39 + } 40 + 41 + response := tangled.KnotListKeys_Output{ 42 + Keys: publicKeys, 43 + } 44 + 45 + if nextCursor != "" { 46 + response.Cursor = &nextCursor 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + if err := json.NewEncoder(w).Encode(response); err != nil { 51 + x.Logger.Error("failed to encode response", "error", err) 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("InternalServerError"), 54 + xrpcerr.WithMessage("failed to encode response"), 55 + ), http.StatusInternalServerError) 56 + return 57 + } 58 + }
+3 -1
knotserver/xrpc/merge.go
··· 67 67 return 68 68 } 69 69 70 - mo := &git.MergeOptions{} 70 + mo := git.MergeOptions{} 71 71 if data.AuthorName != nil { 72 72 mo.AuthorName = *data.AuthorName 73 73 } ··· 81 81 mo.CommitMessage = *data.CommitMessage 82 82 } 83 83 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 84 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 87 86 88 err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+31
knotserver/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+80
knotserver/xrpc/repo_archive.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo, repoPath, unescapedRef, err := x.parseStandardParams(r) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + format := r.URL.Query().Get("format") 23 + if format == "" { 24 + format = "tar.gz" // default 25 + } 26 + 27 + prefix := r.URL.Query().Get("prefix") 28 + 29 + if format != "tar.gz" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("only tar.gz format is supported"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + gr, err := git.Open(repoPath, unescapedRef) 38 + if err != nil { 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("RefNotFound"), 41 + xrpcerr.WithMessage("repository or ref not found"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + repoParts := strings.Split(repo, "/") 47 + repoName := repoParts[len(repoParts)-1] 48 + 49 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 50 + 51 + var archivePrefix string 52 + if prefix != "" { 53 + archivePrefix = prefix 54 + } else { 55 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 56 + } 57 + 58 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 59 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 60 + w.Header().Set("Content-Type", "application/gzip") 61 + 62 + gw := gzip.NewWriter(w) 63 + defer gw.Close() 64 + 65 + err = gr.WriteTar(gw, archivePrefix) 66 + if err != nil { 67 + // once we start writing to the body we can't report error anymore 68 + // so we are only left with logging the error 69 + x.Logger.Error("writing tar file", "error", err.Error()) 70 + return 71 + } 72 + 73 + err = gw.Flush() 74 + if err != nil { 75 + // once we start writing to the body we can't report error anymore 76 + // so we are only left with logging the error 77 + x.Logger.Error("flushing", "error", err.Error()) 78 + return 79 + } 80 + }
+151
knotserver/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/knotserver/git" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 19 + _, repoPath, ref, err := x.parseStandardParams(r) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + treePath := r.URL.Query().Get("path") 26 + if treePath == "" { 27 + writeError(w, xrpcerr.NewXrpcError( 28 + xrpcerr.WithTag("InvalidRequest"), 29 + xrpcerr.WithMessage("missing path parameter"), 30 + ), http.StatusBadRequest) 31 + return 32 + } 33 + 34 + raw := r.URL.Query().Get("raw") == "true" 35 + 36 + gr, err := git.Open(repoPath, ref) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("RefNotFound"), 40 + xrpcerr.WithMessage("repository or ref not found"), 41 + ), http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + w.Header().Set("Content-Type", mimeType) 73 + 74 + case strings.HasPrefix(mimeType, "text/"): 75 + w.Header().Set("Cache-Control", "public, no-cache") 76 + // serve all text content as text/plain 77 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 78 + 79 + case isTextualMimeType(mimeType): 80 + // handle textual application types (json, xml, etc.) as text/plain 81 + w.Header().Set("Cache-Control", "public, no-cache") 82 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 83 + 84 + default: 85 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("InvalidRequest"), 88 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 89 + ), http.StatusForbidden) 90 + return 91 + } 92 + w.Write(contents) 93 + return 94 + } 95 + 96 + isTextual := func(mt string) bool { 97 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 98 + } 99 + 100 + var content string 101 + var encoding string 102 + 103 + isBinary := !isTextual(mimeType) 104 + 105 + if isBinary { 106 + content = base64.StdEncoding.EncodeToString(contents) 107 + encoding = "base64" 108 + } else { 109 + content = string(contents) 110 + encoding = "utf-8" 111 + } 112 + 113 + response := tangled.RepoBlob_Output{ 114 + Ref: ref, 115 + Path: treePath, 116 + Content: content, 117 + Encoding: &encoding, 118 + Size: &[]int64{int64(len(contents))}[0], 119 + IsBinary: &isBinary, 120 + } 121 + 122 + if mimeType != "" { 123 + response.MimeType = &mimeType 124 + } 125 + 126 + w.Header().Set("Content-Type", "application/json") 127 + if err := json.NewEncoder(w).Encode(response); err != nil { 128 + x.Logger.Error("failed to encode response", "error", err) 129 + writeError(w, xrpcerr.NewXrpcError( 130 + xrpcerr.WithTag("InternalServerError"), 131 + xrpcerr.WithMessage("failed to encode response"), 132 + ), http.StatusInternalServerError) 133 + return 134 + } 135 + } 136 + 137 + // isTextualMimeType returns true if the MIME type represents textual content 138 + // that should be served as text/plain for security reasons 139 + func isTextualMimeType(mimeType string) bool { 140 + textualTypes := []string{ 141 + "application/json", 142 + "application/xml", 143 + "application/yaml", 144 + "application/x-yaml", 145 + "application/toml", 146 + "application/javascript", 147 + "application/ecmascript", 148 + } 149 + 150 + return slices.Contains(textualTypes, mimeType) 151 + }
+96
knotserver/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RepoNotFound"), 36 + xrpcerr.WithMessage("repository not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + ref, err := gr.Branch(branchName) 42 + if err != nil { 43 + x.Logger.Error("getting branch", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("BranchNotFound"), 46 + xrpcerr.WithMessage("branch not found"), 47 + ), http.StatusNotFound) 48 + return 49 + } 50 + 51 + commit, err := gr.Commit(ref.Hash()) 52 + if err != nil { 53 + x.Logger.Error("getting commit object", "error", err.Error()) 54 + writeError(w, xrpcerr.NewXrpcError( 55 + xrpcerr.WithTag("BranchNotFound"), 56 + xrpcerr.WithMessage("failed to get commit object"), 57 + ), http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + defaultBranch, err := gr.FindMainBranch() 62 + isDefault := false 63 + if err != nil { 64 + x.Logger.Error("getting default branch", "error", err.Error()) 65 + } else if defaultBranch == branchName { 66 + isDefault = true 67 + } 68 + 69 + response := tangled.RepoBranch_Output{ 70 + Name: ref.Name().Short(), 71 + Hash: ref.Hash().String(), 72 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 73 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 74 + IsDefault: &isDefault, 75 + } 76 + 77 + if commit.Message != "" { 78 + response.Message = &commit.Message 79 + } 80 + 81 + response.Author = &tangled.RepoBranch_Signature{ 82 + Name: commit.Author.Name, 83 + Email: commit.Author.Email, 84 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + if err := json.NewEncoder(w).Encode(response); err != nil { 89 + x.Logger.Error("failed to encode response", "error", err) 90 + writeError(w, xrpcerr.NewXrpcError( 91 + xrpcerr.WithTag("InternalServerError"), 92 + xrpcerr.WithMessage("failed to encode response"), 93 + ), http.StatusInternalServerError) 94 + return 95 + } 96 + }
+72
knotserver/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + cursor := r.URL.Query().Get("cursor") 22 + 23 + // limit := 50 // default 24 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 25 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 26 + // limit = l 27 + // } 28 + // } 29 + 30 + limit := 500 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RepoNotFound"), 36 + xrpcerr.WithMessage("repository not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + branches, _ := gr.Branches() 42 + 43 + offset := 0 44 + if cursor != "" { 45 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 46 + offset = o 47 + } 48 + } 49 + 50 + end := offset + limit 51 + if end > len(branches) { 52 + end = len(branches) 53 + } 54 + 55 + paginatedBranches := branches[offset:end] 56 + 57 + // Create response using existing types.RepoBranchesResponse 58 + response := types.RepoBranchesResponse{ 59 + Branches: paginatedBranches, 60 + } 61 + 62 + // Write JSON response directly 63 + w.Header().Set("Content-Type", "application/json") 64 + if err := json.NewEncoder(w).Encode(response); err != nil { 65 + x.Logger.Error("failed to encode response", "error", err) 66 + writeError(w, xrpcerr.NewXrpcError( 67 + xrpcerr.WithTag("InternalServerError"), 68 + xrpcerr.WithMessage("failed to encode response"), 69 + ), http.StatusInternalServerError) 70 + return 71 + } 72 + }
+98
knotserver/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + rev1Param := r.URL.Query().Get("rev1") 23 + if rev1Param == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing rev1 parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + rev2Param := r.URL.Query().Get("rev2") 32 + if rev2Param == "" { 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("InvalidRequest"), 35 + xrpcerr.WithMessage("missing rev2 parameter"), 36 + ), http.StatusBadRequest) 37 + return 38 + } 39 + 40 + rev1, _ := url.PathUnescape(rev1Param) 41 + rev2, _ := url.PathUnescape(rev2Param) 42 + 43 + gr, err := git.PlainOpen(repoPath) 44 + if err != nil { 45 + writeError(w, xrpcerr.NewXrpcError( 46 + xrpcerr.WithTag("RepoNotFound"), 47 + xrpcerr.WithMessage("repository not found"), 48 + ), http.StatusNotFound) 49 + return 50 + } 51 + 52 + commit1, err := gr.ResolveRevision(rev1) 53 + if err != nil { 54 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 55 + writeError(w, xrpcerr.NewXrpcError( 56 + xrpcerr.WithTag("RevisionNotFound"), 57 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 58 + ), http.StatusBadRequest) 59 + return 60 + } 61 + 62 + commit2, err := gr.ResolveRevision(rev2) 63 + if err != nil { 64 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 65 + writeError(w, xrpcerr.NewXrpcError( 66 + xrpcerr.WithTag("RevisionNotFound"), 67 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 68 + ), http.StatusBadRequest) 69 + return 70 + } 71 + 72 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 73 + if err != nil { 74 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 75 + writeError(w, xrpcerr.NewXrpcError( 76 + xrpcerr.WithTag("CompareError"), 77 + xrpcerr.WithMessage("error comparing revisions"), 78 + ), http.StatusBadRequest) 79 + return 80 + } 81 + 82 + resp := types.RepoFormatPatchResponse{ 83 + Rev1: commit1.Hash.String(), 84 + Rev2: commit2.Hash.String(), 85 + FormatPatch: formatPatch, 86 + Patch: rawPatch, 87 + } 88 + 89 + w.Header().Set("Content-Type", "application/json") 90 + if err := json.NewEncoder(w).Encode(resp); err != nil { 91 + x.Logger.Error("failed to encode response", "error", err) 92 + writeError(w, xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InternalServerError"), 94 + xrpcerr.WithMessage("failed to encode response"), 95 + ), http.StatusInternalServerError) 96 + return 97 + } 98 + }
+65
knotserver/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + refParam := r.URL.Query().Get("ref") 22 + if refParam == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing ref parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + ref, _ := url.QueryUnescape(refParam) 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RefNotFound"), 36 + xrpcerr.WithMessage("repository or ref not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + diff, err := gr.Diff() 42 + if err != nil { 43 + x.Logger.Error("getting diff", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("RefNotFound"), 46 + xrpcerr.WithMessage("failed to generate diff"), 47 + ), http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + resp := types.RepoCommitResponse{ 52 + Ref: ref, 53 + Diff: diff, 54 + } 55 + 56 + w.Header().Set("Content-Type", "application/json") 57 + if err := json.NewEncoder(w).Encode(resp); err != nil { 58 + x.Logger.Error("failed to encode response", "error", err) 59 + writeError(w, xrpcerr.NewXrpcError( 60 + xrpcerr.WithTag("InternalServerError"), 61 + xrpcerr.WithMessage("failed to encode response"), 62 + ), http.StatusInternalServerError) 63 + return 64 + } 65 + }
+54
knotserver/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.Open(repoPath, "") 21 + if err != nil { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("RepoNotFound"), 24 + xrpcerr.WithMessage("repository not found"), 25 + ), http.StatusNotFound) 26 + return 27 + } 28 + 29 + branch, err := gr.FindMainBranch() 30 + if err != nil { 31 + x.Logger.Error("getting default branch", "error", err.Error()) 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("failed to get default branch"), 35 + ), http.StatusInternalServerError) 36 + return 37 + } 38 + 39 + response := tangled.RepoGetDefaultBranch_Output{ 40 + Name: branch, 41 + Hash: "", 42 + When: "1970-01-01T00:00:00.000Z", 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + if err := json.NewEncoder(w).Encode(response); err != nil { 47 + x.Logger.Error("failed to encode response", "error", err) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("InternalServerError"), 50 + xrpcerr.WithMessage("failed to encode response"), 51 + ), http.StatusInternalServerError) 52 + return 53 + } 54 + }
+93
knotserver/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "math" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 17 + refParam := r.URL.Query().Get("ref") 18 + if refParam == "" { 19 + refParam = "HEAD" // default 20 + } 21 + ref, _ := url.PathUnescape(refParam) 22 + 23 + repo := r.URL.Query().Get("repo") 24 + repoPath, err := x.parseRepoParam(repo) 25 + if err != nil { 26 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + gr, err := git.Open(repoPath, ref) 31 + if err != nil { 32 + x.Logger.Error("opening repo", "error", err.Error()) 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("RefNotFound"), 35 + xrpcerr.WithMessage("repository or ref not found"), 36 + ), http.StatusNotFound) 37 + return 38 + } 39 + 40 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 41 + defer cancel() 42 + 43 + sizes, err := gr.AnalyzeLanguages(ctx) 44 + if err != nil { 45 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 46 + writeError(w, xrpcerr.NewXrpcError( 47 + xrpcerr.WithTag("InvalidRequest"), 48 + xrpcerr.WithMessage("failed to analyze repository languages"), 49 + ), http.StatusNoContent) 50 + return 51 + } 52 + 53 + var apiLanguages []*tangled.RepoLanguages_Language 54 + var totalSize int64 55 + 56 + for _, size := range sizes { 57 + totalSize += size 58 + } 59 + 60 + for name, size := range sizes { 61 + percentagef64 := float64(size) / float64(totalSize) * 100 62 + percentage := math.Round(percentagef64) 63 + 64 + lang := &tangled.RepoLanguages_Language{ 65 + Name: name, 66 + Size: size, 67 + Percentage: int64(percentage), 68 + } 69 + 70 + apiLanguages = append(apiLanguages, lang) 71 + } 72 + 73 + response := tangled.RepoLanguages_Output{ 74 + Ref: ref, 75 + Languages: apiLanguages, 76 + } 77 + 78 + if totalSize > 0 { 79 + response.TotalSize = &totalSize 80 + totalFiles := int64(len(sizes)) 81 + response.TotalFiles = &totalFiles 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + if err := json.NewEncoder(w).Encode(response); err != nil { 86 + x.Logger.Error("failed to encode response", "error", err) 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("InternalServerError"), 89 + xrpcerr.WithMessage("failed to encode response"), 90 + ), http.StatusInternalServerError) 91 + return 92 + } 93 + }
+111
knotserver/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "strconv" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + refParam := r.URL.Query().Get("ref") 23 + if refParam == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing ref parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + path := r.URL.Query().Get("path") 32 + cursor := r.URL.Query().Get("cursor") 33 + 34 + limit := 50 // default 35 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 36 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 37 + limit = l 38 + } 39 + } 40 + 41 + ref, err := url.QueryUnescape(refParam) 42 + if err != nil { 43 + writeError(w, xrpcerr.NewXrpcError( 44 + xrpcerr.WithTag("InvalidRequest"), 45 + xrpcerr.WithMessage("invalid ref parameter"), 46 + ), http.StatusBadRequest) 47 + return 48 + } 49 + 50 + gr, err := git.Open(repoPath, ref) 51 + if err != nil { 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("RefNotFound"), 54 + xrpcerr.WithMessage("repository or ref not found"), 55 + ), http.StatusNotFound) 56 + return 57 + } 58 + 59 + offset := 0 60 + if cursor != "" { 61 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 62 + offset = o 63 + } 64 + } 65 + 66 + commits, err := gr.Commits(offset, limit) 67 + if err != nil { 68 + x.Logger.Error("fetching commits", "error", err.Error()) 69 + writeError(w, xrpcerr.NewXrpcError( 70 + xrpcerr.WithTag("PathNotFound"), 71 + xrpcerr.WithMessage("failed to read commit log"), 72 + ), http.StatusNotFound) 73 + return 74 + } 75 + 76 + total, err := gr.TotalCommits() 77 + if err != nil { 78 + x.Logger.Error("fetching total commits", "error", err.Error()) 79 + writeError(w, xrpcerr.NewXrpcError( 80 + xrpcerr.WithTag("InternalServerError"), 81 + xrpcerr.WithMessage("failed to fetch total commits"), 82 + ), http.StatusNotFound) 83 + return 84 + } 85 + 86 + // Create response using existing types.RepoLogResponse 87 + response := types.RepoLogResponse{ 88 + Commits: commits, 89 + Ref: ref, 90 + Page: (offset / limit) + 1, 91 + PerPage: limit, 92 + Total: total, 93 + } 94 + 95 + if path != "" { 96 + response.Description = path 97 + } 98 + 99 + response.Log = true 100 + 101 + // Write JSON response directly 102 + w.Header().Set("Content-Type", "application/json") 103 + if err := json.NewEncoder(w).Encode(response); err != nil { 104 + x.Logger.Error("failed to encode response", "error", err) 105 + writeError(w, xrpcerr.NewXrpcError( 106 + xrpcerr.WithTag("InternalServerError"), 107 + xrpcerr.WithMessage("failed to encode response"), 108 + ), http.StatusInternalServerError) 109 + return 110 + } 111 + }
+99
knotserver/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + "tangled.sh/tangled.sh/core/types" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + cursor := r.URL.Query().Get("cursor") 25 + 26 + limit := 50 // default 27 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 28 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 29 + limit = l 30 + } 31 + } 32 + 33 + gr, err := git.Open(repoPath, "") 34 + if err != nil { 35 + x.Logger.Error("failed to open", "error", err) 36 + writeError(w, xrpcerr.NewXrpcError( 37 + xrpcerr.WithTag("RepoNotFound"), 38 + xrpcerr.WithMessage("repository not found"), 39 + ), http.StatusNotFound) 40 + return 41 + } 42 + 43 + tags, err := gr.Tags() 44 + if err != nil { 45 + x.Logger.Warn("getting tags", "error", err.Error()) 46 + tags = []object.Tag{} 47 + } 48 + 49 + rtags := []*types.TagReference{} 50 + for _, tag := range tags { 51 + var target *object.Tag 52 + if tag.Target != plumbing.ZeroHash { 53 + target = &tag 54 + } 55 + tr := types.TagReference{ 56 + Tag: target, 57 + } 58 + 59 + tr.Reference = types.Reference{ 60 + Name: tag.Name, 61 + Hash: tag.Hash.String(), 62 + } 63 + 64 + if tag.Message != "" { 65 + tr.Message = tag.Message 66 + } 67 + 68 + rtags = append(rtags, &tr) 69 + } 70 + 71 + // apply pagination manually 72 + offset := 0 73 + if cursor != "" { 74 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 75 + offset = o 76 + } 77 + } 78 + 79 + // calculate end index 80 + end := min(offset+limit, len(rtags)) 81 + 82 + paginatedTags := rtags[offset:end] 83 + 84 + // Create response using existing types.RepoTagsResponse 85 + response := types.RepoTagsResponse{ 86 + Tags: paginatedTags, 87 + } 88 + 89 + // Write JSON response directly 90 + w.Header().Set("Content-Type", "application/json") 91 + if err := json.NewEncoder(w).Encode(response); err != nil { 92 + x.Logger.Error("failed to encode response", "error", err) 93 + writeError(w, xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InternalServerError"), 95 + xrpcerr.WithMessage("failed to encode response"), 96 + ), http.StatusInternalServerError) 97 + return 98 + } 99 + }
+116
knotserver/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "path/filepath" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 15 + ctx := r.Context() 16 + 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + refParam := r.URL.Query().Get("ref") 25 + if refParam == "" { 26 + writeError(w, xrpcerr.NewXrpcError( 27 + xrpcerr.WithTag("InvalidRequest"), 28 + xrpcerr.WithMessage("missing ref parameter"), 29 + ), http.StatusBadRequest) 30 + return 31 + } 32 + 33 + path := r.URL.Query().Get("path") 34 + // path can be empty (defaults to root) 35 + 36 + ref, err := url.QueryUnescape(refParam) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("InvalidRequest"), 40 + xrpcerr.WithMessage("invalid ref parameter"), 41 + ), http.StatusBadRequest) 42 + return 43 + } 44 + 45 + gr, err := git.Open(repoPath, ref) 46 + if err != nil { 47 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("RefNotFound"), 50 + xrpcerr.WithMessage("repository or ref not found"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + files, err := gr.FileTree(ctx, path) 56 + if err != nil { 57 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("PathNotFound"), 60 + xrpcerr.WithMessage("failed to read repository tree"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // convert NiceTree -> tangled.RepoTree_TreeEntry 66 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 67 + for i, file := range files { 68 + entry := &tangled.RepoTree_TreeEntry{ 69 + Name: file.Name, 70 + Mode: file.Mode, 71 + Size: file.Size, 72 + Is_file: file.IsFile, 73 + Is_subtree: file.IsSubtree, 74 + } 75 + 76 + if file.LastCommit != nil { 77 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 78 + Hash: file.LastCommit.Hash.String(), 79 + Message: file.LastCommit.Message, 80 + When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"), 81 + } 82 + } 83 + 84 + treeEntries[i] = entry 85 + } 86 + 87 + var parentPtr *string 88 + if path != "" { 89 + parentPtr = &path 90 + } 91 + 92 + var dotdotPtr *string 93 + if path != "" { 94 + dotdot := filepath.Dir(path) 95 + if dotdot != "." { 96 + dotdotPtr = &dotdot 97 + } 98 + } 99 + 100 + response := tangled.RepoTree_Output{ 101 + Ref: ref, 102 + Parent: parentPtr, 103 + Dotdot: dotdotPtr, 104 + Files: treeEntries, 105 + } 106 + 107 + w.Header().Set("Content-Type", "application/json") 108 + if err := json.NewEncoder(w).Encode(response); err != nil { 109 + x.Logger.Error("failed to encode response", "error", err) 110 + writeError(w, xrpcerr.NewXrpcError( 111 + xrpcerr.WithTag("InternalServerError"), 112 + xrpcerr.WithMessage("failed to encode response"), 113 + ), http.StatusInternalServerError) 114 + return 115 + } 116 + }
+70
knotserver/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "runtime/debug" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + // version is set during build time. 14 + var version string 15 + 16 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 17 + if version == "" { 18 + info, ok := debug.ReadBuildInfo() 19 + if !ok { 20 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 21 + return 22 + } 23 + 24 + var modVer string 25 + var sha string 26 + var modified bool 27 + 28 + for _, mod := range info.Deps { 29 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 30 + modVer = mod.Version 31 + break 32 + } 33 + } 34 + 35 + for _, setting := range info.Settings { 36 + switch setting.Key { 37 + case "vcs.revision": 38 + sha = setting.Value 39 + case "vcs.modified": 40 + modified = setting.Value == "true" 41 + } 42 + } 43 + 44 + if modVer == "" { 45 + modVer = "unknown" 46 + } 47 + 48 + if sha == "" { 49 + version = modVer 50 + } else if modified { 51 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 52 + } else { 53 + version = fmt.Sprintf("%s (%s)", modVer, sha) 54 + } 55 + } 56 + 57 + response := tangled.KnotVersion_Output{ 58 + Version: version, 59 + } 60 + 61 + w.Header().Set("Content-Type", "application/json") 62 + if err := json.NewEncoder(w).Encode(response); err != nil { 63 + x.Logger.Error("failed to encode response", "error", err) 64 + writeError(w, xrpcerr.NewXrpcError( 65 + xrpcerr.WithTag("InternalServerError"), 66 + xrpcerr.WithMessage("failed to encode response"), 67 + ), http.StatusInternalServerError) 68 + return 69 + } 70 + }
+88
knotserver/xrpc/xrpc.go
··· 4 4 "encoding/json" 5 5 "log/slog" 6 6 "net/http" 7 + "net/url" 8 + "strings" 7 9 10 + securejoin "github.com/cyphar/filepath-securejoin" 8 11 "tangled.sh/tangled.sh/core/api/tangled" 9 12 "tangled.sh/tangled.sh/core/idresolver" 10 13 "tangled.sh/tangled.sh/core/jetstream" ··· 50 53 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 54 // - use ETags on clients to keep requests to a minimum 52 55 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 56 + 57 + // repo query endpoints (no auth required) 58 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 65 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 66 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 67 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 68 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 69 + 70 + // knot query endpoints (no auth required) 71 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 72 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 73 + 74 + // service query endpoints (no auth required) 75 + r.Get("/"+tangled.OwnerNSID, x.Owner) 76 + 53 77 return r 78 + } 79 + 80 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 81 + // the full repository path on disk 82 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 83 + if repo == "" { 84 + return "", xrpcerr.NewXrpcError( 85 + xrpcerr.WithTag("InvalidRequest"), 86 + xrpcerr.WithMessage("missing repo parameter"), 87 + ) 88 + } 89 + 90 + // Parse repo string (did/repoName format) 91 + parts := strings.Split(repo, "/") 92 + if len(parts) < 2 { 93 + return "", xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InvalidRequest"), 95 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 96 + ) 97 + } 98 + 99 + did := strings.Join(parts[:len(parts)-1], "/") 100 + repoName := parts[len(parts)-1] 101 + 102 + // Construct repository path using the same logic as didPath 103 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 104 + if err != nil { 105 + return "", xrpcerr.NewXrpcError( 106 + xrpcerr.WithTag("RepoNotFound"), 107 + xrpcerr.WithMessage("failed to access repository"), 108 + ) 109 + } 110 + 111 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 112 + if err != nil { 113 + return "", xrpcerr.NewXrpcError( 114 + xrpcerr.WithTag("RepoNotFound"), 115 + xrpcerr.WithMessage("failed to access repository"), 116 + ) 117 + } 118 + 119 + return repoPath, nil 120 + } 121 + 122 + // parseStandardParams parses common query parameters used by most handlers 123 + func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) { 124 + // Parse repo parameter 125 + repo = r.URL.Query().Get("repo") 126 + repoPath, err = x.parseRepoParam(repo) 127 + if err != nil { 128 + return "", "", "", err 129 + } 130 + 131 + // Parse and unescape ref parameter 132 + refParam := r.URL.Query().Get("ref") 133 + if refParam == "" { 134 + return "", "", "", xrpcerr.NewXrpcError( 135 + xrpcerr.WithTag("InvalidRequest"), 136 + xrpcerr.WithMessage("missing ref parameter"), 137 + ) 138 + } 139 + 140 + ref, _ = url.QueryUnescape(refParam) 141 + return repo, repoPath, ref, nil 54 142 } 55 143 56 144 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+158
legal/privacy.md
··· 1 + # Privacy Policy 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + This Privacy Policy describes how Tangled ("we," "us," or "our") 6 + collects, uses, and shares your personal information when you use our 7 + platform and services (the "Service"). 8 + 9 + ## 1. Information We Collect 10 + 11 + ### Account Information 12 + 13 + When you create an account, we collect: 14 + 15 + - Your chosen username 16 + - Email address 17 + - Profile information you choose to provide 18 + - Authentication data 19 + 20 + ### Content and Activity 21 + 22 + We store: 23 + 24 + - Code repositories and associated metadata 25 + - Issues, pull requests, and comments 26 + - Activity logs and usage patterns 27 + - Public keys for authentication 28 + 29 + ## 2. Data Location and Hosting 30 + 31 + ### EU Data Hosting 32 + 33 + **All Tangled service data is hosted within the European Union.** 34 + Specifically: 35 + 36 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 + (*.tngl.sh) are located in Finland 38 + - **Application Data:** All other service data is stored on EU-based 39 + servers 40 + - **Data Processing:** All data processing occurs within EU 41 + jurisdiction 42 + 43 + ### External PDS Notice 44 + 45 + **Important:** If your account is hosted on Bluesky's PDS or other 46 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 + that data. The data protection, storage location, and privacy 48 + practices for such accounts are governed by the respective PDS 49 + provider's policies, not this Privacy Policy. We only control data 50 + processing within our own services and infrastructure. 51 + 52 + ## 3. Third-Party Data Processors 53 + 54 + We only share your data with the following third-party processors: 55 + 56 + ### Resend (Email Services) 57 + 58 + - **Purpose:** Sending transactional emails (account verification, 59 + notifications) 60 + - **Data Shared:** Email address and necessary message content 61 + 62 + ### Cloudflare (Image Caching) 63 + 64 + - **Purpose:** Caching and optimizing image delivery 65 + - **Data Shared:** Public images and associated metadata for caching 66 + purposes 67 + 68 + ### Posthog (Usage Metrics Tracking) 69 + 70 + - **Purpose:** Tracking usage and platform metrics 71 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 + information 73 + 74 + ## 4. How We Use Your Information 75 + 76 + We use your information to: 77 + 78 + - Provide and maintain the Service 79 + - Process your transactions and requests 80 + - Send you technical notices and support messages 81 + - Improve and develop new features 82 + - Ensure security and prevent fraud 83 + - Comply with legal obligations 84 + 85 + ## 5. Data Sharing and Disclosure 86 + 87 + We do not sell, trade, or rent your personal information. We may share 88 + your information only in the following circumstances: 89 + 90 + - With the third-party processors listed above 91 + - When required by law or legal process 92 + - To protect our rights, property, or safety, or that of our users 93 + - In connection with a merger, acquisition, or sale of assets (with 94 + appropriate protections) 95 + 96 + ## 6. Data Security 97 + 98 + We implement appropriate technical and organizational measures to 99 + protect your personal information against unauthorized access, 100 + alteration, disclosure, or destruction. However, no method of 101 + transmission over the Internet is 100% secure. 102 + 103 + ## 7. Data Retention 104 + 105 + We retain your personal information for as long as necessary to provide 106 + the Service and fulfill the purposes outlined in this Privacy Policy, 107 + unless a longer retention period is required by law. 108 + 109 + ## 8. Your Rights 110 + 111 + Under applicable data protection laws, you have the right to: 112 + 113 + - Access your personal information 114 + - Correct inaccurate information 115 + - Request deletion of your information 116 + - Object to processing of your information 117 + - Data portability 118 + - Withdraw consent (where applicable) 119 + 120 + ## 9. Cookies and Tracking 121 + 122 + We use cookies and similar technologies to: 123 + 124 + - Maintain your login session 125 + - Remember your preferences 126 + - Analyze usage patterns to improve the Service 127 + 128 + You can control cookie settings through your browser preferences. 129 + 130 + ## 10. Children's Privacy 131 + 132 + The Service is not intended for children under 16 years of age. We do 133 + not knowingly collect personal information from children under 16. If 134 + we become aware that we have collected such information, we will take 135 + steps to delete it. 136 + 137 + ## 11. International Data Transfers 138 + 139 + While all our primary data processing occurs within the EU, some of our 140 + third-party processors may process data outside the EU. When this 141 + occurs, we ensure appropriate safeguards are in place, such as Standard 142 + Contractual Clauses or adequacy decisions. 143 + 144 + ## 12. Changes to This Privacy Policy 145 + 146 + We may update this Privacy Policy from time to time. We will notify you 147 + of any changes by posting the new Privacy Policy on this page and 148 + updating the "Last updated" date. 149 + 150 + ## 13. Contact Information 151 + 152 + If you have any questions about this Privacy Policy or wish to exercise 153 + your rights, please contact us through our platform or via email. 154 + 155 + --- 156 + 157 + This Privacy Policy complies with the EU General Data Protection 158 + Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
··· 1 + # Terms of Service 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 + to and use of the Tangled platform and services (the "Service") 7 + operated by us ("Tangled," "we," "us," or "our"). 8 + 9 + ## 1. Acceptance of Terms 10 + 11 + By accessing or using our Service, you agree to be bound by these Terms. 12 + If you disagree with any part of these terms, then you may not access 13 + the Service. 14 + 15 + ## 2. Account Registration 16 + 17 + To use certain features of the Service, you must register for an 18 + account. You agree to provide accurate, current, and complete 19 + information during the registration process and to update such 20 + information to keep it accurate, current, and complete. 21 + 22 + ## 3. Account Termination 23 + 24 + > **Important Notice** 25 + > 26 + > **We reserve the right to terminate, suspend, or restrict access to 27 + > your account at any time, for any reason, or for no reason at all, at 28 + > our sole discretion.** This includes, but is not limited to, 29 + > termination for violation of these Terms, inappropriate conduct, spam, 30 + > abuse, or any other behavior we deem harmful to the Service or other 31 + > users. 32 + > 33 + > Account termination may result in the loss of access to your 34 + > repositories, data, and other content associated with your account. We 35 + > are not obligated to provide advance notice of termination, though we 36 + > may do so in our discretion. 37 + 38 + ## 4. Acceptable Use 39 + 40 + You agree not to use the Service to: 41 + 42 + - Violate any applicable laws or regulations 43 + - Infringe upon the rights of others 44 + - Upload, store, or share content that is illegal, harmful, threatening, 45 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 + objectionable 47 + - Engage in spam, phishing, or other deceptive practices 48 + - Attempt to gain unauthorized access to the Service or other users' 49 + accounts 50 + - Interfere with or disrupt the Service or servers connected to the 51 + Service 52 + 53 + ## 5. Content and Intellectual Property 54 + 55 + You retain ownership of the content you upload to the Service. By 56 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 + license to use, reproduce, modify, and distribute your content as 58 + necessary to provide the Service. 59 + 60 + ## 6. Privacy 61 + 62 + Your privacy is important to us. Please review our [Privacy 63 + Policy](/privacy), which also governs your use of the Service. 64 + 65 + ## 7. Disclaimers 66 + 67 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 + no warranties, expressed or implied, and hereby disclaim and negate all 69 + other warranties including without limitation, implied warranties or 70 + conditions of merchantability, fitness for a particular purpose, or 71 + non-infringement of intellectual property or other violation of rights. 72 + 73 + ## 8. Limitation of Liability 74 + 75 + In no event shall Tangled, nor its directors, employees, partners, 76 + agents, suppliers, or affiliates, be liable for any indirect, 77 + incidental, special, consequential, or punitive damages, including 78 + without limitation, loss of profits, data, use, goodwill, or other 79 + intangible losses, resulting from your use of the Service. 80 + 81 + ## 9. Indemnification 82 + 83 + You agree to defend, indemnify, and hold harmless Tangled and its 84 + affiliates, officers, directors, employees, and agents from and against 85 + any and all claims, damages, obligations, losses, liabilities, costs, 86 + or debt, and expenses (including attorney's fees). 87 + 88 + ## 10. Governing Law 89 + 90 + These Terms shall be interpreted and governed by the laws of Finland, 91 + without regard to its conflict of law provisions. 92 + 93 + ## 11. Changes to Terms 94 + 95 + We reserve the right to modify or replace these Terms at any time. If a 96 + revision is material, we will try to provide at least 30 days notice 97 + prior to any new terms taking effect. 98 + 99 + ## 12. Contact Information 100 + 101 + If you have any questions about these Terms of Service, please contact 102 + us through our platform or via email. 103 + 104 + --- 105 + 106 + These terms are effective as of the last updated date shown above and 107 + will remain in effect except with respect to any changes in their 108 + provisions in the future, which will be in effect immediately after 109 + being posted on this page.
+9 -9
lexicons/issue/comment.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": ["issue", "body", "createdAt"], 12 + "required": [ 13 + "issue", 14 + "body", 15 + "createdAt" 16 + ], 13 17 "properties": { 14 18 "issue": { 15 19 "type": "string", 16 20 "format": "at-uri" 17 21 }, 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "owner": { 23 - "type": "string", 24 - "format": "did" 25 - }, 26 22 "body": { 27 23 "type": "string" 28 24 }, 29 25 "createdAt": { 30 26 "type": "string", 31 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 32 } 33 33 } 34 34 }
+73
lexicons/knot/listKeys.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.listKeys", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List all public keys stored in the knot server", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "Maximum number of keys to return", 14 + "minimum": 1, 15 + "maximum": 1000, 16 + "default": 100 17 + }, 18 + "cursor": { 19 + "type": "string", 20 + "description": "Pagination cursor" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["keys"], 29 + "properties": { 30 + "keys": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "#publicKey" 35 + } 36 + }, 37 + "cursor": { 38 + "type": "string", 39 + "description": "Pagination cursor for next page" 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InternalServerError", 47 + "description": "Failed to retrieve public keys" 48 + } 49 + ] 50 + }, 51 + "publicKey": { 52 + "type": "object", 53 + "required": ["did", "key", "createdAt"], 54 + "properties": { 55 + "did": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID associated with the public key" 59 + }, 60 + "key": { 61 + "type": "string", 62 + "maxLength": 4096, 63 + "description": "Public key contents" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "Key upload timestamp" 69 + } 70 + } 71 + } 72 + } 73 + }
+25
lexicons/knot/version.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.version", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the version of a knot", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "version" 14 + ], 15 + "properties": { 16 + "version": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "errors": [] 23 + } 24 + } 25 + }
+31
lexicons/owner.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.owner", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the owner of a service", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "owner" 14 + ], 15 + "properties": { 16 + "owner": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "OwnerNotFound", 26 + "description": "Owner is not set for this service" 27 + } 28 + ] 29 + } 30 + } 31 + }
+55
lexicons/repo/archive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.archive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "format": { 20 + "type": "string", 21 + "description": "Archive format", 22 + "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 23 + "default": "tar.gz" 24 + }, 25 + "prefix": { 26 + "type": "string", 27 + "description": "Prefix for files in the archive" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "*/*", 33 + "description": "Binary archive data" 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "RefNotFound", 42 + "description": "Git reference not found" 43 + }, 44 + { 45 + "name": "InvalidRequest", 46 + "description": "Invalid request parameters" 47 + }, 48 + { 49 + "name": "ArchiveError", 50 + "description": "Failed to create archive" 51 + } 52 + ] 53 + } 54 + } 55 + }
+138
lexicons/repo/blob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.blob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref", "path"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to the file within the repository" 22 + }, 23 + "raw": { 24 + "type": "boolean", 25 + "description": "Return raw file content instead of JSON response", 26 + "default": false 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["ref", "path", "content"], 35 + "properties": { 36 + "ref": { 37 + "type": "string", 38 + "description": "The git reference used" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "The file path" 43 + }, 44 + "content": { 45 + "type": "string", 46 + "description": "File content (base64 encoded for binary files)" 47 + }, 48 + "encoding": { 49 + "type": "string", 50 + "description": "Content encoding", 51 + "enum": ["utf-8", "base64"] 52 + }, 53 + "size": { 54 + "type": "integer", 55 + "description": "File size in bytes" 56 + }, 57 + "isBinary": { 58 + "type": "boolean", 59 + "description": "Whether the file is binary" 60 + }, 61 + "mimeType": { 62 + "type": "string", 63 + "description": "MIME type of the file" 64 + }, 65 + "lastCommit": { 66 + "type": "ref", 67 + "ref": "#lastCommit" 68 + } 69 + } 70 + } 71 + }, 72 + "errors": [ 73 + { 74 + "name": "RepoNotFound", 75 + "description": "Repository not found or access denied" 76 + }, 77 + { 78 + "name": "RefNotFound", 79 + "description": "Git reference not found" 80 + }, 81 + { 82 + "name": "FileNotFound", 83 + "description": "File not found at the specified path" 84 + }, 85 + { 86 + "name": "InvalidRequest", 87 + "description": "Invalid request parameters" 88 + } 89 + ] 90 + }, 91 + "lastCommit": { 92 + "type": "object", 93 + "required": ["hash", "message", "when"], 94 + "properties": { 95 + "hash": { 96 + "type": "string", 97 + "description": "Commit hash" 98 + }, 99 + "shortHash": { 100 + "type": "string", 101 + "description": "Short commit hash" 102 + }, 103 + "message": { 104 + "type": "string", 105 + "description": "Commit message" 106 + }, 107 + "author": { 108 + "type": "ref", 109 + "ref": "#signature" 110 + }, 111 + "when": { 112 + "type": "string", 113 + "format": "datetime", 114 + "description": "Commit timestamp" 115 + } 116 + } 117 + }, 118 + "signature": { 119 + "type": "object", 120 + "required": ["name", "email", "when"], 121 + "properties": { 122 + "name": { 123 + "type": "string", 124 + "description": "Author name" 125 + }, 126 + "email": { 127 + "type": "string", 128 + "description": "Author email" 129 + }, 130 + "when": { 131 + "type": "string", 132 + "format": "datetime", 133 + "description": "Author timestamp" 134 + } 135 + } 136 + } 137 + } 138 + }
+94
lexicons/repo/branch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "name"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "name": { 16 + "type": "string", 17 + "description": "Branch name to get information for" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["name", "hash", "when"], 26 + "properties": { 27 + "name": { 28 + "type": "string", 29 + "description": "Branch name" 30 + }, 31 + "hash": { 32 + "type": "string", 33 + "description": "Latest commit hash on this branch" 34 + }, 35 + "shortHash": { 36 + "type": "string", 37 + "description": "Short commit hash" 38 + }, 39 + "when": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Timestamp of latest commit" 43 + }, 44 + "message": { 45 + "type": "string", 46 + "description": "Latest commit message" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "#signature" 51 + }, 52 + "isDefault": { 53 + "type": "boolean", 54 + "description": "Whether this is the default branch" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "BranchNotFound", 66 + "description": "Branch not found" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "signature": { 75 + "type": "object", 76 + "required": ["name", "email", "when"], 77 + "properties": { 78 + "name": { 79 + "type": "string", 80 + "description": "Author name" 81 + }, 82 + "email": { 83 + "type": "string", 84 + "description": "Author email" 85 + }, 86 + "when": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "Author timestamp" 90 + } 91 + } 92 + } 93 + } 94 + }
+43
lexicons/repo/branches.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branches", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of branches to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+49
lexicons/repo/compare.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.compare", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "rev1", "rev2"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "rev1": { 16 + "type": "string", 17 + "description": "First revision (commit, branch, or tag)" 18 + }, 19 + "rev2": { 20 + "type": "string", 21 + "description": "Second revision (commit, branch, or tag)" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "*/*", 27 + "description": "Compare output in application/json" 28 + }, 29 + "errors": [ 30 + { 31 + "name": "RepoNotFound", 32 + "description": "Repository not found or access denied" 33 + }, 34 + { 35 + "name": "RevisionNotFound", 36 + "description": "One or both revisions not found" 37 + }, 38 + { 39 + "name": "InvalidRequest", 40 + "description": "Invalid request parameters" 41 + }, 42 + { 43 + "name": "CompareError", 44 + "description": "Failed to compare revisions" 45 + } 46 + ] 47 + } 48 + } 49 + }
+40
lexicons/repo/diff.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.diff", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "*/*" 23 + }, 24 + "errors": [ 25 + { 26 + "name": "RepoNotFound", 27 + "description": "Repository not found or access denied" 28 + }, 29 + { 30 + "name": "RefNotFound", 31 + "description": "Git reference not found" 32 + }, 33 + { 34 + "name": "InvalidRequest", 35 + "description": "Invalid request parameters" 36 + } 37 + ] 38 + } 39 + } 40 + }
+82
lexicons/repo/getDefaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["name", "hash", "when"], 22 + "properties": { 23 + "name": { 24 + "type": "string", 25 + "description": "Default branch name" 26 + }, 27 + "hash": { 28 + "type": "string", 29 + "description": "Latest commit hash on default branch" 30 + }, 31 + "shortHash": { 32 + "type": "string", 33 + "description": "Short commit hash" 34 + }, 35 + "when": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of latest commit" 39 + }, 40 + "message": { 41 + "type": "string", 42 + "description": "Latest commit message" 43 + }, 44 + "author": { 45 + "type": "ref", 46 + "ref": "#signature" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "InvalidRequest", 58 + "description": "Invalid request parameters" 59 + } 60 + ] 61 + }, 62 + "signature": { 63 + "type": "object", 64 + "required": ["name", "email", "when"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "description": "Author name" 69 + }, 70 + "email": { 71 + "type": "string", 72 + "description": "Author email" 73 + }, 74 + "when": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "Author timestamp" 78 + } 79 + } 80 + } 81 + } 82 + }
+99
lexicons/repo/languages.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.languages", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)", 18 + "default": "HEAD" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["ref", "languages"], 27 + "properties": { 28 + "ref": { 29 + "type": "string", 30 + "description": "The git reference used" 31 + }, 32 + "languages": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#language" 37 + } 38 + }, 39 + "totalSize": { 40 + "type": "integer", 41 + "description": "Total size of all analyzed files in bytes" 42 + }, 43 + "totalFiles": { 44 + "type": "integer", 45 + "description": "Total number of files analyzed" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "RepoNotFound", 53 + "description": "Repository not found or access denied" 54 + }, 55 + { 56 + "name": "RefNotFound", 57 + "description": "Git reference not found" 58 + }, 59 + { 60 + "name": "InvalidRequest", 61 + "description": "Invalid request parameters" 62 + } 63 + ] 64 + }, 65 + "language": { 66 + "type": "object", 67 + "required": ["name", "size", "percentage"], 68 + "properties": { 69 + "name": { 70 + "type": "string", 71 + "description": "Programming language name" 72 + }, 73 + "size": { 74 + "type": "integer", 75 + "description": "Total size of files in this language (bytes)" 76 + }, 77 + "percentage": { 78 + "type": "integer", 79 + "description": "Percentage of total codebase (0-100)" 80 + }, 81 + "fileCount": { 82 + "type": "integer", 83 + "description": "Number of files in this language" 84 + }, 85 + "color": { 86 + "type": "string", 87 + "description": "Hex color code for this language" 88 + }, 89 + "extensions": { 90 + "type": "array", 91 + "items": { 92 + "type": "string" 93 + }, 94 + "description": "File extensions associated with this language" 95 + } 96 + } 97 + } 98 + } 99 + }
+60
lexicons/repo/log.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.log", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to filter commits by", 22 + "default": "" 23 + }, 24 + "limit": { 25 + "type": "integer", 26 + "description": "Maximum number of commits to return", 27 + "minimum": 1, 28 + "maximum": 100, 29 + "default": 50 30 + }, 31 + "cursor": { 32 + "type": "string", 33 + "description": "Pagination cursor (commit SHA)" 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "*/*" 39 + }, 40 + "errors": [ 41 + { 42 + "name": "RepoNotFound", 43 + "description": "Repository not found or access denied" 44 + }, 45 + { 46 + "name": "RefNotFound", 47 + "description": "Git reference not found" 48 + }, 49 + { 50 + "name": "PathNotFound", 51 + "description": "Path not found in repository" 52 + }, 53 + { 54 + "name": "InvalidRequest", 55 + "description": "Invalid request parameters" 56 + } 57 + ] 58 + } 59 + } 60 + }
-1
lexicons/repo/repo.json
··· 34 34 }, 35 35 "description": { 36 36 "type": "string", 37 - "format": "datetime", 38 37 "minGraphemes": 1, 39 38 "maxGraphemes": 140 40 39 },
+43
lexicons/repo/tags.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of tags to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+123
lexicons/repo/tree.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tree", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path within the repository tree", 22 + "default": "" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["ref", "files"], 31 + "properties": { 32 + "ref": { 33 + "type": "string", 34 + "description": "The git reference used" 35 + }, 36 + "parent": { 37 + "type": "string", 38 + "description": "The parent path in the tree" 39 + }, 40 + "dotdot": { 41 + "type": "string", 42 + "description": "Parent directory path" 43 + }, 44 + "files": { 45 + "type": "array", 46 + "items": { 47 + "type": "ref", 48 + "ref": "#treeEntry" 49 + } 50 + } 51 + } 52 + } 53 + }, 54 + "errors": [ 55 + { 56 + "name": "RepoNotFound", 57 + "description": "Repository not found or access denied" 58 + }, 59 + { 60 + "name": "RefNotFound", 61 + "description": "Git reference not found" 62 + }, 63 + { 64 + "name": "PathNotFound", 65 + "description": "Path not found in repository tree" 66 + }, 67 + { 68 + "name": "InvalidRequest", 69 + "description": "Invalid request parameters" 70 + } 71 + ] 72 + }, 73 + "treeEntry": { 74 + "type": "object", 75 + "required": ["name", "mode", "size", "is_file", "is_subtree"], 76 + "properties": { 77 + "name": { 78 + "type": "string", 79 + "description": "Relative file or directory name" 80 + }, 81 + "mode": { 82 + "type": "string", 83 + "description": "File mode" 84 + }, 85 + "size": { 86 + "type": "integer", 87 + "description": "File size in bytes" 88 + }, 89 + "is_file": { 90 + "type": "boolean", 91 + "description": "Whether this entry is a file" 92 + }, 93 + "is_subtree": { 94 + "type": "boolean", 95 + "description": "Whether this entry is a directory/subtree" 96 + }, 97 + "last_commit": { 98 + "type": "ref", 99 + "ref": "#lastCommit" 100 + } 101 + } 102 + }, 103 + "lastCommit": { 104 + "type": "object", 105 + "required": ["hash", "message", "when"], 106 + "properties": { 107 + "hash": { 108 + "type": "string", 109 + "description": "Commit hash" 110 + }, 111 + "message": { 112 + "type": "string", 113 + "description": "Commit message" 114 + }, 115 + "when": { 116 + "type": "string", 117 + "format": "datetime", 118 + "description": "Commit timestamp" 119 + } 120 + } 121 + } 122 + } 123 + }
+8 -2
nix/gomod2nix.toml
··· 425 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 426 version = "v0.3.1" 427 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/wyatt915/goldmark-treeblood"] 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 + [mod."github.com/wyatt915/treeblood"] 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 428 434 [mod."github.com/yuin/goldmark"] 429 - version = "v1.4.15" 430 - hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 431 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 432 438 version = "v2.0.0-20230729083705-37449abec8cc" 433 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+17 -12
nix/pkgs/knot-unwrapped.nix
··· 3 3 modules, 4 4 sqlite-lib, 5 5 src, 6 - }: 7 - buildGoApplication { 8 - pname = "knot"; 9 - version = "0.1.0"; 10 - inherit src modules; 6 + }: let 7 + version = "1.9.0-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 + 13 + doCheck = false; 11 14 12 - doCheck = false; 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 13 17 14 - subPackages = ["cmd/knot"]; 15 - tags = ["libsqlite3"]; 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 16 21 17 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 19 - CGO_ENABLED = 1; 20 - } 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }
+1 -1
patchutil/combinediff.go
··· 119 119 // we have f1 and f2, combine them 120 120 combined, err := combineFiles(f1, f2) 121 121 if err != nil { 122 - fmt.Println(err) 122 + // fmt.Println(err) 123 123 } 124 124 125 125 // combined can be nil commit 2 reverted all changes from commit 1
-3
spindle/server.go
··· 203 203 w.Write(motd) 204 204 }) 205 205 mux.HandleFunc("/events", s.Events) 206 - mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 207 - w.Write([]byte(s.cfg.Server.Owner)) 208 - }) 209 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 210 207 211 208 mux.Mount("/xrpc", s.XrpcRouter())
+31
spindle/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+10 -3
spindle/xrpc/xrpc.go
··· 35 35 func (x *Xrpc) Router() http.Handler { 36 36 r := chi.NewRouter() 37 37 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) 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 40 + 41 + r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 + r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 + r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 44 + }) 45 + 46 + // service query endpoints (no auth required) 47 + r.Get("/"+tangled.OwnerNSID, x.Owner) 41 48 42 49 return r 43 50 }
+5
xrpc/errors/errors.go
··· 51 51 WithMessage("actor DID not supplied"), 52 52 ) 53 53 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 54 59 var AuthError = func(err error) XrpcError { 55 60 return NewXrpcError( 56 61 WithTag("Auth"),