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

Compare changes

Choose any two refs to compare.

+8302 -5001
+16 -73
api/tangled/cbor_gen.go
··· 5898 } 5899 5900 cw := cbg.NewCborWriter(w) 5901 - fieldCount := 6 5902 - 5903 - if t.Owner == nil { 5904 - fieldCount-- 5905 - } 5906 5907 - if t.Repo == nil { 5908 fieldCount-- 5909 } 5910 ··· 5935 return err 5936 } 5937 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 // t.LexiconTypeID (string) (string) 5971 if len("$type") > 1000000 { 5972 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6009 return err 6010 } 6011 6012 - // t.Owner (string) (string) 6013 - if t.Owner != nil { 6014 6015 - if len("owner") > 1000000 { 6016 - return xerrors.Errorf("Value in field \"owner\" was too long") 6017 } 6018 6019 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 6020 return err 6021 } 6022 - if _, err := cw.WriteString(string("owner")); err != nil { 6023 return err 6024 } 6025 6026 - if t.Owner == nil { 6027 if _, err := cw.Write(cbg.CborNull); err != nil { 6028 return err 6029 } 6030 } else { 6031 - if len(*t.Owner) > 1000000 { 6032 - return xerrors.Errorf("Value in field t.Owner was too long") 6033 } 6034 6035 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 6036 return err 6037 } 6038 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6039 return err 6040 } 6041 } ··· 6118 6119 t.Body = string(sval) 6120 } 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 // t.LexiconTypeID (string) (string) 6143 case "$type": 6144 ··· 6161 6162 t.Issue = string(sval) 6163 } 6164 - // t.Owner (string) (string) 6165 - case "owner": 6166 6167 { 6168 b, err := cr.ReadByte() ··· 6179 return err 6180 } 6181 6182 - t.Owner = (*string)(&sval) 6183 } 6184 } 6185 // t.CreatedAt (string) (string)
··· 5898 } 5899 5900 cw := cbg.NewCborWriter(w) 5901 + fieldCount := 5 5902 5903 + if t.ReplyTo == nil { 5904 fieldCount-- 5905 } 5906 ··· 5931 return err 5932 } 5933 5934 // t.LexiconTypeID (string) (string) 5935 if len("$type") > 1000000 { 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 5973 return err 5974 } 5975 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 5978 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 5981 } 5982 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 5984 return err 5985 } 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 5987 return err 5988 } 5989 5990 + if t.ReplyTo == nil { 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 5992 return err 5993 } 5994 } else { 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 5997 } 5998 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6000 return err 6001 } 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6003 return err 6004 } 6005 } ··· 6082 6083 t.Body = string(sval) 6084 } 6085 // t.LexiconTypeID (string) (string) 6086 case "$type": 6087 ··· 6104 6105 t.Issue = string(sval) 6106 } 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6109 6110 { 6111 b, err := cr.ReadByte() ··· 6122 return err 6123 } 6124 6125 + t.ReplyTo = (*string)(&sval) 6126 } 6127 } 6128 // t.CreatedAt (string) (string)
+1 -2
api/tangled/issuecomment.go
··· 21 Body string `json:"body" cborgen:"body"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 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"` 26 }
··· 21 Body string `json:"body" cborgen:"body"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Issue string `json:"issue" cborgen:"issue"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 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 return err 704 }) 705 706 return &DB{db}, nil 707 } 708 ··· 747 } 748 749 return nil 750 } 751 752 type filter struct {
··· 703 return err 704 }) 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 + 871 return &DB{db}, nil 872 } 873 ··· 912 } 913 914 return nil 915 + } 916 + 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 919 } 920 921 type filter struct {
+4 -4
appview/db/follow.go
··· 56 } 57 58 type FollowStats struct { 59 - Followers int 60 - Following int 61 } 62 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 - followers, following := 0, 0 65 err := e.QueryRow( 66 `SELECT 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 123 for rows.Next() { 124 var did string 125 - var followers, following int 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 return nil, err 128 }
··· 56 } 57 58 type FollowStats struct { 59 + Followers int64 60 + Following int64 61 } 62 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 + var followers, following int64 65 err := e.QueryRow( 66 `SELECT 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 123 for rows.Next() { 124 var did string 125 + var followers, following int64 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 return nil, err 128 }
+410 -453
appview/db/issues.go
··· 3 import ( 4 "database/sql" 5 "fmt" 6 - mathrand "math/rand/v2" 7 "strings" 8 "time" 9 ··· 13 ) 14 15 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 25 26 // optionally, populate this when querying for reverse mappings 27 // like comment counts, parent repo etc. 28 - Metadata *IssueMetadata 29 } 30 31 - type IssueMetadata struct { 32 - CommentCount int 33 - Repo *Repo 34 - // labels, assignee etc. 35 } 36 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 47 } 48 49 - func (i *Issue) AtUri() syntax.ATURI { 50 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 51 } 52 53 func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { ··· 62 } 63 64 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 72 } 73 } 74 75 - func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) { 76 - ownerDid := issueUri.Authority().String() 77 - issueRkey := issueUri.RecordKey().String() 78 79 - var repoAt string 80 - var issueId int 81 82 - query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?` 83 - err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId) 84 - if err != nil { 85 - return "", 0, err 86 } 87 88 - return syntax.ATURI(repoAt), issueId, nil 89 } 90 91 - func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) { 92 created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 if err != nil { 94 created = time.Now() 95 } 96 97 ownerDid := did 98 - if record.Owner != nil { 99 - ownerDid = *record.Owner 100 - } 101 102 - issueUri, err := syntax.ParseATURI(record.Issue) 103 - if err != nil { 104 - return Comment{}, err 105 - } 106 - 107 - repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri) 108 - if err != nil { 109 - return Comment{}, err 110 } 111 112 - comment := Comment{ 113 - OwnerDid: ownerDid, 114 - RepoAt: repoAt, 115 - Rkey: rkey, 116 - Body: record.Body, 117 - Issue: issueId, 118 - CommentId: mathrand.IntN(1000000), 119 - Created: &created, 120 } 121 122 - return comment, nil 123 } 124 125 - func NewIssue(tx *sql.Tx, issue *Issue) error { 126 - defer tx.Rollback() 127 - 128 _, err := tx.Exec(` 129 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 130 values (?, 1) 131 - `, issue.RepoAt) 132 if err != nil { 133 return err 134 } 135 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 { 144 return err 145 - } 146 - 147 - issue.IssueId = nextId 148 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 155 } 156 157 - lastID, err := res.LastInsertId() 158 if err != nil { 159 return err 160 } 161 - issue.ID = lastID 162 163 - if err := tx.Commit(); err != nil { 164 - return err 165 - } 166 167 - return nil 168 } 169 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 256 } 257 258 - func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 259 - issues := make([]Issue, 0, limit) 260 261 var conditions []string 262 var args []any 263 for _, filter := range filters { 264 conditions = append(conditions, filter.Condition()) 265 args = append(args, filter.Arg()...) ··· 269 if conditions != nil { 270 whereClause = " where " + strings.Join(conditions, " and ") 271 } 272 - limitClause := "" 273 - if limit != 0 { 274 - limitClause = fmt.Sprintf(" limit %d ", limit) 275 - } 276 277 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 289 %s 290 - order by 291 - i.created desc 292 - %s`, 293 - whereClause, limitClause) 294 295 rows, err := e.Query(query, args...) 296 if err != nil { 297 - return nil, err 298 } 299 defer rows.Close() 300 301 for rows.Next() { 302 var issue Issue 303 - var issueCreatedAt string 304 err := rows.Scan( 305 - &issue.ID, 306 - &issue.OwnerDid, 307 &issue.RepoAt, 308 &issue.IssueId, 309 - &issueCreatedAt, 310 &issue.Title, 311 &issue.Body, 312 &issue.Open, 313 ) 314 if err != nil { 315 - return nil, err 316 } 317 318 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 319 - if err != nil { 320 - return nil, err 321 } 322 - issue.Created = issueCreatedTime 323 324 - issues = append(issues, issue) 325 } 326 327 - if err := rows.Err(); err != nil { 328 - return nil, err 329 } 330 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) 368 if err != nil { 369 - return nil, err 370 } 371 - defer rows.Close() 372 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 - } 396 397 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 398 - if err != nil { 399 - return nil, err 400 } 401 - issue.Created = issueCreatedTime 402 403 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 404 - if err != nil { 405 - return nil, err 406 - } 407 - repo.Created = repoCreatedTime 408 409 - issue.Metadata = &IssueMetadata{ 410 - Repo: &repo, 411 } 412 - 413 - issues = append(issues, issue) 414 } 415 416 - if err := rows.Err(); err != nil { 417 - return nil, err 418 } 419 420 return issues, nil 421 } 422 423 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 424 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 425 row := e.QueryRow(query, repoAt, issueId) 426 427 var issue Issue 428 var createdAt string 429 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 430 if err != nil { 431 return nil, err 432 } ··· 440 return &issue, nil 441 } 442 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) 450 if err != nil { 451 - return nil, nil, err 452 } 453 454 - createdTime, err := time.Parse(time.RFC3339, createdAt) 455 if err != nil { 456 - return nil, nil, err 457 } 458 - issue.Created = createdTime 459 460 - comments, err := GetComments(e, repoAt, issueId) 461 - if err != nil { 462 - return nil, nil, err 463 } 464 465 - return &issue, comments, nil 466 - } 467 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 - ) 479 return err 480 } 481 482 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 483 - var comments []Comment 484 485 - rows, err := e.Query(` 486 select 487 - owner_did, 488 - issue_id, 489 - comment_id, 490 rkey, 491 body, 492 created, 493 edited, 494 deleted 495 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 - } 507 if err != nil { 508 return nil, err 509 } 510 - defer rows.Close() 511 512 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) 517 if err != nil { 518 return nil, err 519 } 520 521 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 522 - if err != nil { 523 - return nil, err 524 } 525 - comment.Created = &createdAtTime 526 527 - if deletedAt.Valid { 528 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 529 - if err != nil { 530 - return nil, err 531 } 532 - comment.Deleted = &deletedTime 533 } 534 535 - if editedAt.Valid { 536 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 537 - if err != nil { 538 - return nil, err 539 } 540 - comment.Edited = &editedTime 541 } 542 543 - if rkey.Valid { 544 - comment.Rkey = rkey.String 545 } 546 547 comments = append(comments, comment) 548 } 549 550 - if err := rows.Err(); err != nil { 551 return nil, err 552 } 553 554 return comments, nil 555 } 556 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 572 } 573 574 - createdTime, err := time.Parse(time.RFC3339, createdAt) 575 - if err != nil { 576 - return nil, err 577 } 578 - comment.Created = &createdTime 579 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 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 594 } 595 596 - if rkey.Valid { 597 - comment.Rkey = rkey.String 598 } 599 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) 615 return err 616 } 617 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 - } 628 629 - func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error { 630 - _, err := e.Exec( 631 - ` 632 - update comments 633 - set body = ?, 634 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 635 - where owner_did = ? and rkey = ? 636 - `, newBody, ownerDid, rkey) 637 - return err 638 - } 639 640 - func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error { 641 - _, err := e.Exec( 642 - ` 643 - update comments 644 - set body = "", 645 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 646 - where owner_did = ? and rkey = ? 647 - `, ownerDid, rkey) 648 - return err 649 - } 650 - 651 - func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error { 652 - _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey) 653 - return err 654 - } 655 - 656 - func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error { 657 - _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey) 658 - 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) 668 return err 669 } 670
··· 3 import ( 4 "database/sql" 5 "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 "strings" 10 "time" 11 ··· 15 ) 16 17 type Issue struct { 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 29 30 // optionally, populate this when querying for reverse mappings 31 // like comment counts, parent repo etc. 32 + Comments []IssueComment 33 + Repo *Repo 34 } 35 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 38 } 39 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 } 48 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 103 } 104 105 func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { ··· 114 } 115 116 return Issue{ 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 124 } 125 } 126 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 + } 138 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 141 + } 142 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, 149 } 150 + } 151 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 154 } 155 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 created, err := time.Parse(time.RFC3339, record.CreatedAt) 158 if err != nil { 159 created = time.Now() 160 } 161 162 ownerDid := did 163 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 165 + return nil, err 166 } 167 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 175 } 176 177 + return &comment, nil 178 } 179 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 182 _, err := tx.Exec(` 183 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 184 values (?, 1) 185 + `, issue.RepoAt) 186 if err != nil { 187 return err 188 } 189 190 + issues, err := GetIssues( 191 + tx, 192 + FilterEq("did", issue.Did), 193 + FilterEq("rkey", issue.Rkey), 194 + ) 195 + switch { 196 + case err != nil: 197 return err 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 + } 208 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 212 } 213 + } 214 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) 224 if err != nil { 225 return err 226 } 227 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) 234 235 + return row.Scan(&issue.Id, &issue.IssueId) 236 } 237 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 246 } 247 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 250 251 var conditions []string 252 var args []any 253 + 254 for _, filter := range filters { 255 conditions = append(conditions, filter.Condition()) 256 args = append(args, filter.Arg()...) ··· 260 if conditions != nil { 261 whereClause = " where " + strings.Join(conditions, " and ") 262 } 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() 270 271 query := fmt.Sprintf( 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 291 %s 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 296 297 rows, err := e.Query(query, args...) 298 if err != nil { 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 300 } 301 defer rows.Close() 302 303 for rows.Next() { 304 var issue Issue 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 308 err := rows.Scan( 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 312 &issue.RepoAt, 313 &issue.IssueId, 314 &issue.Title, 315 &issue.Body, 316 &issue.Open, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 321 ) 322 if err != nil { 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 + } 334 } 335 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 340 } 341 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 344 } 345 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)) 350 } 351 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 353 if err != nil { 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 } 356 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 361 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) 369 } 370 + } 371 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 + } 378 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]) 383 } 384 } 385 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 389 } 390 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 394 + 395 return issues, nil 396 } 397 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 + } 401 + 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 403 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 404 row := e.QueryRow(query, repoAt, issueId) 405 406 var issue Issue 407 var createdAt string 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 409 if err != nil { 410 return nil, err 411 } ··· 419 return &issue, nil 420 } 421 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 + ) 453 if err != nil { 454 + return 0, err 455 } 456 457 + id, err := result.LastInsertId() 458 if err != nil { 459 + return 0, err 460 } 461 + 462 + return id, nil 463 + } 464 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()...) 471 } 472 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 477 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...) 481 return err 482 } 483 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 + } 498 499 + query := fmt.Sprintf(` 500 select 501 + id, 502 + did, 503 rkey, 504 + issue_at, 505 + reply_to, 506 body, 507 created, 508 edited, 509 deleted 510 from 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 516 if err != nil { 517 return nil, err 518 } 519 520 for rows.Next() { 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 + ) 535 if err != nil { 536 return nil, err 537 } 538 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 542 } 543 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 551 } 552 } 553 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 557 } 558 } 559 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 562 } 563 564 comments = append(comments, comment) 565 } 566 567 + if err = rows.Err(); err != nil { 568 return nil, err 569 } 570 571 return comments, nil 572 } 573 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()...) 580 } 581 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 585 } 586 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 591 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()...) 598 } 599 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 603 } 604 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 607 return err 608 } 609 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 + } 617 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 622 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 625 return err 626 } 627
+23 -5
appview/db/profile.go
··· 22 ByMonth []ByMonth 23 } 24 25 type ByMonth struct { 26 RepoEvents []RepoEvent 27 IssueEvents IssueEvents ··· 118 *items = append(*items, &pull) 119 } 120 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 122 if err != nil { 123 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 } ··· 137 *items = append(*items, &issue) 138 } 139 140 - repos, err := GetAllReposByDid(e, forDid) 141 if err != nil { 142 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 } ··· 535 query = `select count(id) from pulls where owner_did = ? and state = ?` 536 args = append(args, did, PullOpen) 537 case VanityStatOpenIssueCount: 538 - query = `select count(id) from issues where owner_did = ? and open = 1` 539 args = append(args, did) 540 case VanityStatClosedIssueCount: 541 - query = `select count(id) from issues where owner_did = ? and open = 0` 542 args = append(args, did) 543 case VanityStatRepositoryCount: 544 query = `select count(id) from repos where did = ?` ··· 572 } 573 574 // ensure all pinned repos are either own repos or collaborating repos 575 - repos, err := GetAllReposByDid(e, profile.Did) 576 if err != nil { 577 log.Printf("getting repos for %s: %s", profile.Did, err) 578 }
··· 22 ByMonth []ByMonth 23 } 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 + 39 type ByMonth struct { 40 RepoEvents []RepoEvent 41 IssueEvents IssueEvents ··· 132 *items = append(*items, &pull) 133 } 134 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 140 if err != nil { 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 142 } ··· 155 *items = append(*items, &issue) 156 } 157 158 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 159 if err != nil { 160 return nil, fmt.Errorf("error getting all repos by did: %w", err) 161 } ··· 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 554 args = append(args, did, PullOpen) 555 case VanityStatOpenIssueCount: 556 + query = `select count(id) from issues where did = ? and open = 1` 557 args = append(args, did) 558 case VanityStatClosedIssueCount: 559 + query = `select count(id) from issues where did = ? and open = 0` 560 args = append(args, did) 561 case VanityStatRepositoryCount: 562 query = `select count(id) from repos where did = ?` ··· 590 } 591 592 // ensure all pinned repos are either own repos or collaborating repos 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 594 if err != nil { 595 log.Printf("getting repos for %s: %s", profile.Did, err) 596 }
+4 -4
appview/db/punchcard.go
··· 29 Punches []Punch 30 } 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 64 rows, err := e.Query(query, args...) 65 if err != nil { 66 - return punchcard, err 67 } 68 defer rows.Close() 69 ··· 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 76 } 77 78 punch.Date, err = time.Parse(time.DateOnly, date)
··· 29 Punches []Punch 30 } 31 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 64 rows, err := e.Query(query, args...) 65 if err != nil { 66 + return nil, err 67 } 68 defer rows.Close() 69 ··· 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil { 75 + return nil, err 76 } 77 78 punch.Date, err = time.Parse(time.DateOnly, date)
+17 -17
appview/db/registration.go
··· 10 // Registration represents a knot registration. Knot would've been a better 11 // name but we're stuck with this for historical reasons. 12 type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - ReadOnly bool 19 } 20 21 func (r *Registration) Status() Status { 22 - if r.ReadOnly { 23 - return ReadOnly 24 } else if r.Registered != nil { 25 return Registered 26 } else { ··· 32 return r.Status() == Registered 33 } 34 35 - func (r *Registration) IsReadOnly() bool { 36 - return r.Status() == ReadOnly 37 } 38 39 func (r *Registration) IsPending() bool { ··· 45 const ( 46 Registered Status = iota 47 Pending 48 - ReadOnly 49 ) 50 51 func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { ··· 64 } 65 66 query := fmt.Sprintf(` 67 - select id, domain, did, created, registered, read_only 68 from registrations 69 %s 70 order by created ··· 80 for rows.Next() { 81 var createdAt string 82 var registeredAt sql.Null[string] 83 - var readOnly int 84 var reg Registration 85 86 - err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 87 if err != nil { 88 return nil, err 89 } ··· 98 } 99 } 100 101 - if readOnly != 0 { 102 - reg.ReadOnly = true 103 } 104 105 registrations = append(registrations, reg) ··· 116 args = append(args, filter.Arg()...) 117 } 118 119 - query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 if len(conditions) > 0 { 121 query += " where " + strings.Join(conditions, " and ") 122 }
··· 10 // Registration represents a knot registration. Knot would've been a better 11 // name but we're stuck with this for historical reasons. 12 type Registration struct { 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 19 } 20 21 func (r *Registration) Status() Status { 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 24 } else if r.Registered != nil { 25 return Registered 26 } else { ··· 32 return r.Status() == Registered 33 } 34 35 + func (r *Registration) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 37 } 38 39 func (r *Registration) IsPending() bool { ··· 45 const ( 46 Registered Status = iota 47 Pending 48 + NeedsUpgrade 49 ) 50 51 func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { ··· 64 } 65 66 query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, needs_upgrade 68 from registrations 69 %s 70 order by created ··· 80 for rows.Next() { 81 var createdAt string 82 var registeredAt sql.Null[string] 83 + var needsUpgrade int 84 var reg Registration 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 if err != nil { 88 return nil, err 89 } ··· 98 } 99 } 100 101 + if needsUpgrade != 0 { 102 + reg.NeedsUpgrade = true 103 } 104 105 registrations = append(registrations, reg) ··· 116 args = append(args, filter.Arg()...) 117 } 118 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 if len(conditions) > 0 { 121 query += " where " + strings.Join(conditions, " and ") 122 }
+19 -125
appview/db/repos.go
··· 2 3 import ( 4 "database/sql" 5 "fmt" 6 "log" 7 "slices" ··· 36 func (r Repo) DidSlashRepo() string { 37 p, _ := securejoin.SecureJoin(r.Did, r.Name) 38 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 } 74 75 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 310 311 slices.SortFunc(repos, func(a, b Repo) int { 312 if a.Created.After(b.Created) { 313 - return 1 314 } 315 - return -1 316 }) 317 318 return repos, nil 319 } 320 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 346 } 347 - defer rows.Close() 348 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 - } 375 376 - repo.RepoStats = &repoStats 377 378 - repos = append(repos, repo) 379 - } 380 - 381 - if err := rows.Err(); err != nil { 382 - return nil, err 383 } 384 385 - return repos, nil 386 } 387 388 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 570 IssueCount IssueCount 571 PullCount PullCount 572 } 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 - }
··· 2 3 import ( 4 "database/sql" 5 + "errors" 6 "fmt" 7 "log" 8 "slices" ··· 37 func (r Repo) DidSlashRepo() string { 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 39 return p 40 } 41 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 277 278 slices.SortFunc(repos, func(a, b Repo) int { 279 if a.Created.After(b.Created) { 280 + return -1 281 } 282 + return 1 283 }) 284 285 return repos, nil 286 } 287 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()...) 294 } 295 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 299 + } 300 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 304 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 307 } 308 309 + return count, nil 310 } 311 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 494 IssueCount IssueCount 495 PullCount PullCount 496 }
+14 -7
appview/db/spindle.go
··· 10 ) 11 12 type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 18 } 19 20 type SpindleMember struct { ··· 42 } 43 44 query := fmt.Sprintf( 45 - `select id, owner, instance, verified, created 46 from spindles 47 %s 48 order by created ··· 61 var spindle Spindle 62 var createdAt string 63 var verified sql.NullString 64 65 if err := rows.Scan( 66 &spindle.Id, ··· 68 &spindle.Instance, 69 &verified, 70 &createdAt, 71 ); err != nil { 72 return nil, err 73 } ··· 86 spindle.Verified = &t 87 } 88 89 spindles = append(spindles, spindle) 90 } 91 ··· 115 whereClause = " where " + strings.Join(conditions, " and ") 116 } 117 118 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 119 120 res, err := e.Exec(query, args...) 121 if err != nil {
··· 10 ) 11 12 type Spindle struct { 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + NeedsUpgrade bool 19 } 20 21 type SpindleMember struct { ··· 43 } 44 45 query := fmt.Sprintf( 46 + `select id, owner, instance, verified, created, needs_upgrade 47 from spindles 48 %s 49 order by created ··· 62 var spindle Spindle 63 var createdAt string 64 var verified sql.NullString 65 + var needsUpgrade int 66 67 if err := rows.Scan( 68 &spindle.Id, ··· 70 &spindle.Instance, 71 &verified, 72 &createdAt, 73 + &needsUpgrade, 74 ); err != nil { 75 return nil, err 76 } ··· 89 spindle.Verified = &t 90 } 91 92 + if needsUpgrade != 0 { 93 + spindle.NeedsUpgrade = true 94 + } 95 + 96 spindles = append(spindles, spindle) 97 } 98 ··· 122 whereClause = " where " + strings.Join(conditions, " and ") 123 } 124 125 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 126 127 res, err := e.Exec(query, args...) 128 if err != nil {
+26
appview/db/star.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "log" 6 "strings" ··· 181 } 182 183 return stars, nil 184 } 185 186 func GetAllStars(e Execer, limit int) ([]Star, error) {
··· 1 package db 2 3 import ( 4 + "database/sql" 5 + "errors" 6 "fmt" 7 "log" 8 "strings" ··· 183 } 184 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 210 } 211 212 func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
··· 206 return all, nil 207 } 208 209 func DeleteString(e Execer, filters ...filter) error { 210 var conditions []string 211 var args []any
··· 206 return all, nil 207 } 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 + 233 func DeleteString(e Execer, filters ...filter) error { 234 var conditions []string 235 var args []any
+12 -14
appview/db/timeline.go
··· 20 *FollowStats 21 } 22 23 - const Limit = 50 24 - 25 // TODO: this gathers heterogenous events from different sources and aggregates 26 // 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) { 28 var events []TimelineEvent 29 30 - repos, err := getTimelineRepos(e) 31 if err != nil { 32 return nil, err 33 } 34 35 - stars, err := getTimelineStars(e) 36 if err != nil { 37 return nil, err 38 } 39 40 - follows, err := getTimelineFollows(e) 41 if err != nil { 42 return nil, err 43 } ··· 51 }) 52 53 // Limit the slice to 100 events 54 - if len(events) > Limit { 55 - events = events[:Limit] 56 } 57 58 return events, nil 59 } 60 61 - func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 62 - repos, err := GetRepos(e, Limit) 63 if err != nil { 64 return nil, err 65 } ··· 104 return events, nil 105 } 106 107 - func getTimelineStars(e Execer) ([]TimelineEvent, error) { 108 - stars, err := GetStars(e, Limit) 109 if err != nil { 110 return nil, err 111 } ··· 131 return events, nil 132 } 133 134 - func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 136 if err != nil { 137 return nil, err 138 }
··· 20 *FollowStats 21 } 22 23 // TODO: this gathers heterogenous events from different sources and aggregates 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 26 var events []TimelineEvent 27 28 + repos, err := getTimelineRepos(e, limit) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit) 39 if err != nil { 40 return nil, err 41 } ··· 49 }) 50 51 // Limit the slice to 100 events 52 + if len(events) > limit { 53 + events = events[:limit] 54 } 55 56 return events, nil 57 } 58 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 61 if err != nil { 62 return nil, err 63 } ··· 102 return events, nil 103 } 104 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 107 if err != nil { 108 return nil, err 109 } ··· 129 return events, nil 130 } 131 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 134 if err != nil { 135 return nil, err 136 }
+29 -74
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 - "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/pages/markup" 19 "tangled.sh/tangled.sh/core/appview/serververify" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) ··· 27 IdResolver *idresolver.Resolver 28 Config *config.Config 29 Logger *slog.Logger 30 } 31 32 type processFunc func(ctx context.Context, e *models.Event) error ··· 790 } 791 792 switch e.Commit.Operation { 793 - case models.CommitOperationCreate: 794 raw := json.RawMessage(e.Commit.Record) 795 record := tangled.RepoIssue{} 796 err = json.Unmarshal(raw, &record) ··· 801 802 issue := db.IssueFromRecord(did, rkey, record) 803 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") 810 } 811 812 tx, err := ddb.BeginTx(ctx, nil) ··· 814 l.Error("failed to begin transaction", "err", err) 815 return err 816 } 817 818 - err = db.NewIssue(tx, &issue) 819 if err != nil { 820 l.Error("failed to create issue", "err", err) 821 return err 822 } 823 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) 830 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) 851 return err 852 } 853 854 return nil 855 856 case models.CommitOperationDelete: 857 - if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil { 858 l.Error("failed to delete", "err", err) 859 return fmt.Errorf("failed to delete issue record: %w", err) 860 } ··· 862 return nil 863 } 864 865 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 866 } 867 868 func (i *Ingester) ingestIssueComment(e *models.Event) error { ··· 880 } 881 882 switch e.Commit.Operation { 883 - case models.CommitOperationCreate: 884 raw := json.RawMessage(e.Commit.Record) 885 record := tangled.RepoIssueComment{} 886 err = json.Unmarshal(raw, &record) 887 if err != nil { 888 - l.Error("invalid record", "err", err) 889 - return err 890 } 891 892 comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 893 if err != nil { 894 - l.Error("failed to parse comment from record", "err", err) 895 - return err 896 } 897 898 - sanitizer := markup.NewSanitizer() 899 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" { 900 - return fmt.Errorf("body is empty after HTML sanitization") 901 } 902 903 - err = db.NewIssueComment(ddb, &comment) 904 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 929 } 930 931 return nil 932 933 case models.CommitOperationDelete: 934 - if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil { 935 - l.Error("failed to delete", "err", err) 936 return fmt.Errorf("failed to delete issue comment record: %w", err) 937 } 938 939 return nil 940 } 941 942 - return fmt.Errorf("unknown operation: %s", e.Commit.Operation) 943 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 "tangled.sh/tangled.sh/core/api/tangled" 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) ··· 27 IdResolver *idresolver.Resolver 28 Config *config.Config 29 Logger *slog.Logger 30 + Validator *validator.Validator 31 } 32 33 type processFunc func(ctx context.Context, e *models.Event) error ··· 791 } 792 793 switch e.Commit.Operation { 794 + case models.CommitOperationCreate, models.CommitOperationUpdate: 795 raw := json.RawMessage(e.Commit.Record) 796 record := tangled.RepoIssue{} 797 err = json.Unmarshal(raw, &record) ··· 802 803 issue := db.IssueFromRecord(did, rkey, record) 804 805 + if err := i.Validator.ValidateIssue(&issue); err != nil { 806 + return fmt.Errorf("failed to validate issue: %w", err) 807 } 808 809 tx, err := ddb.BeginTx(ctx, nil) ··· 811 l.Error("failed to begin transaction", "err", err) 812 return err 813 } 814 + defer tx.Rollback() 815 816 + err = db.PutIssue(tx, &issue) 817 if err != nil { 818 l.Error("failed to create issue", "err", err) 819 return err 820 } 821 822 + err = tx.Commit() 823 if err != nil { 824 + l.Error("failed to commit txn", "err", err) 825 return err 826 } 827 828 return nil 829 830 case models.CommitOperationDelete: 831 + if err := db.DeleteIssues( 832 + ddb, 833 + db.FilterEq("did", did), 834 + db.FilterEq("rkey", rkey), 835 + ); err != nil { 836 l.Error("failed to delete", "err", err) 837 return fmt.Errorf("failed to delete issue record: %w", err) 838 } ··· 840 return nil 841 } 842 843 + return nil 844 } 845 846 func (i *Ingester) ingestIssueComment(e *models.Event) error { ··· 858 } 859 860 switch e.Commit.Operation { 861 + case models.CommitOperationCreate, models.CommitOperationUpdate: 862 raw := json.RawMessage(e.Commit.Record) 863 record := tangled.RepoIssueComment{} 864 err = json.Unmarshal(raw, &record) 865 if err != nil { 866 + return fmt.Errorf("invalid record: %w", err) 867 } 868 869 comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 870 if err != nil { 871 + return fmt.Errorf("failed to parse comment from record: %w", err) 872 } 873 874 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 875 + return fmt.Errorf("failed to validate comment: %w", err) 876 } 877 878 + _, err = db.AddIssueComment(ddb, *comment) 879 if err != nil { 880 + return fmt.Errorf("failed to create issue comment: %w", err) 881 } 882 883 return nil 884 885 case models.CommitOperationDelete: 886 + if err := db.DeleteIssueComments( 887 + ddb, 888 + db.FilterEq("did", did), 889 + db.FilterEq("rkey", rkey), 890 + ); err != nil { 891 return fmt.Errorf("failed to delete issue comment record: %w", err) 892 } 893 894 return nil 895 } 896 897 + return nil 898 }
+477 -280
appview/issues/issues.go
··· 1 package issues 2 3 import ( 4 "fmt" 5 "log" 6 - mathrand "math/rand/v2" 7 "net/http" 8 "slices" 9 - "strconv" 10 - "strings" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 ··· 21 "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 "tangled.sh/tangled.sh/core/idresolver" 28 "tangled.sh/tangled.sh/core/tid" 29 ) 30 ··· 36 db *db.DB 37 config *config.Config 38 notifier notify.Notifier 39 } 40 41 func New( ··· 46 db *db.DB, 47 config *config.Config, 48 notifier notify.Notifier, 49 ) *Issues { 50 return &Issues{ 51 oauth: oauth, ··· 55 db: db, 56 config: config, 57 notifier: notifier, 58 } 59 } 60 61 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 62 user := rp.oauth.GetUser(r) 63 f, err := rp.repoResolver.Resolve(r) 64 if err != nil { ··· 66 return 67 } 68 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.") 81 return 82 } 83 84 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 88 } 89 90 userReactions := map[db.ReactionKind]bool{} ··· 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 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 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 - 108 OrderedReactionKinds: db.OrderedReactionKinds, 109 Reactions: reactionCountMap, 110 UserReacted: userReactions, 111 }) 112 - 113 } 114 115 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 116 user := rp.oauth.GetUser(r) 117 f, err := rp.repoResolver.Resolve(r) 118 if err != nil { ··· 120 return 121 } 122 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) 128 return 129 } 130 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 - } 137 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 { 149 150 - closed := tangled.RepoIssueStateClosed 151 152 client, err := rp.oauth.AuthorizedClient(r) 153 if err != nil { 154 - log.Println("failed to get authorized client", err) 155 return 156 } 157 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 159 Repo: user.Did, 160 - Rkey: tid.TID(), 161 Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 166 }, 167 }) 168 169 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.") 172 return 173 } 174 175 - err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 176 if err != nil { 177 log.Println("failed to close issue", err) 178 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 return 180 } 181 182 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 183 return 184 } else { 185 log.Println("user is not permitted to close issue") ··· 189 } 190 191 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 192 user := rp.oauth.GetUser(r) 193 f, err := rp.repoResolver.Resolve(r) 194 if err != nil { ··· 196 return 197 } 198 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.") 211 return 212 } 213 ··· 218 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 return user.Did == collab.Did 220 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 222 223 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 225 if err != nil { 226 log.Println("failed to reopen issue", err) 227 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 return 229 } 230 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 231 return 232 } else { 233 log.Println("user is not the owner of the repo") ··· 237 } 238 239 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 240 user := rp.oauth.GetUser(r) 241 f, err := rp.repoResolver.Resolve(r) 242 if err != nil { 243 - log.Println("failed to get repo and knot", err) 244 return 245 } 246 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) 252 return 253 } 254 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 - } 262 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 265 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 - } 279 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 - } 288 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 295 } 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 - } 315 316 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 317 return 318 } 319 } 320 321 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 322 user := rp.oauth.GetUser(r) 323 f, err := rp.repoResolver.Resolve(r) 324 if err != nil { 325 - log.Println("failed to get repo and knot", err) 326 return 327 } 328 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) 334 return 335 } 336 337 - commentId := chi.URLParam(r, "comment_id") 338 - commentIdInt, err := strconv.Atoi(commentId) 339 if err != nil { 340 - http.Error(w, "bad comment id", http.StatusBadRequest) 341 - log.Println("failed to parse issue id", err) 342 return 343 } 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.") 349 return 350 } 351 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{ 359 LoggedInUser: user, 360 RepoInfo: f.RepoInfo(user), 361 Issue: issue, 362 - Comment: comment, 363 }) 364 } 365 366 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 367 user := rp.oauth.GetUser(r) 368 f, err := rp.repoResolver.Resolve(r) 369 if err != nil { 370 - log.Println("failed to get repo and knot", err) 371 return 372 } 373 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) 379 return 380 } 381 382 - commentId := chi.URLParam(r, "comment_id") 383 - commentIdInt, err := strconv.Atoi(commentId) 384 if err != nil { 385 - http.Error(w, "bad comment id", http.StatusBadRequest) 386 - log.Println("failed to parse issue id", err) 387 return 388 } 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.") 394 return 395 } 396 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 { 404 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 405 return 406 } ··· 411 LoggedInUser: user, 412 RepoInfo: f.RepoInfo(user), 413 Issue: issue, 414 - Comment: comment, 415 }) 416 case http.MethodPost: 417 // extract form value ··· 422 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 423 return 424 } 425 - rkey := comment.Rkey 426 427 - // optimistic update 428 - edited := time.Now() 429 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 430 if err != nil { 431 log.Println("failed to perferom update-description query", err) 432 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 434 } 435 436 // rkey is optional, it was introduced later 437 - if comment.Rkey != "" { 438 // update the record on pds 439 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 440 if err != nil { 441 - // failed to get record 442 - log.Println(err, rkey) 443 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 444 return 445 } 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 453 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 454 Collection: tangled.RepoIssueCommentNSID, 455 Repo: user.Did, 456 - Rkey: rkey, 457 SwapRecord: ex.Cid, 458 Record: &lexutil.LexiconTypeDecoder{ 459 - Val: &tangled.RepoIssueComment{ 460 - Repo: &repoAt, 461 - Issue: issueAt, 462 - Owner: &comment.OwnerDid, 463 - Body: newBody, 464 - CreatedAt: createdAt, 465 - }, 466 }, 467 }) 468 if err != nil { 469 - log.Println(err) 470 } 471 } 472 473 - // optimistic update for htmx 474 - comment.Body = newBody 475 - comment.Edited = &edited 476 - 477 // return new comment body with htmx 478 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 479 LoggedInUser: user, 480 RepoInfo: f.RepoInfo(user), 481 Issue: issue, 482 - Comment: comment, 483 }) 484 return 485 486 } 487 488 } 489 490 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 491 user := rp.oauth.GetUser(r) 492 f, err := rp.repoResolver.Resolve(r) 493 if err != nil { 494 - log.Println("failed to get repo and knot", err) 495 return 496 } 497 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) 503 return 504 } 505 506 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 507 if err != nil { 508 - log.Println("failed to get issue", err) 509 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 510 return 511 } 512 513 - commentId := chi.URLParam(r, "comment_id") 514 - commentIdInt, err := strconv.Atoi(commentId) 515 if err != nil { 516 - http.Error(w, "bad comment id", http.StatusBadRequest) 517 - log.Println("failed to parse issue id", err) 518 return 519 } 520 521 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 522 if err != nil { 523 - http.Error(w, "bad comment id", http.StatusBadRequest) 524 return 525 } 526 527 - if comment.OwnerDid != user.Did { 528 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 529 return 530 } ··· 536 537 // optimistic deletion 538 deleted := time.Now() 539 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 540 if err != nil { 541 - log.Println("failed to delete comment") 542 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 543 return 544 } ··· 552 return 553 } 554 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 555 - Collection: tangled.GraphFollowNSID, 556 Repo: user.Did, 557 Rkey: comment.Rkey, 558 }) ··· 566 comment.Deleted = &deleted 567 568 // htmx fragment of comment after deletion 569 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 570 LoggedInUser: user, 571 RepoInfo: f.RepoInfo(user), 572 Issue: issue, 573 - Comment: comment, 574 }) 575 } 576 ··· 600 return 601 } 602 603 - issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 604 if err != nil { 605 log.Println("failed to get issues", err) 606 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 617 } 618 619 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 620 user := rp.oauth.GetUser(r) 621 622 f, err := rp.repoResolver.Resolve(r) 623 if err != nil { 624 - log.Println("failed to get repo and knot", err) 625 return 626 } 627 ··· 632 RepoInfo: f.RepoInfo(user), 633 }) 634 case http.MethodPost: 635 - title := r.FormValue("title") 636 - body := r.FormValue("body") 637 638 - if title == "" || body == "" { 639 - rp.pages.Notice(w, "issues", "Title and body are required") 640 return 641 } 642 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") 646 return 647 } 648 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 649 - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 650 return 651 } 652 653 tx, err := rp.db.BeginTx(r.Context(), nil) 654 if err != nil { 655 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 656 return 657 } 658 659 - issue := &db.Issue{ 660 - RepoAt: f.RepoAt(), 661 - Rkey: tid.TID(), 662 - Title: title, 663 - Body: body, 664 - OwnerDid: user.Did, 665 } 666 - err = db.NewIssue(tx, issue) 667 if err != nil { 668 log.Println("failed to create issue", err) 669 rp.pages.Notice(w, "issues", "Failed to create issue.") 670 return 671 } 672 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 { 693 log.Println("failed to create issue", err) 694 rp.pages.Notice(w, "issues", "Failed to create issue.") 695 return 696 } 697 698 rp.notifier.NewIssue(r.Context(), issue) 699 - 700 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 701 return 702 } 703 }
··· 1 package issues 2 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 "fmt" 8 "log" 9 + "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 ··· 22 "tangled.sh/tangled.sh/core/appview/notify" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 31 "tangled.sh/tangled.sh/core/tid" 32 ) 33 ··· 39 db *db.DB 40 config *config.Config 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 44 } 45 46 func New( ··· 51 db *db.DB, 52 config *config.Config, 53 notifier notify.Notifier, 54 + validator *validator.Validator, 55 ) *Issues { 56 return &Issues{ 57 oauth: oauth, ··· 61 db: db, 62 config: config, 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 66 } 67 } 68 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { ··· 75 return 76 } 77 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 82 return 83 } 84 85 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 86 if err != nil { 87 + l.Error("failed to get issue reactions", "err", err) 88 } 89 90 userReactions := map[db.ReactionKind]bool{} ··· 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 94 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 100 OrderedReactionKinds: db.OrderedReactionKinds, 101 Reactions: reactionCountMap, 102 UserReacted: userReactions, 103 }) 104 } 105 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 108 user := rp.oauth.GetUser(r) 109 f, err := rp.repoResolver.Resolve(r) 110 if err != nil { ··· 112 return 113 } 114 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 119 return 120 } 121 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") 134 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 + } 140 141 + newRecord := newIssue.AsRecord() 142 143 + // edit an atproto record 144 client, err := rp.oauth.AuthorizedClient(r) 145 if err != nil { 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 148 return 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 + 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 159 + Collection: tangled.RepoIssueNSID, 160 Repo: user.Did, 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 163 Record: &lexutil.LexiconTypeDecoder{ 164 + Val: &newRecord, 165 }, 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 + } 172 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 175 if err != nil { 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.") 192 return 193 } 194 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 + ) 280 if err != nil { 281 log.Println("failed to close issue", err) 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 283 return 284 } 285 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 287 return 288 } else { 289 log.Println("user is not permitted to close issue") ··· 293 } 294 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 297 user := rp.oauth.GetUser(r) 298 f, err := rp.repoResolver.Resolve(r) 299 if err != nil { ··· 301 return 302 } 303 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 308 return 309 } 310 ··· 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 316 return user.Did == collab.Did 317 }) 318 + isIssueOwner := user.Did == issue.Did 319 320 if isCollaborator || isIssueOwner { 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 325 if err != nil { 326 log.Println("failed to reopen issue", err) 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 328 return 329 } 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 331 return 332 } else { 333 log.Println("user is not the owner of the repo") ··· 337 } 338 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 341 user := rp.oauth.GetUser(r) 342 f, err := rp.repoResolver.Resolve(r) 343 if err != nil { 344 + l.Error("failed to get repo and knot", "err", err) 345 return 346 } 347 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 352 return 353 } 354 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 360 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 366 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() 381 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 + } 388 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) 407 } 408 + }() 409 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.") 414 return 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)) 420 } 421 422 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 424 user := rp.oauth.GetUser(r) 425 f, err := rp.repoResolver.Resolve(r) 426 if err != nil { 427 + l.Error("failed to get repo and knot", "err", err) 428 return 429 } 430 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 435 return 436 } 437 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 443 if err != nil { 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 446 return 447 } 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) 451 return 452 } 453 + comment := comments[0] 454 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 456 LoggedInUser: user, 457 RepoInfo: f.RepoInfo(user), 458 Issue: issue, 459 + Comment: &comment, 460 }) 461 } 462 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 465 user := rp.oauth.GetUser(r) 466 f, err := rp.repoResolver.Resolve(r) 467 if err != nil { 468 + l.Error("failed to get repo and knot", "err", err) 469 return 470 } 471 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 476 return 477 } 478 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 484 if err != nil { 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 487 return 488 } 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) 492 return 493 } 494 + comment := comments[0] 495 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 499 return 500 } ··· 505 LoggedInUser: user, 506 RepoInfo: f.RepoInfo(user), 507 Issue: issue, 508 + Comment: &comment, 509 }) 510 case http.MethodPost: 511 // extract form value ··· 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 517 return 518 } 519 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) 527 if err != nil { 528 log.Println("failed to perferom update-description query", err) 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 531 } 532 533 // rkey is optional, it was introduced later 534 + if newComment.Rkey != "" { 535 // update the record on pds 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 537 if err != nil { 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 540 return 541 } 542 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 544 Collection: tangled.RepoIssueCommentNSID, 545 Repo: user.Did, 546 + Rkey: newComment.Rkey, 547 SwapRecord: ex.Cid, 548 Record: &lexutil.LexiconTypeDecoder{ 549 + Val: &record, 550 }, 551 }) 552 if err != nil { 553 + l.Error("failed to update record on PDS", "err", err) 554 } 555 } 556 557 // return new comment body with htmx 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 559 LoggedInUser: user, 560 RepoInfo: f.RepoInfo(user), 561 Issue: issue, 562 + Comment: &newComment, 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) 573 return 574 + } 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 581 } 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 + }) 606 } 607 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 610 user := rp.oauth.GetUser(r) 611 f, err := rp.repoResolver.Resolve(r) 612 if err != nil { 613 + l.Error("failed to get repo and knot", "err", err) 614 return 615 } 616 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 621 return 622 } 623 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 629 if err != nil { 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 632 return 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] 640 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) 653 if err != nil { 654 + l.Error("failed to get repo and knot", "err", err) 655 return 656 } 657 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 + ) 670 if err != nil { 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 673 return 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] 681 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 685 return 686 } ··· 692 693 // optimistic deletion 694 deleted := time.Now() 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 696 if err != nil { 697 + l.Error("failed to delete comment", "err", err) 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 699 return 700 } ··· 708 return 709 } 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 711 + Collection: tangled.RepoIssueCommentNSID, 712 Repo: user.Did, 713 Rkey: comment.Rkey, 714 }) ··· 722 comment.Deleted = &deleted 723 724 // htmx fragment of comment after deletion 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 726 LoggedInUser: user, 727 RepoInfo: f.RepoInfo(user), 728 Issue: issue, 729 + Comment: &comment, 730 }) 731 } 732 ··· 756 return 757 } 758 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 + ) 769 if err != nil { 770 log.Println("failed to get issues", err) 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 782 } 783 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 786 user := rp.oauth.GetUser(r) 787 788 f, err := rp.repoResolver.Resolve(r) 789 if err != nil { 790 + l.Error("failed to get repo and knot", "err", err) 791 return 792 } 793 ··· 798 RepoInfo: f.RepoInfo(user), 799 }) 800 case http.MethodPost: 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 + } 809 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)) 813 return 814 } 815 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.") 823 return 824 } 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.") 836 return 837 } 838 + atUri := resp.Uri 839 840 tx, err := rp.db.BeginTx(r.Context(), nil) 841 if err != nil { 842 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 843 return 844 } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 848 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 + } 856 } 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 860 if err != nil { 861 log.Println("failed to create issue", err) 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 863 return 864 } 865 866 + if err = tx.Commit(); err != nil { 867 log.Println("failed to create issue", err) 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 869 return 870 } 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 874 rp.notifier.NewIssue(r.Context(), issue) 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 876 return 877 } 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 13 r.Route("/", func(r chi.Router) { 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 16 17 r.Group(func(r chi.Router) { 18 r.Use(middleware.AuthMiddleware(i.oauth)) 19 r.Get("/new", i.NewIssue) 20 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 }) 31 }) 32
··· 12 13 r.Route("/", func(r chi.Router) { 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 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 + }) 39 40 r.Group(func(r chi.Router) { 41 r.Use(middleware.AuthMiddleware(i.oauth)) 42 r.Get("/new", i.NewIssue) 43 r.Post("/new", i.NewIssue) 44 }) 45 }) 46
+5 -34
appview/knots/knots.go
··· 3 import ( 4 "errors" 5 "fmt" 6 - "log" 7 "log/slog" 8 "net/http" 9 "slices" ··· 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/serververify" 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 "tangled.sh/tangled.sh/core/idresolver" 22 "tangled.sh/tangled.sh/core/rbac" ··· 49 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 - 53 - r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 54 55 return r 56 } ··· 399 if err != nil { 400 l.Error("verification failed", "err", err) 401 402 - if errors.Is(err, serververify.FetchError) { 403 - k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 return 405 } 406 ··· 420 return 421 } 422 423 - // if this knot was previously read-only, then emit a record too 424 // 425 // this is part of migrating from the old knot system to the new one 426 - if registration.ReadOnly { 427 // re-announce by registering under same rkey 428 client, err := k.OAuth.AuthorizedClient(r) 429 if err != nil { ··· 484 return 485 } 486 updatedRegistration := registrations[0] 487 - 488 - log.Println(updatedRegistration) 489 490 w.Header().Set("HX-Reswap", "outerHTML") 491 k.Pages.KnotListing(w, pages.KnotListingParams{ ··· 678 // ok 679 k.Pages.HxRefresh(w) 680 } 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 - }
··· 3 import ( 4 "errors" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "slices" ··· 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 "tangled.sh/tangled.sh/core/idresolver" 22 "tangled.sh/tangled.sh/core/rbac" ··· 49 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 53 return r 54 } ··· 397 if err != nil { 398 l.Error("verification failed", "err", err) 399 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!") 402 return 403 } 404 ··· 418 return 419 } 420 421 + // if this knot requires upgrade, then emit a record too 422 // 423 // this is part of migrating from the old knot system to the new one 424 + if registration.NeedsUpgrade { 425 // re-announce by registering under same rkey 426 client, err := k.OAuth.AuthorizedClient(r) 427 if err != nil { ··· 482 return 483 } 484 updatedRegistration := registrations[0] 485 486 w.Header().Set("HX-Reswap", "outerHTML") 487 k.Pages.KnotListing(w, pages.KnotListingParams{ ··· 674 // ok 675 k.Pages.HxRefresh(w) 676 }
+40
appview/middleware/middleware.go
··· 275 } 276 } 277 278 // this should serve the go-import meta tag even if the path is technically 279 // a 404 like tangled.sh/oppi.li/go-git/v5 280 func (mw Middleware) GoImport() middlewareFunc {
··· 275 } 276 } 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 + 318 // this should serve the go-import meta tag even if the path is technically 319 // a 404 like tangled.sh/oppi.li/go-git/v5 320 func (mw Middleware) GoImport() middlewareFunc {
+18
appview/notify/merged_notifier.go
··· 66 notifier.UpdateProfile(ctx, profile) 67 } 68 }
··· 66 notifier.UpdateProfile(ctx, profile) 67 } 68 } 69 + 70 + func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) { 71 + for _, notifier := range m.notifiers { 72 + notifier.NewString(ctx, string) 73 + } 74 + } 75 + 76 + func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) { 77 + for _, notifier := range m.notifiers { 78 + notifier.EditString(ctx, string) 79 + } 80 + } 81 + 82 + func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 + for _, notifier := range m.notifiers { 84 + notifier.DeleteString(ctx, did, rkey) 85 + } 86 + }
+8
appview/notify/notifier.go
··· 21 NewPullComment(ctx context.Context, comment *db.PullComment) 22 23 UpdateProfile(ctx context.Context, profile *db.Profile) 24 } 25 26 // BaseNotifier is a listener that does nothing ··· 42 func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 43 44 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
··· 21 NewPullComment(ctx context.Context, comment *db.PullComment) 22 23 UpdateProfile(ctx context.Context, profile *db.Profile) 24 + 25 + NewString(ctx context.Context, s *db.String) 26 + EditString(ctx context.Context, s *db.String) 27 + DeleteString(ctx context.Context, did, rkey string) 28 } 29 30 // BaseNotifier is a listener that does nothing ··· 46 func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 47 48 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 49 + 50 + func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {} 51 + func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {} 52 + func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+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 "split": func(s string) []string { 30 return strings.Split(s, "\n") 31 }, 32 "resolve": func(s string) string { 33 identity, err := p.resolver.ResolveIdent(context.Background(), s) 34
··· 29 "split": func(s string) []string { 30 return strings.Split(s, "\n") 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 35 "resolve": func(s string) string { 36 identity, err := p.resolver.ResolveIdent(context.Background(), s) 37
+12
appview/pages/markup/format.go
··· 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 func GetFormat(filename string) Format { 17 for format, extensions := range FileTypes { 18 for _, extension := range extensions {
··· 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 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 + 28 func GetFormat(filename string) Format { 29 for format, extensions := range FileTypes { 30 for _, extension := range extensions {
+10 -8
appview/pages/markup/markdown.go
··· 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 ) 27 ··· 231 232 actualPath := rctx.actualPath(dst) 233 234 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), 243 } 244 newPath := parsedURL.String() 245 return newPath
··· 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 ) 28 ··· 232 233 actualPath := rctx.actualPath(dst) 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 + 240 parsedURL := &url.URL{ 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 245 } 246 newPath := parsedURL.String() 247 return newPath
+270 -190
appview/pages/pages.go
··· 9 "html/template" 10 "io" 11 "io/fs" 12 - "log" 13 "net/http" 14 "os" 15 "path/filepath" ··· 42 var Files embed.FS 43 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver 50 dev bool 51 - embedFS embed.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 } 55 56 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 64 65 p := &Pages{ 66 mu: sync.RWMutex{}, 67 - t: make(map[string]*template.Template), 68 dev: config.Core.Dev, 69 avatar: config.Avatar, 70 - embedFS: Files, 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 } 75 76 - // Initial load of all templates 77 - p.loadAllTemplates() 78 79 return p 80 } 81 82 - func (p *Pages) loadAllTemplates() { 83 - templates := make(map[string]*template.Template) 84 - var fragmentPaths []string 85 86 - // Use embedded FS for initial loading 87 - // First, collect all fragment paths 88 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 89 if err != nil { 90 return err ··· 98 if !strings.Contains(path, "fragments/") { 99 return nil 100 } 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 fragmentPaths = append(fragmentPaths, path) 111 - log.Printf("loaded fragment: %s", name) 112 return nil 113 }) 114 if err != nil { 115 - log.Fatalf("walking template dir for fragments: %v", err) 116 } 117 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 - }) 154 if err != nil { 155 - log.Fatalf("walking template dir: %v", err) 156 } 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 168 } 169 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 - }) 190 if err != nil { 191 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 192 } 193 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 - } 199 200 - // Create a new template 201 - tmpl := template.New(name).Funcs(p.funcMap()) 202 203 - // Parse layouts 204 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 205 - layouts, err := filepath.Glob(layoutGlob) 206 if err != nil { 207 - return fmt.Errorf("finding layout templates: %w", err) 208 } 209 210 - // Create paths for parsing 211 - allFiles := append(layouts, fragmentPaths...) 212 - allFiles = append(allFiles, templatePath) 213 214 - // Parse all templates 215 - tmpl, err = tmpl.ParseFiles(allFiles...) 216 - if err != nil { 217 - return fmt.Errorf("parsing template files: %w", err) 218 } 219 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 226 } 227 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 - } 235 } 236 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) 242 } 243 244 - if base == "" { 245 - return tmpl.Execute(w, params) 246 - } else { 247 - return tmpl.ExecuteTemplate(w, base, params) 248 - } 249 } 250 251 func (p *Pages) execute(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "layouts/base", params) 253 - } 254 255 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "", params) 257 } 258 259 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 260 - return p.executeOrReload(name, w, "layouts/repobase", params) 261 } 262 263 func (p *Pages) Favicon(w io.Writer) error { ··· 282 283 type TermsOfServiceParams struct { 284 LoggedInUser *oauth.User 285 } 286 287 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 288 return p.execute("legal/terms", w, params) 289 } 290 291 type PrivacyPolicyParams struct { 292 LoggedInUser *oauth.User 293 } 294 295 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 296 return p.execute("legal/privacy", w, params) 297 } 298 ··· 338 return p.execute("user/settings/emails", w, params) 339 } 340 341 - type KnotBannerParams struct { 342 Registrations []db.Registration 343 } 344 345 - func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 - return p.executePlain("knots/fragments/banner", w, params) 347 } 348 349 type KnotsParams struct { ··· 422 return p.execute("repo/fork", w, params) 423 } 424 425 - type ProfileHomePageParams struct { 426 LoggedInUser *oauth.User 427 Repos []db.Repo 428 CollaboratingRepos []db.Repo 429 ProfileTimeline *db.ProfileTimeline 430 - Card ProfileCard 431 - Punchcard db.Punchcard 432 } 433 434 - type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount int 440 441 - Profile *db.Profile 442 } 443 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 - return p.execute("user/profile", w, params) 446 } 447 448 - type ReposPageParams struct { 449 LoggedInUser *oauth.User 450 Repos []db.Repo 451 - Card ProfileCard 452 } 453 454 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 - return p.execute("user/repos", w, params) 456 } 457 458 type FollowCard struct { 459 UserDid string 460 FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 463 Profile *db.Profile 464 } 465 466 - type FollowersPageParams struct { 467 LoggedInUser *oauth.User 468 Followers []FollowCard 469 - Card ProfileCard 470 } 471 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 474 } 475 476 - type FollowingPageParams struct { 477 LoggedInUser *oauth.User 478 Following []FollowCard 479 - Card ProfileCard 480 } 481 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 484 } 485 486 type FollowFragmentParams struct { ··· 553 VerifiedCommits commitverify.VerifiedCommits 554 Languages []types.RepoLanguageDetails 555 Pipelines map[string]db.Pipeline 556 types.RepoIndexResponse 557 } 558 ··· 562 return p.executeRepo("repo/empty", w, params) 563 } 564 565 p.rctx.RepoInfo = params.RepoInfo 566 p.rctx.RepoInfo.Ref = params.Ref 567 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 649 650 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 651 params.Active = "overview" 652 - return p.execute("repo/tree", w, params) 653 } 654 655 type RepoBranchesParams struct { ··· 700 ShowRendered bool 701 RenderToggle bool 702 RenderedContents template.HTML 703 - types.RepoBlobResponse 704 } 705 706 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 835 RepoInfo repoinfo.RepoInfo 836 Active string 837 Issue *db.Issue 838 - Comments []db.Comment 839 IssueOwnerHandle string 840 841 OrderedReactionKinds []db.ReactionKind 842 Reactions map[db.ReactionKind]int 843 UserReacted map[db.ReactionKind]bool 844 845 - State string 846 } 847 848 type ThreadReactionFragmentParams struct { ··· 856 return p.executePlain("repo/fragments/reaction", w, params) 857 } 858 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 type RepoNewIssueParams struct { 870 LoggedInUser *oauth.User 871 RepoInfo repoinfo.RepoInfo 872 Active string 873 } 874 875 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 876 params.Active = "issues" 877 return p.executeRepo("repo/issues/new", w, params) 878 } 879 ··· 881 LoggedInUser *oauth.User 882 RepoInfo repoinfo.RepoInfo 883 Issue *db.Issue 884 - Comment *db.Comment 885 } 886 887 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 888 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 889 } 890 891 - type SingleIssueCommentParams struct { 892 LoggedInUser *oauth.User 893 RepoInfo repoinfo.RepoInfo 894 Issue *db.Issue 895 - Comment *db.Comment 896 } 897 898 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 899 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 900 } 901 902 type RepoNewPullParams struct { ··· 1262 return p.execute("strings/string", w, params) 1263 } 1264 1265 func (p *Pages) Static() http.Handler { 1266 if p.dev { 1267 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1269 1270 sub, err := fs.Sub(Files, "static") 1271 if err != nil { 1272 - log.Fatalf("no static dir found? that's crazy: %v", err) 1273 } 1274 // Custom handler to apply Cache-Control headers for font files 1275 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1292 func CssContentHash() string { 1293 cssFile, err := Files.Open("static/tw.css") 1294 if err != nil { 1295 - log.Printf("Error opening CSS file: %v", err) 1296 return "" 1297 } 1298 defer cssFile.Close() 1299 1300 hasher := sha256.New() 1301 if _, err := io.Copy(hasher, cssFile); err != nil { 1302 - log.Printf("Error hashing CSS file: %v", err) 1303 return "" 1304 } 1305
··· 9 "html/template" 10 "io" 11 "io/fs" 12 + "log/slog" 13 "net/http" 14 "os" 15 "path/filepath" ··· 42 var Files embed.FS 43 44 type Pages struct { 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver 50 dev bool 51 + embedFS fs.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 + logger *slog.Logger 55 } 56 57 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 65 66 p := &Pages{ 67 mu: sync.RWMutex{}, 68 + cache: NewTmplCache[string, *template.Template](), 69 dev: config.Core.Dev, 70 avatar: config.Avatar, 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 75 } 76 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 82 83 return p 84 } 85 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 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 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 if err != nil { 99 return err ··· 107 if !strings.Contains(path, "fragments/") { 108 return nil 109 } 110 fragmentPaths = append(fragmentPaths, path) 111 return nil 112 }) 113 if err != nil { 114 + return nil, err 115 } 116 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() 123 if err != nil { 124 + return nil, err 125 } 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 128 } 129 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 135 if err != nil { 136 + return nil, err 137 } 138 139 + return parsed, nil 140 + } 141 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 144 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...) 151 if err != nil { 152 + return nil, err 153 } 154 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 158 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 163 } 164 + return p.parse(stack...) 165 + } 166 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...) 174 } 175 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 181 } 182 + return p.parse(stack...) 183 + } 184 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 189 } 190 191 + return tpl.Execute(w, params) 192 } 193 194 func (p *Pages) execute(name string, w io.Writer, params any) error { 195 + tpl, err := p.parseBase(name) 196 + if err != nil { 197 + return err 198 + } 199 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 201 } 202 203 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 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) 219 } 220 221 func (p *Pages) Favicon(w io.Writer) error { ··· 240 241 type TermsOfServiceParams struct { 242 LoggedInUser *oauth.User 243 + Content template.HTML 244 } 245 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 + 259 return p.execute("legal/terms", w, params) 260 } 261 262 type PrivacyPolicyParams struct { 263 LoggedInUser *oauth.User 264 + Content template.HTML 265 } 266 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 + 280 return p.execute("legal/privacy", w, params) 281 } 282 ··· 322 return p.execute("user/settings/emails", w, params) 323 } 324 325 + type UpgradeBannerParams struct { 326 Registrations []db.Registration 327 + Spindles []db.Spindle 328 } 329 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 332 } 333 334 type KnotsParams struct { ··· 407 return p.execute("repo/fork", w, params) 408 } 409 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 { 440 LoggedInUser *oauth.User 441 Repos []db.Repo 442 CollaboratingRepos []db.Repo 443 ProfileTimeline *db.ProfileTimeline 444 + Card *ProfileCard 445 + Active string 446 } 447 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 451 + } 452 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 458 } 459 460 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 461 + params.Active = "repos" 462 + return p.executeProfile("user/repos", w, params) 463 } 464 465 + type ProfileStarredParams struct { 466 LoggedInUser *oauth.User 467 Repos []db.Repo 468 + Card *ProfileCard 469 + Active string 470 } 471 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) 487 } 488 489 type FollowCard struct { 490 UserDid string 491 FollowStatus db.FollowStatus 492 + FollowersCount int64 493 + FollowingCount int64 494 Profile *db.Profile 495 } 496 497 + type ProfileFollowersParams struct { 498 LoggedInUser *oauth.User 499 Followers []FollowCard 500 + Card *ProfileCard 501 + Active string 502 } 503 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 507 } 508 509 + type ProfileFollowingParams struct { 510 LoggedInUser *oauth.User 511 Following []FollowCard 512 + Card *ProfileCard 513 + Active string 514 } 515 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 519 } 520 521 type FollowFragmentParams struct { ··· 588 VerifiedCommits commitverify.VerifiedCommits 589 Languages []types.RepoLanguageDetails 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 592 types.RepoIndexResponse 593 } 594 ··· 598 return p.executeRepo("repo/empty", w, params) 599 } 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 605 p.rctx.RepoInfo = params.RepoInfo 606 p.rctx.RepoInfo.Ref = params.Ref 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 689 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 params.Active = "overview" 692 + return p.executeRepo("repo/tree", w, params) 693 } 694 695 type RepoBranchesParams struct { ··· 740 ShowRendered bool 741 RenderToggle bool 742 RenderedContents template.HTML 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 749 } 750 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 880 RepoInfo repoinfo.RepoInfo 881 Active string 882 Issue *db.Issue 883 + CommentList []db.CommentListItem 884 IssueOwnerHandle string 885 886 OrderedReactionKinds []db.ReactionKind 887 Reactions map[db.ReactionKind]int 888 UserReacted map[db.ReactionKind]bool 889 + } 890 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) 906 } 907 908 type ThreadReactionFragmentParams struct { ··· 916 return p.executePlain("repo/fragments/reaction", w, params) 917 } 918 919 type RepoNewIssueParams struct { 920 LoggedInUser *oauth.User 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 923 Active string 924 + Action string 925 } 926 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 928 params.Active = "issues" 929 + params.Action = "create" 930 return p.executeRepo("repo/issues/new", w, params) 931 } 932 ··· 934 LoggedInUser *oauth.User 935 RepoInfo repoinfo.RepoInfo 936 Issue *db.Issue 937 + Comment *db.IssueComment 938 } 939 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 942 } 943 944 + type ReplyIssueCommentPlaceholderParams struct { 945 LoggedInUser *oauth.User 946 RepoInfo repoinfo.RepoInfo 947 Issue *db.Issue 948 + Comment *db.IssueComment 949 } 950 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) 975 } 976 977 type RepoNewPullParams struct { ··· 1337 return p.execute("strings/string", w, params) 1338 } 1339 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1344 func (p *Pages) Static() http.Handler { 1345 if p.dev { 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1348 1349 sub, err := fs.Sub(Files, "static") 1350 if err != nil { 1351 + p.logger.Error("no static dir found? that's crazy", "err", err) 1352 + panic(err) 1353 } 1354 // Custom handler to apply Cache-Control headers for font files 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1372 func CssContentHash() string { 1373 cssFile, err := Files.Open("static/tw.css") 1374 if err != nil { 1375 + slog.Debug("Error opening CSS file", "err", err) 1376 return "" 1377 } 1378 defer cssFile.Close() 1379 1380 hasher := sha256.New() 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1382 + slog.Debug("Error hashing CSS file", "err", err) 1383 return "" 1384 } 1385
+2 -7
appview/pages/repoinfo/repoinfo.go
··· 78 func (r RepoInfo) TabMetadata() map[string]any { 79 meta := make(map[string]any) 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 - } 88 89 // more stuff? 90
··· 78 func (r RepoInfo) TabMetadata() map[string]any { 79 meta := make(map[string]any) 80 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 83 84 // more stuff? 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 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 go back 23 </a>
··· 17 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 go back 23 </a>
+4 -4
appview/pages/templates/errors/500.html
··· 8 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 - 12 <div class="space-y-4"> 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 500 &mdash; internal server error ··· 24 <p class="mt-1">Our team has been automatically notified about this error.</p> 25 </div> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 32 {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> ··· 36 </div> 37 </div> 38 </div> 39 - {{ end }}
··· 8 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 + 12 <div class="space-y-4"> 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 500 &mdash; internal server error ··· 24 <p class="mt-1">Our team has been automatically notified about this error.</p> 25 </div> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create gap-2"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> ··· 36 </div> 37 </div> 38 </div> 39 + {{ end }}
+2 -2
appview/pages/templates/errors/503.html
··· 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> 25 {{ i "arrow-left" "w-4 h-4" }} 26 back to timeline 27 </a>
··· 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 {{ i "arrow-left" "w-4 h-4" }} 26 back to timeline 27 </a>
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 back to timeline 23 </a>
··· 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 back to timeline 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 }}
+90
appview/pages/templates/fragments/multiline-select.html
···
··· 1 + {{ define "fragments/multiline-select" }} 2 + <script> 3 + function highlight(scroll = false) { 4 + document.querySelectorAll(".hl").forEach(el => { 5 + el.classList.remove("hl"); 6 + }); 7 + 8 + const hash = window.location.hash; 9 + if (!hash || !hash.startsWith("#L")) { 10 + return; 11 + } 12 + 13 + const rangeStr = hash.substring(2); 14 + const parts = rangeStr.split("-"); 15 + let startLine, endLine; 16 + 17 + if (parts.length === 2) { 18 + startLine = parseInt(parts[0], 10); 19 + endLine = parseInt(parts[1], 10); 20 + } else { 21 + startLine = parseInt(parts[0], 10); 22 + endLine = startLine; 23 + } 24 + 25 + if (isNaN(startLine) || isNaN(endLine)) { 26 + console.log("nan"); 27 + console.log(startLine); 28 + console.log(endLine); 29 + return; 30 + } 31 + 32 + let target = null; 33 + 34 + for (let i = startLine; i<= endLine; i++) { 35 + const idEl = document.getElementById(`L${i}`); 36 + if (idEl) { 37 + const el = idEl.closest(".line"); 38 + if (el) { 39 + el.classList.add("hl"); 40 + target = el; 41 + } 42 + } 43 + } 44 + 45 + if (scroll && target) { 46 + target.scrollIntoView({ 47 + behavior: "smooth", 48 + block: "center", 49 + }); 50 + } 51 + } 52 + 53 + document.addEventListener("DOMContentLoaded", () => { 54 + console.log("DOMContentLoaded"); 55 + highlight(true); 56 + }); 57 + window.addEventListener("hashchange", () => { 58 + console.log("hashchange"); 59 + highlight(); 60 + }); 61 + window.addEventListener("popstate", () => { 62 + console.log("popstate"); 63 + highlight(); 64 + }); 65 + 66 + const lineNumbers = document.querySelectorAll('a[href^="#L"'); 67 + let startLine = null; 68 + 69 + lineNumbers.forEach(el => { 70 + el.addEventListener("click", (event) => { 71 + event.preventDefault(); 72 + const currentLine = parseInt(el.href.split("#L")[1]); 73 + 74 + if (event.shiftKey && startLine !== null) { 75 + const endLine = currentLine; 76 + const min = Math.min(startLine, endLine); 77 + const max = Math.max(startLine, endLine); 78 + const newHash = `#L${min}-${max}`; 79 + history.pushState(null, '', newHash); 80 + } else { 81 + const newHash = `#L${currentLine}`; 82 + history.pushState(null, '', newHash); 83 + startLine = currentLine; 84 + } 85 + 86 + highlight(); 87 + }); 88 + }); 89 + </script> 90 + {{ 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 </span> 37 {{ template "knots/fragments/addMemberModal" . }} 38 {{ block "knotDeleteButton" . }} {{ end }} 39 - {{ else if .IsReadOnly }} 40 <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 - {{ i "shield-alert" "w-4 h-4" }} read-only 42 </span> 43 {{ block "knotRetryButton" . }} {{ end }} 44 {{ block "knotDeleteButton" . }} {{ end }}
··· 36 </span> 37 {{ template "knots/fragments/addMemberModal" . }} 38 {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsNeedsUpgrade }} 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" }} needs upgrade 42 </span> 43 {{ block "knotRetryButton" . }} {{ end }} 44 {{ block "knotDeleteButton" . }} {{ end }}
+12 -10
appview/pages/templates/knots/index.html
··· 1 {{ define "title" }}knots{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 </div> 7 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 {{ end }} 16 17 {{ 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> 26 </p> 27 - </section> 28 {{ end }} 29 30 {{ define "list" }}
··· 1 {{ define "title" }}knots{{ end }} 2 3 {{ define "content" }} 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">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> 10 </div> 11 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 19 {{ end }} 20 21 {{ define "about" }} 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 </p> 27 + 28 + 29 + </section> 30 {{ end }} 31 32 {{ define "list" }}
+27 -12
appview/pages/templates/layouts/base.html
··· 3 <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 <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> 13 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 16 </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"> 18 {{ 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" . }} 21 </header> 22 {{ end }} 23 24 {{ block "mainLayout" . }} 25 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 {{ block "contentLayout" . }} 27 <main class="col-span-1 md:col-span-8"> 28 {{ block "content" . }}{{ end }} ··· 38 {{ end }} 39 40 {{ block "footerLayout" . }} 41 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 - {{ template "layouts/footer" . }} 43 </footer> 44 {{ end }} 45 </body>
··· 3 <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 + <meta name="description" content="Social coding, but for real this time!"/> 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 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 + 20 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </head> 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"> 25 {{ block "topbarLayout" . }} 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" . }} 36 </header> 37 {{ end }} 38 39 {{ block "mainLayout" . }} 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"> 41 {{ block "contentLayout" . }} 42 <main class="col-span-1 md:col-span-8"> 43 {{ block "content" . }}{{ end }} ··· 53 {{ end }} 54 55 {{ block "footerLayout" . }} 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" . }} 58 </footer> 59 {{ end }} 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 }}
+109
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 px-2 py-6 md: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 + {{ $style := "hidden md:block md:col-span-3" }} 15 + {{ if eq $.Active "overview" }} 16 + {{ $style = "md:col-span-3" }} 17 + {{ end }} 18 + <div class="{{ $style }} order-1 order-1"> 19 + <div class="flex flex-col gap-4"> 20 + {{ template "user/fragments/profileCard" .Card }} 21 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 22 + </div> 23 + </div> 24 + 25 + {{ block "profileContent" . }} {{ end }} 26 + </div> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "profileTabs" }} 31 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 32 + <div class="flex z-60"> 33 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 34 + {{ $tabs := .Card.GetTabs }} 35 + {{ $tabmeta := dict "x" "y" }} 36 + {{ range $item := $tabs }} 37 + {{ $key := index $item 0 }} 38 + {{ $value := index $item 1 }} 39 + {{ $icon := index $item 2 }} 40 + {{ $meta := index $item 3 }} 41 + <a 42 + href="?tab={{ $value }}" 43 + class="relative -mr-px group no-underline hover:no-underline" 44 + hx-boost="true"> 45 + <div 46 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 47 + {{ if eq $.Active $key }} 48 + {{ $activeTabStyles }} 49 + {{ else }} 50 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 + {{ end }} 52 + "> 53 + <span class="flex items-center justify-center"> 54 + {{ i $icon "w-4 h-4 mr-2" }} 55 + {{ $key }} 56 + {{ if $meta }} 57 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + </a> 62 + {{ end }} 63 + </div> 64 + </nav> 65 + {{ end }} 66 + 67 + {{ define "punchcard" }} 68 + {{ $now := now }} 69 + <div> 70 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 71 + PUNCHCARD 72 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 73 + {{ .Total | int64 | commaFmt }} commits 74 + </span> 75 + </p> 76 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 77 + {{ range .Punches }} 78 + {{ $count := .Count }} 79 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 80 + {{ if lt $count 1 }} 81 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 82 + {{ else if lt $count 2 }} 83 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 84 + {{ else if lt $count 4 }} 85 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 86 + {{ else if lt $count 8 }} 87 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 88 + {{ else }} 89 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 90 + {{ end }} 91 + 92 + {{ if .Date.After $now }} 93 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 94 + {{ end }} 95 + <div class="w-full h-full flex justify-center items-center"> 96 + <div 97 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 98 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 99 + </div> 100 + </div> 101 + {{ end }} 102 + </div> 103 + </div> 104 + {{ end }} 105 + 106 + {{ define "layouts/profilebase" }} 107 + {{ template "layouts/base" . }} 108 + {{ end }} 109 +
+4 -8
appview/pages/templates/layouts/repobase.html
··· 42 </section> 43 44 <section 45 - class="w-full flex flex-col drop-shadow-sm" 46 > 47 <nav class="w-full pl-4 overflow-auto"> 48 <div class="flex z-60"> ··· 71 <span class="flex items-center justify-center"> 72 {{ i $icon "w-4 h-4 mr-2" }} 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> 76 {{ end }} 77 </span> 78 </div> ··· 81 </div> 82 </nav> 83 <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 85 > 86 {{ block "repoContent" . }}{{ end }} 87 </section> 88 {{ block "repoAfter" . }}{{ end }} 89 </section> 90 {{ end }} 91 - 92 - {{ define "layouts/repobase" }} 93 - {{ template "layouts/base" . }} 94 - {{ end }}
··· 42 </section> 43 44 <section 45 + class="w-full flex flex-col" 46 > 47 <nav class="w-full pl-4 overflow-auto"> 48 <div class="flex z-60"> ··· 71 <span class="flex items-center justify-center"> 72 {{ i $icon "w-4 h-4 mr-2" }} 73 {{ $key }} 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 {{ end }} 77 </span> 78 </div> ··· 81 </div> 82 </nav> 83 <section 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 > 86 {{ block "repoContent" . }}{{ end }} 87 </section> 88 {{ block "repoAfter" . }}{{ end }} 89 </section> 90 {{ 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 }} 2 {{ define "content" }} 3 <div class="max-w-4xl mx-auto px-4 py-8"> 4 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 <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> 130 </div> 131 </div> 132 </div> 133 - {{ end }}
··· 1 + {{ define "title" }}privacy policy{{ end }} 2 + 3 {{ define "content" }} 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + {{ .Content }} 8 </div> 9 </div> 10 </div> 11 + {{ end }}
+2 -62
appview/pages/templates/legal/terms.html
··· 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 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> 68 </div> 69 </div> 70 </div> 71 - {{ end }}
··· 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + {{ .Content }} 8 </div> 9 </div> 10 </div> 11 + {{ end }}
+1
appview/pages/templates/repo/blob.html
··· 78 {{ end }} 79 </div> 80 {{ end }} 81 {{ end }}
··· 78 {{ end }} 79 </div> 80 {{ end }} 81 + {{ template "fragments/multiline-select" }} 82 {{ end }}
+2 -2
appview/pages/templates/repo/commit.html
··· 81 82 {{ define "topbarLayout" }} 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 - {{ template "layouts/topbar" . }} 85 </header> 86 {{ end }} 87 ··· 106 107 {{ define "footerLayout" }} 108 <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 110 </footer> 111 {{ end }} 112
··· 81 82 {{ define "topbarLayout" }} 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 ··· 106 107 {{ define "footerLayout" }} 108 <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }} 112
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 12 13 {{ define "topbarLayout" }} 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 16 </header> 17 {{ end }} 18 ··· 37 38 {{ define "footerLayout" }} 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 41 </footer> 42 {{ end }} 43
··· 12 13 {{ define "topbarLayout" }} 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/fragments/topbar" . }} 16 </header> 17 {{ end }} 18 ··· 37 38 {{ define "footerLayout" }} 39 <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/fragments/footer" . }} 41 </footer> 42 {{ end }} 43
+1 -1
appview/pages/templates/repo/fork.html
··· 19 class="mr-2" 20 id="domain-{{ . }}" 21 /> 22 - <span class="dark:text-white">{{ . }}</span> 23 </div> 24 {{ else }} 25 <p class="dark:text-white">No knots available.</p>
··· 19 class="mr-2" 20 id="domain-{{ . }}" 21 /> 22 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 </div> 24 {{ else }} 25 <p class="dark:text-white">No knots available.</p>
+6
appview/pages/templates/repo/fragments/diff.html
··· 11 {{ $last := sub (len $diff) 1 }} 12 13 <div class="flex flex-col gap-4"> 14 {{ range $idx, $hunk := $diff }} 15 {{ with $hunk }} 16 <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 </div> 50 </details> 51 {{ end }} 52 {{ end }} 53 </div> 54 {{ end }}
··· 11 {{ $last := sub (len $diff) 1 }} 12 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 }} 19 {{ range $idx, $hunk := $diff }} 20 {{ with $hunk }} 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 }}"> ··· 54 </div> 55 </details> 56 {{ end }} 57 + {{ end }} 58 {{ end }} 59 </div> 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 +
+9
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ $formatted := shortRelTimeFmt . }} 3 + {{ $content := printf "%s ago" $formatted }} 4 + {{ if eq $formatted "now" }} 5 + {{ $content = "now" }} 6 + {{ end }} 7 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }} 8 + {{ end }} 9 +
-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 {{ define "repo/fragments/time" }} 6 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 {{ 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 }}
··· 1 {{ define "repo/fragments/time" }} 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 3 {{ 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 {{ end }} 36 37 {{ define "repoLanguages" }} 38 - <div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t"> 39 {{ 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> 45 {{ end }} 46 - </div> 47 {{ end }} 48 - 49 50 {{ define "branchSelector" }} 51 <div class="flex gap-2 items-center justify-between w-full">
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 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"> 48 {{ range $value := .Languages }} 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> 62 {{ end }} 63 + </div> 64 + </details> 65 {{ end }} 66 67 {{ define "branchSelector" }} 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 {{ 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> 7 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> 41 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> 48 </div> 49 - {{ end }} 50 {{ end }} 51
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 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> 9 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 13 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" . }} 18 </div> 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> 32 {{ end }} 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 {{ end }} 10 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> 18 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 }} 25 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> 41 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 47 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 {{ end }} 63 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> 77 78 - {{ block "newComment" . }} {{ end }} 79 80 {{ end }} 81 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 - } 194 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 - } 213 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 }}
··· 9 {{ end }} 10 11 {{ define "repoContent" }} 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 }} 21 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 }} 30 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> 54 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 }} 61 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 65 {{ end }} 66 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 }} 76 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 }} 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> 102 {{ end }} 103 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 + }} 114 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 117 + {{ end }} 118
+42 -44
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 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 }} 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> 66 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 69 - </span> 70 71 - <span class="before:content-['ยท']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 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> 83 </div> 84 - {{ end }} 85 - </div> 86 - 87 - {{ block "pagination" . }} {{ end }} 88 - 89 {{ end }} 90 91 {{ define "pagination" }}
··· 37 {{ end }} 38 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 }} 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> 66 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 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 }} 85 </div> 86 + {{ block "pagination" . }} {{ end }} 87 {{ end }} 88 89 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 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> 37 {{ end }}
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + {{ template "repo/issues/fragments/putIssue" . }} 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 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 - <span class="dark:text-white">{{ . }}</span> 53 </div> 54 {{ else }} 55 <p class="dark:text-white">No knots available.</p>
··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 </div> 54 {{ else }} 55 <p class="dark:text-white">No knots available.</p>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 </div> 53 {{ end }} 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) }} 56 </div> 57 </div> 58 </a>
··· 52 </div> 53 {{ end }} 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 </div> 57 </div> 58 </a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }}
··· 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }}
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
··· 30 31 {{ define "topbarLayout" }} 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/topbar" . }} 34 </header> 35 {{ end }} 36 ··· 55 56 {{ define "footerLayout" }} 57 <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 59 </footer> 60 {{ end }} 61
··· 30 31 {{ define "topbarLayout" }} 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/fragments/topbar" . }} 34 </header> 35 {{ end }} 36 ··· 55 56 {{ define "footerLayout" }} 57 <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }} 61
+2 -2
appview/pages/templates/repo/pulls/patch.html
··· 36 37 {{ define "topbarLayout" }} 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/topbar" . }} 40 </header> 41 {{ end }} 42 ··· 61 62 {{ define "footerLayout" }} 63 <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 65 </footer> 66 {{ end }} 67
··· 36 37 {{ define "topbarLayout" }} 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/fragments/topbar" . }} 40 </header> 41 {{ end }} 42 ··· 61 62 {{ define "footerLayout" }} 63 <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/fragments/footer" . }} 65 </footer> 66 {{ end }} 67
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 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 <div class="flex gap-2 items-center px-6"> 146 <div class="flex-grow min-w-0 w-full py-2"> 147 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 148 </div> 149 </div> 150 </a>
··· 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 <div class="flex gap-2 items-center px-6"> 146 <div class="flex-grow min-w-0 w-full py-2"> 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 </div> 149 </div> 150 </a>
+3 -3
appview/pages/templates/repo/tree.html
··· 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 27 {{ range .BreadCrumbs }} 28 - <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 29 {{ end }} 30 </div> 31 <div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 32 {{ $stats := .TreeStats }} 33 34 - <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 35 {{ if eq $stats.NumFolders 1 }} 36 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 37 <span>{{ $stats.NumFolders }} folder</span> ··· 55 {{ range .Files }} 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 <div class="col-span-8 md:col-span-4"> 58 - {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }} 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61
··· 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 27 {{ range .BreadCrumbs }} 28 + <a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 29 {{ end }} 30 </div> 31 <div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 32 {{ $stats := .TreeStats }} 33 34 + <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span> 35 {{ if eq $stats.NumFolders 1 }} 36 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 37 <span>{{ $stats.NumFolders }} folder</span> ··· 55 {{ range .Files }} 56 <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 <div class="col-span-8 md:col-span-4"> 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }} 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 30 {{ define "spindleRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .Verified }} 34 <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 {{ template "spindles/fragments/addMemberModal" . }} 36 {{ else }} 37 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 38 {{ block "spindleRetryButton" . }} {{ end }} 39 {{ end }} 40 {{ block "spindleDeleteButton" . }} {{ end }} 41 </div> 42 {{ end }}
··· 30 {{ define "spindleRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 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 }} 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> 39 {{ template "spindles/fragments/addMemberModal" . }} 40 {{ else }} 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> 42 {{ block "spindleRetryButton" . }} {{ end }} 43 {{ end }} 44 + 45 {{ block "spindleDeleteButton" . }} {{ end }} 46 </div> 47 {{ end }}
+10 -9
appview/pages/templates/spindles/index.html
··· 1 {{ define "title" }}spindles{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 </div> 7 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 {{ end }} 16 17 {{ 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> 24 </p> 25 - </section> 26 {{ end }} 27 28 {{ define "list" }}
··· 1 {{ define "title" }}spindles{{ end }} 2 3 {{ define "content" }} 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> 10 </div> 11 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 19 {{ end }} 20 21 {{ define "about" }} 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. 25 </p> 26 + </section> 27 {{ end }} 28 29 {{ define "list" }}
-4
appview/pages/templates/strings/put.html
··· 1 {{ define "title" }}publish a new string{{ end }} 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 {{ define "content" }} 8 <div class="px-6 py-2 mb-4"> 9 {{ if eq .Action "new" }}
··· 1 {{ define "title" }}publish a new string{{ end }} 2 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }}
+3 -6
appview/pages/templates/strings/string.html
··· 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 {{ end }} 10 11 - {{ define "topbar" }} 12 - {{ template "layouts/topbar" $ }} 13 - {{ end }} 14 - 15 {{ define "content" }} 16 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> ··· 27 hx-boost="true" 28 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 {{ i "pencil" "size-4" }} 30 - <span class="hidden md:inline">edit</span> 31 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 </a> 33 <button ··· 38 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 39 > 40 {{ i "trash-2" "size-4" }} 41 - <span class="hidden md:inline">delete</span> 42 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 </button> 44 </div> ··· 84 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 85 {{ end }} 86 </div> 87 </section> 88 {{ end }}
··· 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 {{ end }} 10 11 {{ define "content" }} 12 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> ··· 23 hx-boost="true" 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 25 {{ i "pencil" "size-4" }} 26 + <span class="hidden md:inline">edit</span> 27 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 28 </a> 29 <button ··· 34 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 35 > 36 {{ i "trash-2" "size-4" }} 37 + <span class="hidden md:inline">delete</span> 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 </button> 40 </div> ··· 80 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 81 {{ end }} 82 </div> 83 + {{ template "fragments/multiline-select" }} 84 </section> 85 {{ end }}
-4
appview/pages/templates/strings/timeline.html
··· 1 {{ define "title" }} all strings {{ end }} 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 {{ define "content" }} 8 {{ block "timeline" $ }}{{ end }} 9 {{ end }}
··· 1 {{ define "title" }} all strings {{ end }} 2 3 {{ define "content" }} 4 {{ block "timeline" $ }}{{ end }} 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 {{ end }} 9 10 {{ define "content" }} 11 - {{ if .LoggedInUser }} 12 - {{ else }} 13 - {{ block "hero" $ }}{{ end }} 14 - {{ end }} 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> 183 {{ end }}
··· 8 {{ end }} 9 10 {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
+2 -4
appview/pages/templates/user/completeSignup.html
··· 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 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 36 </h1> 37 <h2 class="text-center text-xl italic dark:text-white"> 38 tightly-knit social coding.
··· 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 31 <main class="max-w-md px-6 -mt-4"> 32 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 34 </h1> 35 <h2 class="text-center text-xl italic dark:text-white"> 36 tightly-knit social coding.
+4 -16
appview/pages/templates/user/followers.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "followers" . }}{{ end }} 17 - </div> 18 - </div> 19 {{ end }} 20 21 {{ define "followers" }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "followers" }}
+4 -16
appview/pages/templates/user/following.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "following" . }}{{ end }} 17 - </div> 18 - </div> 19 {{ end }} 20 21 {{ define "following" }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "following" }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 - class="py-1 px-1 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 + class="p-2 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/picHandle.html
··· 1 {{ define "user/fragments/picHandle" }} 2 <img 3 src="{{ tinyAvatar . }}" 4 - alt="{{ . }}" 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 {{ . | truncateAt30 }}
··· 1 {{ define "user/fragments/picHandle" }} 2 <img 3 src="{{ tinyAvatar . }}" 4 + alt="" 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 {{ . | truncateAt30 }}
+2 -4
appview/pages/templates/user/fragments/profileCard.html
··· 1 {{ define "user/fragments/profileCard" }} 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 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 <div id="avatar" class="col-span-1 flex justify-center items-center"> 6 <div class="w-3/4 aspect-square relative"> ··· 85 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 86 </div> 87 </div> 88 - </div> 89 {{ end }} 90 91 {{ define "followerFollowing" }} ··· 94 {{ with $root }} 95 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 98 <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 100 </div> 101 {{ end }} 102 {{ end }}
··· 1 {{ define "user/fragments/profileCard" }} 2 {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 <div class="w-3/4 aspect-square relative"> ··· 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 </div> 86 </div> 87 {{ end }} 88 89 {{ define "followerFollowing" }} ··· 92 {{ with $root }} 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 <span class="select-none after:content-['ยท']"></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 </div> 99 {{ end }} 100 {{ end }}
+1 -2
appview/pages/templates/user/fragments/repoCard.html
··· 36 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 {{ with .Language }} 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> 41 <span>{{ . }}</span> 42 </div> 43 {{ end }}
··· 36 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 {{ with .Language }} 38 <div class="flex gap-2 items-center text-sm"> 39 + {{ template "repo/fragments/languageBall" . }} 40 <span>{{ . }}</span> 41 </div> 42 {{ end }}
+2 -2
appview/pages/templates/user/login.html
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 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 18 </h1> 19 <h2 class="text-center text-xl italic dark:text-white"> 20 tightly-knit social coding.
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 </h1> 19 <h2 class="text-center text-xl italic dark:text-white"> 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 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 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> 19 {{ end }} 20 21 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 26 {{ else }} 27 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 {{ end }}
··· 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 "ownRepos" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "ownRepos" }} 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 $ . false) }} 14 + </div> 15 {{ else }} 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 17 {{ end }}
+2 -2
appview/pages/templates/user/settings/emails.html
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 + <div class="bg-white dark:bg-gray-800 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 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
+2 -2
appview/pages/templates/user/settings/keys.html
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 + <div class="bg-white dark:bg-gray-800 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 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
+2 -2
appview/pages/templates/user/settings/profile.html
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 + <div class="bg-white dark:bg-gray-800 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 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
+3 -1
appview/pages/templates/user/signup.html
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 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> 17 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 <form 19 class="mt-4 max-w-sm mx-auto"
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 19 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 20 <form 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 }}
+34 -1
appview/posthog/notifier.go
··· 58 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 62 Event: "new_issue", 63 Properties: posthog.Properties{ 64 "repo_at": issue.RepoAt.String(), ··· 129 log.Println("failed to enqueue posthog event:", err) 130 } 131 }
··· 58 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 Event: "new_issue", 63 Properties: posthog.Properties{ 64 "repo_at": issue.RepoAt.String(), ··· 129 log.Println("failed to enqueue posthog event:", err) 130 } 131 } 132 + 133 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 + err := n.client.Enqueue(posthog.Capture{ 135 + DistinctId: did, 136 + Event: "delete_string", 137 + Properties: posthog.Properties{"rkey": rkey}, 138 + }) 139 + if err != nil { 140 + log.Println("failed to enqueue posthog event:", err) 141 + } 142 + } 143 + 144 + func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) { 145 + err := n.client.Enqueue(posthog.Capture{ 146 + DistinctId: string.Did.String(), 147 + Event: "edit_string", 148 + Properties: posthog.Properties{"rkey": string.Rkey}, 149 + }) 150 + if err != nil { 151 + log.Println("failed to enqueue posthog event:", err) 152 + } 153 + } 154 + 155 + func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) { 156 + err := n.client.Enqueue(posthog.Capture{ 157 + DistinctId: string.Did.String(), 158 + Event: "create_string", 159 + Properties: posthog.Properties{"rkey": string.Rkey}, 160 + }) 161 + if err != nil { 162 + log.Println("failed to enqueue posthog event:", err) 163 + } 164 + }
+230 -85
appview/pulls/pulls.go
··· 2 3 import ( 4 "database/sql" 5 "errors" 6 "fmt" 7 "log" ··· 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" ··· 99 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 - resubmitResult = s.resubmitCheck(f, pull, stack) 103 } 104 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 154 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 - resubmitResult = s.resubmitCheck(f, pull, stack) 158 } 159 160 repoInfo := f.RepoInfo(user) ··· 282 return result 283 } 284 285 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 return pages.Unknown 288 } ··· 307 repoName = f.Name 308 } 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 314 } 315 316 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 317 if err != nil { 318 log.Println("failed to reach knotserver", err) 319 return pages.Unknown 320 } 321 322 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 323 324 if pull.IsStacked() && stack != nil { ··· 326 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 327 } 328 329 - if latestSourceRev != result.Branch.Hash { 330 return pages.ShouldResubmit 331 } 332 ··· 678 679 switch r.Method { 680 case http.MethodGet: 681 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 682 if err != nil { 683 - log.Printf("failed to create unsigned client for %s", f.Knot) 684 - s.pages.Error503(w) 685 return 686 } 687 688 - result, err := us.Branches(f.OwnerDid(), f.Name) 689 - if err != nil { 690 - log.Println("failed to fetch branches", err) 691 return 692 } 693 ··· 752 return 753 } 754 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 - } 761 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 767 } 768 769 if !caps.PullRequests.FormatPatch { 770 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 806 sourceBranch string, 807 isStacked bool, 808 ) { 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 815 } 816 817 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 818 if err != nil { 819 log.Println("failed to compare", err) 820 s.pages.Notice(w, "pull", err.Error()) 821 return 822 } 823 ··· 869 oauth.WithLxm(tangled.RepoHiddenRefNSID), 870 oauth.WithDev(s.config.Core.Dev), 871 ) 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 885 resp, err := tangled.RepoHiddenRef( 886 r.Context(), ··· 911 // hiddenRef: hidden/feature-1/main (on repo-fork) 912 // targetBranch: main (on repo-1) 913 // sourceBranch: feature-1 (on repo-fork) 914 - comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch) 915 if err != nil { 916 log.Println("failed to compare across branches", err) 917 s.pages.Notice(w, "pull", err.Error()) 918 return 919 } 920 921 sourceRev := comparison.Rev2 922 patch := comparison.Patch 923 ··· 1211 return 1212 } 1213 1214 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1215 if err != nil { 1216 - log.Printf("failed to create unsigned client for %s", f.Knot) 1217 - s.pages.Error503(w) 1218 return 1219 } 1220 1221 - result, err := us.Branches(f.OwnerDid(), f.Name) 1222 - if err != nil { 1223 - log.Println("failed to reach knotserver", err) 1224 return 1225 } 1226 ··· 1284 return 1285 } 1286 1287 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1288 if err != nil { 1289 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1290 - s.pages.Error503(w) 1291 return 1292 } 1293 1294 - sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name) 1295 - if err != nil { 1296 - log.Println("failed to reach knotserver for source branches", err) 1297 return 1298 } 1299 1300 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1301 if err != nil { 1302 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1303 - s.pages.Error503(w) 1304 return 1305 } 1306 1307 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1308 - if err != nil { 1309 - log.Println("failed to reach knotserver for target branches", err) 1310 return 1311 } 1312 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) 1316 }) 1317 1318 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1319 RepoInfo: f.RepoInfo(user), 1320 - SourceBranches: sourceBranches, 1321 - TargetBranches: targetResult.Branches, 1322 }) 1323 } 1324 ··· 1413 return 1414 } 1415 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 1421 } 1422 1423 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1424 if err != nil { 1425 log.Printf("compare request failed: %s", err) 1426 s.pages.Notice(w, "resubmit-error", err.Error()) 1427 return 1428 } 1429 ··· 1463 } 1464 1465 // extract patch by performing compare 1466 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1467 if err != nil { 1468 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1469 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1470 return 1471 } ··· 1501 return 1502 } 1503 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 - } 1511 1512 sourceRev := comparison.Rev2 1513 patch := comparison.Patch
··· 2 3 import ( 4 "database/sql" 5 + "encoding/json" 6 "errors" 7 "fmt" 8 "log" ··· 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 "tangled.sh/tangled.sh/core/idresolver" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" ··· 99 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 } 104 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 154 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 } 159 160 repoInfo := f.RepoInfo(user) ··· 282 return result 283 } 284 285 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 return pages.Unknown 288 } ··· 307 repoName = f.Name 308 } 309 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, 317 } 318 319 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 320 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 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 + } 326 log.Println("failed to reach knotserver", err) 327 return pages.Unknown 328 } 329 330 + targetBranch := branchResp 331 + 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 333 334 if pull.IsStacked() && stack != nil { ··· 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 337 } 338 339 + if latestSourceRev != targetBranch.Hash { 340 return pages.ShouldResubmit 341 } 342 ··· 688 689 switch r.Method { 690 case http.MethodGet: 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) 702 if err != nil { 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) 709 return 710 } 711 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) 716 return 717 } 718 ··· 777 return 778 } 779 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 + // } 786 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 + }, 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 + // } 815 816 if !caps.PullRequests.FormatPatch { 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 853 sourceBranch string, 854 isStacked bool, 855 ) { 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, 863 } 864 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 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 + } 873 log.Println("failed to compare", err) 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.") 882 return 883 } 884 ··· 930 oauth.WithLxm(tangled.RepoHiddenRefNSID), 931 oauth.WithDev(s.config.Core.Dev), 932 ) 933 934 resp, err := tangled.RepoHiddenRef( 935 r.Context(), ··· 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 961 // targetBranch: main (on repo-1) 962 // sourceBranch: feature-1 (on repo-fork) 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) 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 + } 980 log.Println("failed to compare across branches", err) 981 s.pages.Notice(w, "pull", err.Error()) 982 return 983 } 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 + 992 sourceRev := comparison.Rev2 993 patch := comparison.Patch 994 ··· 1282 return 1283 } 1284 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) 1296 if err != nil { 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) 1303 return 1304 } 1305 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) 1310 return 1311 } 1312 ··· 1370 return 1371 } 1372 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) 1384 if err != nil { 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) 1391 return 1392 } 1393 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) 1399 return 1400 } 1401 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) 1413 if err != nil { 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) 1420 return 1421 } 1422 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) 1428 return 1429 } 1430 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) 1433 }) 1434 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1436 RepoInfo: f.RepoInfo(user), 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1439 }) 1440 } 1441 ··· 1530 return 1531 } 1532 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, 1540 } 1541 1542 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1543 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 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 + } 1550 log.Printf("compare request failed: %s", err) 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.") 1559 return 1560 } 1561 ··· 1595 } 1596 1597 // extract patch by performing compare 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) 1605 if err != nil { 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) 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1620 return 1621 } ··· 1651 return 1652 } 1653 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1656 1657 sourceRev := comparison.Rev2 1658 patch := comparison.Patch
+26 -8
appview/repo/artifact.go
··· 1 package repo 2 3 import ( 4 "fmt" 5 "log" 6 "net/http" ··· 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "github.com/dustin/go-humanize" 13 "github.com/go-chi/chi/v5" 14 "github.com/go-git/go-git/v5/plumbing" ··· 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/knotclient" 21 "tangled.sh/tangled.sh/core/tid" 22 "tangled.sh/tangled.sh/core/types" 23 ) ··· 33 return 34 } 35 36 - tag, err := rp.resolveTag(f, tagParam) 37 if err != nil { 38 log.Println("failed to resolve tag", err) 39 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 140 return 141 } 142 143 - tag, err := rp.resolveTag(f, tagParam) 144 if err != nil { 145 log.Println("failed to resolve tag", err) 146 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 259 w.Write([]byte{}) 260 } 261 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 tagParam, err := url.QueryUnescape(tagParam) 264 if err != nil { 265 return nil, err 266 } 267 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 - if err != nil { 270 - return nil, err 271 } 272 273 - result, err := us.Tags(f.OwnerDid(), f.Name) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err 277 } 278
··· 1 package repo 2 3 import ( 4 + "context" 5 + "encoding/json" 6 "fmt" 7 "log" 8 "net/http" ··· 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 "github.com/dustin/go-humanize" 16 "github.com/go-chi/chi/v5" 17 "github.com/go-git/go-git/v5/plumbing" ··· 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 "tangled.sh/tangled.sh/core/tid" 25 "tangled.sh/tangled.sh/core/types" 26 ) ··· 36 return 37 } 38 39 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 40 if err != nil { 41 log.Println("failed to resolve tag", err) 42 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 143 return 144 } 145 146 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 if err != nil { 148 log.Println("failed to resolve tag", err) 149 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 262 w.Write([]byte{}) 263 } 264 265 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 266 tagParam, err := url.QueryUnescape(tagParam) 267 if err != nil { 268 return nil, err 269 } 270 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, 278 } 279 280 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 281 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 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 + } 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) 294 return nil, err 295 } 296
+7 -2
appview/repo/feed.go
··· 9 "time" 10 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/reporesolver" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" ··· 23 return nil, err 24 } 25 26 - issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 if err != nil { 28 return nil, err 29 } ··· 104 } 105 106 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) 108 if err != nil { 109 return nil, err 110 }
··· 9 "time" 10 11 "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/pagination" 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 24 return nil, err 25 } 26 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 32 if err != nil { 33 return nil, err 34 } ··· 109 } 110 111 func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 if err != nil { 114 return nil, err 115 }
+204 -20
appview/repo/index.go
··· 1 package repo 2 3 import ( 4 "log" 5 "net/http" 6 "slices" 7 "sort" 8 "strings" 9 10 "tangled.sh/tangled.sh/core/appview/commitverify" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 - "tangled.sh/tangled.sh/core/knotclient" 15 "tangled.sh/tangled.sh/core/types" 16 17 "github.com/go-chi/chi/v5" ··· 20 21 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 22 ref := chi.URLParam(r, "ref") 23 24 f, err := rp.repoResolver.Resolve(r) 25 if err != nil { ··· 27 return 28 } 29 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 35 } 36 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 42 } 43 ··· 98 log.Println(err) 99 } 100 101 - user := rp.oauth.GetUser(r) 102 - repoInfo := f.RepoInfo(user) 103 - 104 // TODO: a bit dirty 105 - languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 106 if err != nil { 107 log.Printf("failed to compute language percentages: %s", err) 108 // non-fatal ··· 135 } 136 137 func (rp *Repo) getLanguageInfo( 138 f *reporesolver.ResolvedRepo, 139 - us *knotclient.UnsignedClient, 140 currentRef string, 141 isDefaultRef bool, 142 ) ([]types.RepoLanguageDetails, error) { ··· 148 ) 149 150 if err != nil || langs == nil { 151 - // non-fatal, fetch langs from ks 152 - ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 153 if err != nil { 154 return nil, err 155 } 156 - if ls == nil { 157 return nil, nil 158 } 159 160 - for l, s := range ls.Languages { 161 langs = append(langs, db.RepoLanguage{ 162 RepoAt: f.RepoAt(), 163 Ref: currentRef, 164 IsDefaultRef: isDefaultRef, 165 - Language: l, 166 - Bytes: s, 167 }) 168 } 169 ··· 206 207 return languageStats, nil 208 }
··· 1 package repo 2 3 import ( 4 + "errors" 5 + "fmt" 6 "log" 7 "net/http" 8 + "net/url" 9 "slices" 10 "sort" 11 "strings" 12 + "sync" 13 + "time" 14 15 + "context" 16 + "encoding/json" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-git/go-git/v5/plumbing" 20 + "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview/commitverify" 22 "tangled.sh/tangled.sh/core/appview/db" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 26 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 "tangled.sh/tangled.sh/core/types" 28 29 "github.com/go-chi/chi/v5" ··· 32 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 34 ref := chi.URLParam(r, "ref") 35 + ref, _ = url.PathUnescape(ref) 36 37 f, err := rp.repoResolver.Resolve(r) 38 if err != nil { ··· 40 return 41 } 42 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 } 51 52 + user := rp.oauth.GetUser(r) 53 + repoInfo := f.RepoInfo(user) 54 + 55 + // Build index response from multiple XRPC calls 56 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 + log.Println("failed to call XRPC repo.index", err) 60 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 + LoggedInUser: user, 62 + NeedsKnotUpgrade: true, 63 + RepoInfo: repoInfo, 64 + }) 65 + return 66 + } 67 + 68 rp.pages.Error503(w) 69 + log.Println("failed to build index response", err) 70 return 71 } 72 ··· 127 log.Println(err) 128 } 129 130 // TODO: a bit dirty 131 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 132 if err != nil { 133 log.Printf("failed to compute language percentages: %s", err) 134 // non-fatal ··· 161 } 162 163 func (rp *Repo) getLanguageInfo( 164 + ctx context.Context, 165 f *reporesolver.ResolvedRepo, 166 + xrpcc *indigoxrpc.Client, 167 currentRef string, 168 isDefaultRef bool, 169 ) ([]types.RepoLanguageDetails, error) { ··· 175 ) 176 177 if err != nil || langs == nil { 178 + // non-fatal, fetch langs from ks via XRPC 179 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 180 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 if err != nil { 182 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 + log.Println("failed to call XRPC repo.languages", xrpcerr) 184 + return nil, xrpcerr 185 + } 186 return nil, err 187 } 188 + 189 + if ls == nil || ls.Languages == nil { 190 return nil, nil 191 } 192 193 + for _, lang := range ls.Languages { 194 langs = append(langs, db.RepoLanguage{ 195 RepoAt: f.RepoAt(), 196 Ref: currentRef, 197 IsDefaultRef: isDefaultRef, 198 + Language: lang.Name, 199 + Bytes: lang.Size, 200 }) 201 } 202 ··· 239 240 return languageStats, nil 241 } 242 + 243 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 244 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 245 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 246 + 247 + // first get branches to determine the ref if not specified 248 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 249 + if err != nil { 250 + return nil, fmt.Errorf("failed to call repoBranches: %w", err) 251 + } 252 + 253 + var branchesResp types.RepoBranchesResponse 254 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 255 + return nil, fmt.Errorf("failed to unmarshal branches response: %w", err) 256 + } 257 + 258 + // if no ref specified, use default branch or first available 259 + if ref == "" { 260 + for _, branch := range branchesResp.Branches { 261 + if branch.IsDefault { 262 + ref = branch.Name 263 + break 264 + } 265 + } 266 + } 267 + 268 + // if ref is still empty, this means the default branch is not set 269 + if ref == "" { 270 + return &types.RepoIndexResponse{ 271 + IsEmpty: true, 272 + Branches: branchesResp.Branches, 273 + }, nil 274 + } 275 + 276 + // now run the remaining queries in parallel 277 + var wg sync.WaitGroup 278 + var errs error 279 + 280 + var ( 281 + tagsResp types.RepoTagsResponse 282 + treeResp *tangled.RepoTree_Output 283 + logResp types.RepoLogResponse 284 + readmeContent string 285 + readmeFileName string 286 + ) 287 + 288 + // tags 289 + wg.Add(1) 290 + go func() { 291 + defer wg.Done() 292 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 293 + if err != nil { 294 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 295 + return 296 + } 297 + 298 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 299 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 300 + } 301 + }() 302 + 303 + // tree/files 304 + wg.Add(1) 305 + go func() { 306 + defer wg.Done() 307 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 308 + if err != nil { 309 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 310 + return 311 + } 312 + treeResp = resp 313 + }() 314 + 315 + // commits 316 + wg.Add(1) 317 + go func() { 318 + defer wg.Done() 319 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 320 + if err != nil { 321 + errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 322 + return 323 + } 324 + 325 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 326 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 327 + } 328 + }() 329 + 330 + // readme content 331 + wg.Add(1) 332 + go func() { 333 + defer wg.Done() 334 + for _, filename := range markup.ReadmeFilenames { 335 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 336 + if err != nil { 337 + continue 338 + } 339 + 340 + if blobResp == nil { 341 + continue 342 + } 343 + 344 + readmeContent = blobResp.Content 345 + readmeFileName = filename 346 + break 347 + } 348 + }() 349 + 350 + wg.Wait() 351 + 352 + if errs != nil { 353 + return nil, errs 354 + } 355 + 356 + var files []types.NiceTree 357 + if treeResp != nil && treeResp.Files != nil { 358 + for _, file := range treeResp.Files { 359 + niceFile := types.NiceTree{ 360 + IsFile: file.Is_file, 361 + IsSubtree: file.Is_subtree, 362 + Name: file.Name, 363 + Mode: file.Mode, 364 + Size: file.Size, 365 + } 366 + if file.Last_commit != nil { 367 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 368 + niceFile.LastCommit = &types.LastCommitInfo{ 369 + Hash: plumbing.NewHash(file.Last_commit.Hash), 370 + Message: file.Last_commit.Message, 371 + When: when, 372 + } 373 + } 374 + files = append(files, niceFile) 375 + } 376 + } 377 + 378 + result := &types.RepoIndexResponse{ 379 + IsEmpty: false, 380 + Ref: ref, 381 + Readme: readmeContent, 382 + ReadmeFileName: readmeFileName, 383 + Commits: logResp.Commits, 384 + Description: logResp.Description, 385 + Files: files, 386 + Branches: branchesResp.Branches, 387 + Tags: tagsResp.Tags, 388 + TotalCommits: logResp.Total, 389 + } 390 + 391 + return result, nil 392 + }
+369 -160
appview/repo/repo.go
··· 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 "tangled.sh/tangled.sh/core/api/tangled" 23 "tangled.sh/tangled.sh/core/appview/commitverify" 24 "tangled.sh/tangled.sh/core/appview/config" ··· 31 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" 34 - "tangled.sh/tangled.sh/core/knotclient" 35 "tangled.sh/tangled.sh/core/patchutil" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" ··· 85 } 86 87 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 - refParam := chi.URLParam(r, "ref") 89 f, err := rp.repoResolver.Resolve(r) 90 if err != nil { 91 log.Println("failed to get repo and knot", err) 92 return 93 } 94 95 - var uri string 96 - if rp.config.Core.Dev { 97 - uri = "http" 98 - } else { 99 - uri = "https" 100 } 101 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 102 103 - http.Redirect(w, r, url, http.StatusFound) 104 } 105 106 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 119 } 120 121 ref := chi.URLParam(r, "ref") 122 123 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 124 - if err != nil { 125 - log.Println("failed to create unsigned client", err) 126 return 127 } 128 129 - repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 - if err != nil { 131 rp.pages.Error503(w) 132 - log.Println("failed to reach knotserver", err) 133 return 134 } 135 136 - tagResult, err := us.Tags(f.OwnerDid(), f.Name) 137 - if err != nil { 138 rp.pages.Error503(w) 139 - log.Println("failed to reach knotserver", err) 140 return 141 } 142 143 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() 148 } 149 - tagMap[hash] = append(tagMap[hash], tag.Name) 150 } 151 152 - branchResult, err := us.Branches(f.OwnerDid(), f.Name) 153 - if err != nil { 154 rp.pages.Error503(w) 155 - log.Println("failed to reach knotserver", err) 156 return 157 } 158 159 - for _, branch := range branchResult.Branches { 160 - hash := branch.Hash 161 - tagMap[hash] = append(tagMap[hash], branch.Name) 162 } 163 164 user := rp.oauth.GetUser(r) 165 166 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 167 if err != nil { 168 log.Println("failed to fetch email to did mapping", err) 169 } 170 171 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 172 if err != nil { 173 log.Println(err) 174 } ··· 176 repoInfo := f.RepoInfo(user) 177 178 var shas []string 179 - for _, c := range repolog.Commits { 180 shas = append(shas, c.Hash.String()) 181 } 182 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 189 LoggedInUser: user, 190 TagMap: tagMap, 191 RepoInfo: repoInfo, 192 - RepoLogResponse: *repolog, 193 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 194 VerifiedCommits: vc, 195 Pipelines: pipelines, ··· 301 return 302 } 303 ref := chi.URLParam(r, "ref") 304 - protocol := "http" 305 - if !rp.config.Core.Dev { 306 - protocol = "https" 307 - } 308 309 var diffOpts types.DiffOpts 310 if d := r.URL.Query().Get("diff"); d == "split" { ··· 316 return 317 } 318 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 324 } 325 326 - body, err := io.ReadAll(resp.Body) 327 - if err != nil { 328 - log.Printf("Error reading response body: %v", err) 329 return 330 } 331 332 var result types.RepoCommitResponse 333 - err = json.Unmarshal(body, &result) 334 - if err != nil { 335 - log.Println("failed to parse response:", err) 336 return 337 } 338 ··· 377 } 378 379 ref := chi.URLParam(r, "ref") 380 - treePath := chi.URLParam(r, "*") 381 - protocol := "http" 382 - if !rp.config.Core.Dev { 383 - protocol = "https" 384 - } 385 386 // if the tree path has a trailing slash, let's strip it 387 // so we don't 404 388 treePath = strings.TrimSuffix(treePath, "/") 389 390 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 391 - if err != nil { 392 rp.pages.Error503(w) 393 - log.Println("failed to reach knotserver", err) 394 return 395 } 396 397 - // uhhh so knotserver returns a 500 if the entry isn't found in 398 - // the requested tree path, so let's stick to not-OK here. 399 - // we can fix this once we build out the xrpc apis for these operations. 400 - if resp.StatusCode != http.StatusOK { 401 - rp.pages.Error404(w) 402 - return 403 } 404 405 - body, err := io.ReadAll(resp.Body) 406 - if err != nil { 407 - log.Printf("Error reading response body: %v", err) 408 - return 409 } 410 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 416 } 417 418 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 419 // so we can safely redirect to the "parent" (which is the same file). 420 - unescapedTreePath, _ := url.PathUnescape(treePath) 421 - if len(result.Files) == 0 && result.Parent == unescapedTreePath { 422 - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 423 return 424 } 425 426 user := rp.oauth.GetUser(r) 427 428 var breadcrumbs [][]string 429 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 430 if treePath != "" { 431 for idx, elem := range strings.Split(treePath, "/") { 432 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 433 } 434 } 435 ··· 451 return 452 } 453 454 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 455 - if err != nil { 456 - log.Println("failed to create unsigned client", err) 457 return 458 } 459 460 - result, err := us.Tags(f.OwnerDid(), f.Name) 461 - if err != nil { 462 rp.pages.Error503(w) 463 - log.Println("failed to reach knotserver", err) 464 return 465 } 466 ··· 496 rp.pages.RepoTags(w, pages.RepoTagsParams{ 497 LoggedInUser: user, 498 RepoInfo: f.RepoInfo(user), 499 - RepoTagsResponse: *result, 500 ArtifactMap: artifactMap, 501 DanglingArtifacts: danglingArtifacts, 502 }) ··· 509 return 510 } 511 512 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 513 - if err != nil { 514 - log.Println("failed to create unsigned client", err) 515 return 516 } 517 518 - result, err := us.Branches(f.OwnerDid(), f.Name) 519 - if err != nil { 520 rp.pages.Error503(w) 521 - log.Println("failed to reach knotserver", err) 522 return 523 } 524 ··· 528 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 529 LoggedInUser: user, 530 RepoInfo: f.RepoInfo(user), 531 - RepoBranchesResponse: *result, 532 }) 533 } 534 ··· 540 } 541 542 ref := chi.URLParam(r, "ref") 543 filePath := chi.URLParam(r, "*") 544 - protocol := "http" 545 if !rp.config.Core.Dev { 546 - protocol = "https" 547 } 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 553 } 554 555 - if resp.StatusCode == http.StatusNotFound { 556 - rp.pages.Error404(w) 557 - return 558 - } 559 - 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 - } 572 573 var breadcrumbs [][]string 574 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 575 if filePath != "" { 576 for idx, elem := range strings.Split(filePath, "/") { 577 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 578 } 579 } 580 581 showRendered := false 582 renderToggle := false 583 584 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 585 renderToggle = true 586 showRendered = r.URL.Query().Get("code") != "true" 587 } ··· 591 var isVideo bool 592 var contentSrc string 593 594 - if result.IsBinary { 595 - ext := strings.ToLower(filepath.Ext(result.Path)) 596 switch ext { 597 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 isImage = true ··· 602 unsupported = true 603 } 604 605 - // fetch the actual binary content like in RepoBlobRaw 606 607 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 contentSrc = blobURL 609 if !rp.config.Core.Dev { 610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 } 612 } 613 614 user := rp.oauth.GetUser(r) 615 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, 626 }) 627 } 628 ··· 635 } 636 637 ref := chi.URLParam(r, "ref") 638 filePath := chi.URLParam(r, "*") 639 640 - protocol := "http" 641 if !rp.config.Core.Dev { 642 - protocol = "https" 643 } 644 645 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 646 647 req, err := http.NewRequest("GET", blobURL, nil) 648 if err != nil { ··· 685 return 686 } 687 688 - if strings.Contains(contentType, "text/plain") { 689 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 w.Write(body) 691 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 692 w.Header().Set("Content-Type", contentType) 693 w.Write(body) 694 } else { ··· 696 w.Write([]byte("unsupported content type")) 697 return 698 } 699 } 700 701 // modify the spindle configured for this repo ··· 1201 f, err := rp.repoResolver.Resolve(r) 1202 user := rp.oauth.GetUser(r) 1203 1204 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1205 - if err != nil { 1206 - log.Println("failed to create unsigned client", err) 1207 return 1208 } 1209 1210 - result, err := us.Branches(f.OwnerDid(), f.Name) 1211 - if err != nil { 1212 rp.pages.Error503(w) 1213 - log.Println("failed to reach knotserver", err) 1214 return 1215 } 1216 ··· 1304 1305 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1306 ref := chi.URLParam(r, "ref") 1307 1308 user := rp.oauth.GetUser(r) 1309 f, err := rp.repoResolver.Resolve(r) ··· 1581 return 1582 } 1583 1584 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1585 - if err != nil { 1586 - log.Printf("failed to create unsigned client for %s", f.Knot) 1587 rp.pages.Error503(w) 1588 return 1589 } 1590 1591 - result, err := us.Branches(f.OwnerDid(), f.Name) 1592 - if err != nil { 1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1594 - log.Println("failed to reach knotserver", err) 1595 return 1596 } 1597 - branches := result.Branches 1598 1599 sortBranches(branches) 1600 ··· 1618 head = queryHead 1619 } 1620 1621 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1622 - if err != nil { 1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 - log.Println("failed to reach knotserver", err) 1625 return 1626 } 1627 ··· 1673 return 1674 } 1675 1676 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1677 - if err != nil { 1678 - log.Printf("failed to create unsigned client for %s", f.Knot) 1679 rp.pages.Error503(w) 1680 return 1681 } 1682 1683 - branches, err := us.Branches(f.OwnerDid(), f.Name) 1684 - if err != nil { 1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1686 - log.Println("failed to reach knotserver", err) 1687 return 1688 } 1689 1690 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1691 - if err != nil { 1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1693 - log.Println("failed to reach knotserver", err) 1694 return 1695 } 1696 1697 - formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1698 - if err != nil { 1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1700 - log.Println("failed to compare", err) 1701 return 1702 } 1703 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1704 1705 repoinfo := f.RepoInfo(user)
··· 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.sh/tangled.sh/core/api/tangled" 24 "tangled.sh/tangled.sh/core/appview/commitverify" 25 "tangled.sh/tangled.sh/core/appview/config" ··· 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 "tangled.sh/tangled.sh/core/idresolver" 35 "tangled.sh/tangled.sh/core/patchutil" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" ··· 85 } 86 87 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + ref := chi.URLParam(r, "ref") 89 + ref, _ = url.PathUnescape(ref) 90 + 91 f, err := rp.repoResolver.Resolve(r) 92 if err != nil { 93 log.Println("failed to get repo and knot", err) 94 return 95 } 96 97 + scheme := "http" 98 + if !rp.config.Core.Dev { 99 + scheme = "https" 100 } 101 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 102 + xrpcc := &indigoxrpc.Client{ 103 + Host: host, 104 + } 105 + 106 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 107 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 108 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 109 + log.Println("failed to call XRPC repo.archive", xrpcerr) 110 + rp.pages.Error503(w) 111 + return 112 + } 113 + 114 + // Set headers for file download, just pass along whatever the knot specifies 115 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 116 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 117 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 118 + w.Header().Set("Content-Type", "application/gzip") 119 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 120 121 + // Write the archive data directly 122 + w.Write(archiveBytes) 123 } 124 125 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 138 } 139 140 ref := chi.URLParam(r, "ref") 141 + ref, _ = url.PathUnescape(ref) 142 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) 162 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 163 + log.Println("failed to call XRPC repo.log", xrpcerr) 164 + rp.pages.Error503(w) 165 return 166 } 167 168 + var xrpcResp types.RepoLogResponse 169 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 170 + log.Println("failed to decode XRPC response", err) 171 rp.pages.Error503(w) 172 return 173 } 174 175 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 176 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 177 + log.Println("failed to call XRPC repo.tags", xrpcerr) 178 rp.pages.Error503(w) 179 return 180 } 181 182 tagMap := make(map[string][]string) 183 + if tagBytes != nil { 184 + var tagResp types.RepoTagsResponse 185 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 186 + for _, tag := range tagResp.Tags { 187 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 188 + } 189 } 190 } 191 192 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 193 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 194 + log.Println("failed to call XRPC repo.branches", xrpcerr) 195 rp.pages.Error503(w) 196 return 197 } 198 199 + if branchBytes != nil { 200 + var branchResp types.RepoBranchesResponse 201 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 202 + for _, branch := range branchResp.Branches { 203 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 204 + } 205 + } 206 } 207 208 user := rp.oauth.GetUser(r) 209 210 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 211 if err != nil { 212 log.Println("failed to fetch email to did mapping", err) 213 } 214 215 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 216 if err != nil { 217 log.Println(err) 218 } ··· 220 repoInfo := f.RepoInfo(user) 221 222 var shas []string 223 + for _, c := range xrpcResp.Commits { 224 shas = append(shas, c.Hash.String()) 225 } 226 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 233 LoggedInUser: user, 234 TagMap: tagMap, 235 RepoInfo: repoInfo, 236 + RepoLogResponse: xrpcResp, 237 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 238 VerifiedCommits: vc, 239 Pipelines: pipelines, ··· 345 return 346 } 347 ref := chi.URLParam(r, "ref") 348 + ref, _ = url.PathUnescape(ref) 349 350 var diffOpts types.DiffOpts 351 if d := r.URL.Query().Get("diff"); d == "split" { ··· 357 return 358 } 359 360 + scheme := "http" 361 + if !rp.config.Core.Dev { 362 + scheme = "https" 363 + } 364 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 365 + xrpcc := &indigoxrpc.Client{ 366 + Host: host, 367 } 368 369 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 370 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 371 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 372 + log.Println("failed to call XRPC repo.diff", xrpcerr) 373 + rp.pages.Error503(w) 374 return 375 } 376 377 var result types.RepoCommitResponse 378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 379 + log.Println("failed to decode XRPC response", err) 380 + rp.pages.Error503(w) 381 return 382 } 383 ··· 422 } 423 424 ref := chi.URLParam(r, "ref") 425 + ref, _ = url.PathUnescape(ref) 426 427 // if the tree path has a trailing slash, let's strip it 428 // so we don't 404 429 + treePath := chi.URLParam(r, "*") 430 + treePath, _ = url.PathUnescape(treePath) 431 treePath = strings.TrimSuffix(treePath, "/") 432 433 + scheme := "http" 434 + if !rp.config.Core.Dev { 435 + scheme = "https" 436 + } 437 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 438 + xrpcc := &indigoxrpc.Client{ 439 + Host: host, 440 + } 441 + 442 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 443 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 444 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 445 + log.Println("failed to call XRPC repo.tree", xrpcerr) 446 rp.pages.Error503(w) 447 return 448 } 449 450 + // Convert XRPC response to internal types.RepoTreeResponse 451 + files := make([]types.NiceTree, len(xrpcResp.Files)) 452 + for i, xrpcFile := range xrpcResp.Files { 453 + file := types.NiceTree{ 454 + Name: xrpcFile.Name, 455 + Mode: xrpcFile.Mode, 456 + Size: int64(xrpcFile.Size), 457 + IsFile: xrpcFile.Is_file, 458 + IsSubtree: xrpcFile.Is_subtree, 459 + } 460 + 461 + // Convert last commit info if present 462 + if xrpcFile.Last_commit != nil { 463 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 464 + file.LastCommit = &types.LastCommitInfo{ 465 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 466 + Message: xrpcFile.Last_commit.Message, 467 + When: commitWhen, 468 + } 469 + } 470 + 471 + files[i] = file 472 } 473 474 + result := types.RepoTreeResponse{ 475 + Ref: xrpcResp.Ref, 476 + Files: files, 477 } 478 479 + if xrpcResp.Parent != nil { 480 + result.Parent = *xrpcResp.Parent 481 + } 482 + if xrpcResp.Dotdot != nil { 483 + result.DotDot = *xrpcResp.Dotdot 484 } 485 486 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 487 // so we can safely redirect to the "parent" (which is the same file). 488 + if len(result.Files) == 0 && result.Parent == treePath { 489 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 490 + http.Redirect(w, r, redirectTo, http.StatusFound) 491 return 492 } 493 494 user := rp.oauth.GetUser(r) 495 496 var breadcrumbs [][]string 497 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 498 if treePath != "" { 499 for idx, elem := range strings.Split(treePath, "/") { 500 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 501 } 502 } 503 ··· 519 return 520 } 521 522 + scheme := "http" 523 + if !rp.config.Core.Dev { 524 + scheme = "https" 525 + } 526 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 527 + xrpcc := &indigoxrpc.Client{ 528 + Host: host, 529 + } 530 + 531 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 532 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 533 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 534 + log.Println("failed to call XRPC repo.tags", xrpcerr) 535 + rp.pages.Error503(w) 536 return 537 } 538 539 + var result types.RepoTagsResponse 540 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 541 + log.Println("failed to decode XRPC response", err) 542 rp.pages.Error503(w) 543 return 544 } 545 ··· 575 rp.pages.RepoTags(w, pages.RepoTagsParams{ 576 LoggedInUser: user, 577 RepoInfo: f.RepoInfo(user), 578 + RepoTagsResponse: result, 579 ArtifactMap: artifactMap, 580 DanglingArtifacts: danglingArtifacts, 581 }) ··· 588 return 589 } 590 591 + scheme := "http" 592 + if !rp.config.Core.Dev { 593 + scheme = "https" 594 + } 595 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 596 + xrpcc := &indigoxrpc.Client{ 597 + Host: host, 598 + } 599 + 600 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 601 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 602 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 603 + log.Println("failed to call XRPC repo.branches", xrpcerr) 604 + rp.pages.Error503(w) 605 return 606 } 607 608 + var result types.RepoBranchesResponse 609 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 610 + log.Println("failed to decode XRPC response", err) 611 rp.pages.Error503(w) 612 return 613 } 614 ··· 618 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 619 LoggedInUser: user, 620 RepoInfo: f.RepoInfo(user), 621 + RepoBranchesResponse: result, 622 }) 623 } 624 ··· 630 } 631 632 ref := chi.URLParam(r, "ref") 633 + ref, _ = url.PathUnescape(ref) 634 + 635 filePath := chi.URLParam(r, "*") 636 + filePath, _ = url.PathUnescape(filePath) 637 + 638 + scheme := "http" 639 if !rp.config.Core.Dev { 640 + scheme = "https" 641 } 642 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 643 + xrpcc := &indigoxrpc.Client{ 644 + Host: host, 645 } 646 647 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 648 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 649 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 650 + log.Println("failed to call XRPC repo.blob", xrpcerr) 651 + rp.pages.Error503(w) 652 return 653 } 654 655 + // Use XRPC response directly instead of converting to internal types 656 657 var breadcrumbs [][]string 658 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 659 if filePath != "" { 660 for idx, elem := range strings.Split(filePath, "/") { 661 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 662 } 663 } 664 665 showRendered := false 666 renderToggle := false 667 668 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 669 renderToggle = true 670 showRendered = r.URL.Query().Get("code") != "true" 671 } ··· 675 var isVideo bool 676 var contentSrc string 677 678 + if resp.IsBinary != nil && *resp.IsBinary { 679 + ext := strings.ToLower(filepath.Ext(resp.Path)) 680 switch ext { 681 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 682 isImage = true ··· 686 unsupported = true 687 } 688 689 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 690 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 691 692 + baseURL := &url.URL{ 693 + Scheme: scheme, 694 + Host: f.Knot, 695 + Path: "/xrpc/sh.tangled.repo.blob", 696 + } 697 + query := baseURL.Query() 698 + query.Set("repo", repoName) 699 + query.Set("ref", ref) 700 + query.Set("path", filePath) 701 + query.Set("raw", "true") 702 + baseURL.RawQuery = query.Encode() 703 + blobURL := baseURL.String() 704 + 705 contentSrc = blobURL 706 if !rp.config.Core.Dev { 707 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 708 } 709 } 710 711 + lines := 0 712 + if resp.IsBinary == nil || !*resp.IsBinary { 713 + lines = strings.Count(resp.Content, "\n") + 1 714 + } 715 + 716 + var sizeHint uint64 717 + if resp.Size != nil { 718 + sizeHint = uint64(*resp.Size) 719 + } else { 720 + sizeHint = uint64(len(resp.Content)) 721 + } 722 + 723 user := rp.oauth.GetUser(r) 724 + 725 + // Determine if content is binary (dereference pointer) 726 + isBinary := false 727 + if resp.IsBinary != nil { 728 + isBinary = *resp.IsBinary 729 + } 730 + 731 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 732 + LoggedInUser: user, 733 + RepoInfo: f.RepoInfo(user), 734 + BreadCrumbs: breadcrumbs, 735 + ShowRendered: showRendered, 736 + RenderToggle: renderToggle, 737 + Unsupported: unsupported, 738 + IsImage: isImage, 739 + IsVideo: isVideo, 740 + ContentSrc: contentSrc, 741 + RepoBlob_Output: resp, 742 + Contents: resp.Content, 743 + Lines: lines, 744 + SizeHint: sizeHint, 745 + IsBinary: isBinary, 746 }) 747 } 748 ··· 755 } 756 757 ref := chi.URLParam(r, "ref") 758 + ref, _ = url.PathUnescape(ref) 759 + 760 filePath := chi.URLParam(r, "*") 761 + filePath, _ = url.PathUnescape(filePath) 762 763 + scheme := "http" 764 if !rp.config.Core.Dev { 765 + scheme = "https" 766 } 767 768 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 769 + baseURL := &url.URL{ 770 + Scheme: scheme, 771 + Host: f.Knot, 772 + Path: "/xrpc/sh.tangled.repo.blob", 773 + } 774 + query := baseURL.Query() 775 + query.Set("repo", repo) 776 + query.Set("ref", ref) 777 + query.Set("path", filePath) 778 + query.Set("raw", "true") 779 + baseURL.RawQuery = query.Encode() 780 + blobURL := baseURL.String() 781 782 req, err := http.NewRequest("GET", blobURL, nil) 783 if err != nil { ··· 820 return 821 } 822 823 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 824 + // serve all textual content as text/plain 825 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 826 w.Write(body) 827 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 828 + // serve images and videos with their original content type 829 w.Header().Set("Content-Type", contentType) 830 w.Write(body) 831 } else { ··· 833 w.Write([]byte("unsupported content type")) 834 return 835 } 836 + } 837 + 838 + // isTextualMimeType returns true if the MIME type represents textual content 839 + // that should be served as text/plain 840 + func isTextualMimeType(mimeType string) bool { 841 + textualTypes := []string{ 842 + "application/json", 843 + "application/xml", 844 + "application/yaml", 845 + "application/x-yaml", 846 + "application/toml", 847 + "application/javascript", 848 + "application/ecmascript", 849 + "message/", 850 + } 851 + 852 + return slices.Contains(textualTypes, mimeType) 853 } 854 855 // modify the spindle configured for this repo ··· 1355 f, err := rp.repoResolver.Resolve(r) 1356 user := rp.oauth.GetUser(r) 1357 1358 + scheme := "http" 1359 + if !rp.config.Core.Dev { 1360 + scheme = "https" 1361 + } 1362 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1363 + xrpcc := &indigoxrpc.Client{ 1364 + Host: host, 1365 + } 1366 + 1367 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1368 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1369 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1370 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1371 + rp.pages.Error503(w) 1372 return 1373 } 1374 1375 + var result types.RepoBranchesResponse 1376 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1377 + log.Println("failed to decode XRPC response", err) 1378 rp.pages.Error503(w) 1379 return 1380 } 1381 ··· 1469 1470 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1471 ref := chi.URLParam(r, "ref") 1472 + ref, _ = url.PathUnescape(ref) 1473 1474 user := rp.oauth.GetUser(r) 1475 f, err := rp.repoResolver.Resolve(r) ··· 1747 return 1748 } 1749 1750 + scheme := "http" 1751 + if !rp.config.Core.Dev { 1752 + scheme = "https" 1753 + } 1754 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1755 + xrpcc := &indigoxrpc.Client{ 1756 + Host: host, 1757 + } 1758 + 1759 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1760 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1761 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1762 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1763 rp.pages.Error503(w) 1764 return 1765 } 1766 1767 + var branchResult types.RepoBranchesResponse 1768 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1769 + log.Println("failed to decode XRPC branches response", err) 1770 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1771 return 1772 } 1773 + branches := branchResult.Branches 1774 1775 sortBranches(branches) 1776 ··· 1794 head = queryHead 1795 } 1796 1797 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1798 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1799 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1800 + rp.pages.Error503(w) 1801 + return 1802 + } 1803 + 1804 + var tags types.RepoTagsResponse 1805 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1806 + log.Println("failed to decode XRPC tags response", err) 1807 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1808 return 1809 } 1810 ··· 1856 return 1857 } 1858 1859 + scheme := "http" 1860 + if !rp.config.Core.Dev { 1861 + scheme = "https" 1862 + } 1863 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1864 + xrpcc := &indigoxrpc.Client{ 1865 + Host: host, 1866 + } 1867 + 1868 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1869 + 1870 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1871 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1872 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1873 rp.pages.Error503(w) 1874 return 1875 } 1876 1877 + var branches types.RepoBranchesResponse 1878 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1879 + log.Println("failed to decode XRPC branches response", err) 1880 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1881 + return 1882 + } 1883 + 1884 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1885 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1886 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1887 + rp.pages.Error503(w) 1888 return 1889 } 1890 1891 + var tags types.RepoTagsResponse 1892 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1893 + log.Println("failed to decode XRPC tags response", err) 1894 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1895 return 1896 } 1897 1898 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1899 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1900 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1901 + rp.pages.Error503(w) 1902 + return 1903 + } 1904 + 1905 + var formatPatch types.RepoFormatPatchResponse 1906 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1907 + log.Println("failed to decode XRPC compare response", err) 1908 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1909 return 1910 } 1911 + 1912 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1913 1914 repoinfo := f.RepoInfo(user)
+11 -27
appview/serververify/verify.go
··· 4 "context" 5 "errors" 6 "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/rbac" 14 ) 15 ··· 24 scheme = "http" 25 } 26 27 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 28 - req, err := http.NewRequest("GET", url, nil) 29 - if err != nil { 30 - return "", err 31 - } 32 - 33 - client := &http.Client{ 34 - Timeout: 1 * time.Second, 35 - } 36 - 37 - resp, err := client.Do(req.WithContext(ctx)) 38 - if err != nil || resp.StatusCode != 200 { 39 - return "", fmt.Errorf("failed to fetch /owner") 40 } 41 42 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 43 - if err != nil { 44 - return "", fmt.Errorf("failed to read /owner response: %w", err) 45 } 46 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 } 54 55 type OwnerMismatch struct { ··· 65 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 observedOwner, err := fetchOwner(ctx, domain, dev) 67 if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 } 70 71 if observedOwner != expectedOwner {
··· 4 "context" 5 "errors" 6 "fmt" 7 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 "tangled.sh/tangled.sh/core/rbac" 13 ) 14 ··· 23 scheme = "http" 24 } 25 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 29 } 30 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 34 } 35 36 + return res.Owner, nil 37 } 38 39 type OwnerMismatch struct { ··· 49 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 50 observedOwner, err := fetchOwner(ctx, domain, dev) 51 if err != nil { 52 + return err 53 } 54 55 if observedOwner != expectedOwner {
+4 -3
appview/spindles/spindles.go
··· 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 404 if err != nil { 405 l.Error("verification failed", "err", err) 406 407 - if errors.Is(err, serververify.FetchError) { 408 - s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 409 return 410 } 411 ··· 442 } 443 444 w.Header().Set("HX-Reswap", "outerHTML") 445 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 446 } 447 448 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
··· 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 "tangled.sh/tangled.sh/core/tid" ··· 405 if err != nil { 406 l.Error("verification failed", "err", err) 407 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!") 410 return 411 } 412 ··· 443 } 444 445 w.Header().Set("HX-Reswap", "outerHTML") 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 447 } 448 449 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
+197 -137
appview/state/profile.go
··· 17 "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 ) 23 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 tabVal := r.URL.Query().Get("tab") 26 switch tabVal { 27 - case "": 28 - s.profileHomePage(w, r) 29 case "repos": 30 s.reposPage(w, r) 31 case "followers": 32 s.followersPage(w, r) 33 case "following": 34 s.followingPage(w, r) 35 } 36 } 37 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 { 45 didOrHandle := chi.URLParam(r, "user") 46 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 49 } 50 51 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 56 } 57 did := ident.DID.String() 58 59 profile, err := db.GetProfile(s.db, did) 60 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 64 } 65 66 followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 69 } 70 71 loggedInUser := s.oauth.GetUser(r) ··· 74 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 } 76 77 - return &ProfilePageParams{ 78 - Id: ident, 79 - LoggedInUser: loggedInUser, 80 - Card: pages.ProfileCard{ 81 - UserDid: did, 82 - UserHandle: ident.Handle.String(), 83 - Profile: profile, 84 - FollowStatus: followStatus, 85 FollowersCount: followStats.Followers, 86 FollowingCount: followStats.Following, 87 }, 88 - } 89 } 90 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 94 return 95 } 96 97 - id := pageWithProfile.Id 98 repos, err := db.GetRepos( 99 s.db, 100 0, 101 - db.FilterEq("did", id.DID), 102 ) 103 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 105 } 106 107 - profile := pageWithProfile.Card.Profile 108 // filter out ones that are pinned 109 pinnedRepos := []db.Repo{} 110 for i, r := range repos { 111 // if this is a pinned repo, add it 112 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 113 pinnedRepos = append(pinnedRepos, r) 114 } 115 116 // if there are no saved pins, add the first 4 repos 117 - if profile.IsPinnedReposEmpty() && i < 4 { 118 pinnedRepos = append(pinnedRepos, r) 119 } 120 } 121 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 123 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 125 } 126 127 pinnedCollaboratingRepos := []db.Repo{} 128 for _, r := range collaboratingRepos { 129 // if this is a pinned repo, add it 130 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 131 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 132 } 133 } 134 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 136 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 138 } 139 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 143 } 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 - } 158 159 - now := time.Now() 160 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 - punchcard, err := db.MakePunchcard( 162 s.db, 163 - db.FilterEq("did", id.DID), 164 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 - db.FilterLte("date", now.Format(time.DateOnly)), 166 ) 167 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 169 } 170 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, 178 }) 179 } 180 181 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 184 return 185 } 186 187 - id := pageWithProfile.Id 188 repos, err := db.GetRepos( 189 s.db, 190 0, 191 - db.FilterEq("did", id.DID), 192 ) 193 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 195 } 196 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 199 Repos: repos, 200 - Card: pageWithProfile.Card, 201 }) 202 } 203 204 type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 208 } 209 210 - func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 - pageWithProfile := s.profilePage(w, r) 212 - if pageWithProfile == nil { 213 - return FollowsPageParams{}, nil 214 } 215 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 218 219 - follows, err := fetchFollows(s.db, id.DID.String()) 220 if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 223 } 224 225 if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 231 } 232 233 followDids := make([]string, 0, len(follows)) ··· 237 238 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 242 } 243 244 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 246 log.Printf("getting follow counts for %s: %s", followDids, err) 247 } 248 249 - var loggedInUserFollowing map[string]struct{} 250 if loggedInUser != nil { 251 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 if err != nil { 253 - return FollowsPageParams{}, err 254 } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 260 } 261 } 262 263 - followCards := make([]pages.FollowCard, 0, len(follows)) 264 - for _, did := range followDids { 265 - followStats, exists := followStatsMap[did] 266 - if !exists { 267 - followStats = db.FollowStats{} 268 - } 269 followStatus := db.IsNotFollowing 270 - if loggedInUserFollowing != nil { 271 - if _, exists := loggedInUserFollowing[did]; exists { 272 - followStatus = db.IsFollowing 273 - } else if loggedInUser.Did == did { 274 - followStatus = db.IsSelf 275 - } 276 } 277 var profile *db.Profile 278 if p, exists := profiles[did]; exists { 279 profile = p ··· 281 profile = &db.Profile{} 282 profile.Did = did 283 } 284 - followCards = append(followCards, pages.FollowCard{ 285 UserDid: did, 286 FollowStatus: followStatus, 287 FollowersCount: followStats.Followers, 288 FollowingCount: followStats.Following, 289 Profile: profile, 290 - }) 291 } 292 293 - return FollowsPageParams{ 294 - LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 298 } 299 300 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 - followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 if err != nil { 303 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 return 305 } 306 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 309 Followers: followPage.Follows, 310 Card: followPage.Card, 311 }) 312 } 313 314 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 - followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 if err != nil { 317 s.pages.Notice(w, "all-following", "Failed to load following") 318 return 319 } 320 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 323 Following: followPage.Follows, 324 Card: followPage.Card, 325 }) ··· 408 409 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 for _, issue := range issues { 411 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 412 if err != nil { 413 return err 414 } ··· 440 441 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 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"}, 445 Created: issue.Created, 446 Author: author, 447 } ··· 642 log.Printf("getting profile data for %s: %s", user.Did, err) 643 } 644 645 - repos, err := db.GetAllReposByDid(s.db, user.Did) 646 if err != nil { 647 log.Printf("getting repos for %s: %s", user.Did, err) 648 }
··· 17 "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 ) 22 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 tabVal := r.URL.Query().Get("tab") 25 switch tabVal { 26 case "repos": 27 s.reposPage(w, r) 28 case "followers": 29 s.followersPage(w, r) 30 case "following": 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) 38 } 39 } 40 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 42 didOrHandle := chi.URLParam(r, "user") 43 if didOrHandle == "" { 44 + return nil, fmt.Errorf("empty DID or handle") 45 } 46 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 48 if !ok { 49 + return nil, fmt.Errorf("failed to resolve ID") 50 } 51 did := ident.DID.String() 52 53 profile, err := db.GetProfile(s.db, did) 54 if err != 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) 71 } 72 73 followStats, err := db.GetFollowerFollowingCount(s.db, did) 74 if err != nil { 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 76 } 77 78 loggedInUser := s.oauth.GetUser(r) ··· 81 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 } 83 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, 105 FollowersCount: followStats.Followers, 106 FollowingCount: followStats.Following, 107 }, 108 + Punchcard: punchcard, 109 + }, nil 110 } 111 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) 119 return 120 } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 123 repos, err := db.GetRepos( 124 s.db, 125 0, 126 + db.FilterEq("did", profile.UserDid), 127 ) 128 if err != nil { 129 + l.Error("failed to fetch repos", "err", err) 130 } 131 132 // filter out ones that are pinned 133 pinnedRepos := []db.Repo{} 134 for i, r := range repos { 135 // if this is a pinned repo, add it 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 137 pinnedRepos = append(pinnedRepos, r) 138 } 139 140 // if there are no saved pins, add the first 4 repos 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 142 pinnedRepos = append(pinnedRepos, r) 143 } 144 } 145 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 147 if err != nil { 148 + l.Error("failed to fetch collaborating repos", "err", err) 149 } 150 151 pinnedCollaboratingRepos := []db.Repo{} 152 for _, r := range collaboratingRepos { 153 // if this is a pinned repo, add it 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 156 } 157 } 158 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 160 if err != nil { 161 + l.Error("failed to create timeline", "err", err) 162 } 163 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 181 } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 183 184 + repos, err := db.GetRepos( 185 s.db, 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 188 ) 189 if err != nil { 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 193 } 194 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 199 }) 200 } 201 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) 209 return 210 } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 212 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 + 224 repos, err := db.GetRepos( 225 s.db, 226 0, 227 + db.FilterIn("at_uri", repoAts), 228 ) 229 if err != nil { 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 233 } 234 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 237 Repos: repos, 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, 264 }) 265 } 266 267 type FollowsPageParams struct { 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 270 } 271 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 282 } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 284 285 + loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 289 290 + follows, err := fetchFollows(s.db, profile.UserDid) 291 if err != nil { 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 294 } 295 296 if len(follows) == 0 { 297 + return &params, nil 298 } 299 300 followDids := make([]string, 0, len(follows)) ··· 304 305 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 306 if err != nil { 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 309 } 310 311 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 313 log.Printf("getting follow counts for %s: %s", followDids, err) 314 } 315 316 + loggedInUserFollowing := make(map[string]struct{}) 317 if loggedInUser != nil { 318 following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 if err != nil { 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 322 } 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 326 } 327 } 328 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 332 followStatus := db.IsNotFollowing 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 337 } 338 + 339 var profile *db.Profile 340 if p, exists := profiles[did]; exists { 341 profile = p ··· 343 profile = &db.Profile{} 344 profile.Did = did 345 } 346 + followCards[i] = pages.FollowCard{ 347 UserDid: did, 348 FollowStatus: followStatus, 349 FollowersCount: followStats.Followers, 350 FollowingCount: followStats.Following, 351 Profile: profile, 352 + } 353 } 354 355 + params.Follows = followCards 356 + 357 + return &params, nil 358 } 359 360 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 362 if err != nil { 363 s.pages.Notice(w, "all-followers", "Failed to load followers") 364 return 365 } 366 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 369 Followers: followPage.Follows, 370 Card: followPage.Card, 371 }) 372 } 373 374 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 376 if err != nil { 377 s.pages.Notice(w, "all-following", "Failed to load following") 378 return 379 } 380 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 383 Following: followPage.Follows, 384 Card: followPage.Card, 385 }) ··· 468 469 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 470 for _, issue := range issues { 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 if err != nil { 473 return err 474 } ··· 500 501 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 return &feeds.Item{ 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"}, 505 Created: issue.Created, 506 Author: author, 507 } ··· 702 log.Printf("getting profile data for %s: %s", user.Did, err) 703 } 704 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 706 if err != nil { 707 log.Printf("getting repos for %s: %s", user.Did, err) 708 }
+4 -2
appview/state/router.go
··· 111 112 r.Handle("/static/*", s.pages.Static()) 113 114 - r.Get("/", s.Timeline) 115 116 r.Route("/repo", func(r chi.Router) { 117 r.Route("/new", func(r chi.Router) { ··· 230 } 231 232 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) 234 return issues.Router(mw) 235 } 236
··· 111 112 r.Handle("/static/*", s.pages.Static()) 113 114 + r.Get("/", s.HomeOrTimeline) 115 + r.Get("/timeline", s.Timeline) 116 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 117 118 r.Route("/repo", func(r chi.Router) { 119 r.Route("/new", func(r chi.Router) { ··· 232 } 233 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 236 return issues.Router(mw) 237 } 238
+76 -4
appview/state/state.go
··· 28 "tangled.sh/tangled.sh/core/appview/pages" 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" ··· 53 knotstream *eventconsumer.Consumer 54 spindlestream *eventconsumer.Consumer 55 logger *slog.Logger 56 } 57 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 73 } 74 75 pgs := pages.NewPages(config, res) 76 - 77 cache := cache.New(config.Redis.Addr) 78 sess := session.New(cache) 79 - 80 oauth := oauth.NewOAuth(config, sess) 81 82 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 83 if err != nil { ··· 121 IdResolver: res, 122 Config: config, 123 Logger: tlog.New("ingester"), 124 } 125 err = jc.StartJetstream(ctx, ingester.Ingest()) 126 if err != nil { ··· 160 knotstream, 161 spindlestream, 162 slog.Default(), 163 } 164 165 return state, nil 166 } 167 168 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 169 w.Header().Set("Content-Type", "image/svg+xml") 170 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 190 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 191 LoggedInUser: user, 192 }) 193 } 194 195 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 196 user := s.oauth.GetUser(r) 197 198 - timeline, err := db.MakeTimeline(s.db) 199 if err != nil { 200 log.Println(err) 201 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 215 }) 216 } 217 218 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 219 user := chi.URLParam(r, "user") 220 user = strings.TrimPrefix(user, "@") ··· 243 244 for _, k := range pubKeys { 245 key := strings.TrimRight(k.Key, "\n") 246 - w.Write([]byte(fmt.Sprintln(key))) 247 } 248 } 249
··· 28 "tangled.sh/tangled.sh/core/appview/pages" 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 "tangled.sh/tangled.sh/core/idresolver" ··· 54 knotstream *eventconsumer.Consumer 55 spindlestream *eventconsumer.Consumer 56 logger *slog.Logger 57 + validator *validator.Validator 58 } 59 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 75 } 76 77 pgs := pages.NewPages(config, res) 78 cache := cache.New(config.Redis.Addr) 79 sess := session.New(cache) 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 if err != nil { ··· 122 IdResolver: res, 123 Config: config, 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 126 } 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 128 if err != nil { ··· 162 knotstream, 163 spindlestream, 164 slog.Default(), 165 + validator, 166 } 167 168 return state, nil 169 } 170 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 174 + } 175 + 176 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 177 w.Header().Set("Content-Type", "image/svg+xml") 178 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 198 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 199 LoggedInUser: user, 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) 209 } 210 211 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 212 user := s.oauth.GetUser(r) 213 214 + timeline, err := db.MakeTimeline(s.db, 50) 215 if err != nil { 216 log.Println(err) 217 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 231 }) 232 } 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 + 290 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 291 user := chi.URLParam(r, "user") 292 user = strings.TrimPrefix(user, "@") ··· 315 316 for _, k := range pubKeys { 317 key := strings.TrimRight(k.Key, "\n") 318 + fmt.Fprintln(w, key) 319 } 320 } 321
+9 -59
appview/strings/strings.go
··· 5 "log/slog" 6 "net/http" 7 "path" 8 - "slices" 9 "strconv" 10 "time" 11 ··· 13 "tangled.sh/tangled.sh/core/appview/config" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/middleware" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 37 IdResolver *idresolver.Resolver 38 Logger *slog.Logger 39 Knotstream *eventconsumer.Consumer 40 } 41 42 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { ··· 161 } 162 163 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 - }) 222 } 223 224 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { ··· 342 return 343 } 344 345 // if that went okay, redir to the string 346 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 347 } ··· 416 return 417 } 418 419 // successful 420 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 421 } ··· 457 fail("Failed to delete string.", err) 458 return 459 } 460 461 s.Pages.HxRedirect(w, "/strings/"+user.Handle) 462 }
··· 5 "log/slog" 6 "net/http" 7 "path" 8 "strconv" 9 "time" 10 ··· 12 "tangled.sh/tangled.sh/core/appview/config" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/middleware" 15 + "tangled.sh/tangled.sh/core/appview/notify" 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 37 IdResolver *idresolver.Resolver 38 Logger *slog.Logger 39 Knotstream *eventconsumer.Consumer 40 + Notifier notify.Notifier 41 } 42 43 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { ··· 162 } 163 164 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 165 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 166 } 167 168 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { ··· 286 return 287 } 288 289 + s.Notifier.EditString(r.Context(), &entry) 290 + 291 // if that went okay, redir to the string 292 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 293 } ··· 362 return 363 } 364 365 + s.Notifier.NewString(r.Context(), &string) 366 + 367 // successful 368 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 369 } ··· 405 fail("Failed to delete string.", err) 406 return 407 } 408 + 409 + s.Notifier.DeleteString(r.Context(), user.Did, rkey) 410 411 s.Pages.HxRedirect(w, "/strings/"+user.Handle) 412 }
+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 "bytes" 5 "context" 6 "errors" 7 - "fmt" 8 "io" 9 "net/http" 10 ··· 12 "github.com/bluesky-social/indigo/xrpc" 13 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 15 ) 16 17 type Client struct { ··· 115 116 var xrpcerr *indigoxrpc.Error 117 if ok := errors.As(err, &xrpcerr); !ok { 118 - return fmt.Errorf("Recieved invalid XRPC error response.") 119 } 120 121 switch xrpcerr.StatusCode { 122 case http.StatusNotFound: 123 - return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 case http.StatusUnauthorized: 125 - return fmt.Errorf("Unauthorized XRPC request.") 126 default: 127 - return fmt.Errorf("Failed to perform operation. Try again later.") 128 } 129 }
··· 4 "bytes" 5 "context" 6 "errors" 7 "io" 8 "net/http" 9 ··· 11 "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 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") 21 ) 22 23 type Client struct { ··· 121 122 var xrpcerr *indigoxrpc.Error 123 if ok := errors.As(err, &xrpcerr); !ok { 124 + return ErrXrpcInvalid 125 } 126 127 switch xrpcerr.StatusCode { 128 case http.StatusNotFound: 129 + return ErrXrpcUnsupported 130 case http.StatusUnauthorized: 131 + return ErrXrpcUnauthorized 132 default: 133 + return ErrXrpcFailed 134 } 135 }
+3
cmd/appview/main.go
··· 23 } 24 25 state, err := state.Make(ctx, c) 26 27 if err != nil { 28 log.Fatal(err)
··· 23 } 24 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 29 30 if err != nil { 31 log.Fatal(err)
+44
contrib/Tiltfile
···
··· 1 + common_env = { 2 + "TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""), 3 + "TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""), 4 + "TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"), 5 + "TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"), 6 + } 7 + 8 + nix_globs = ["nix/**", "flake.nix", "flake.lock"] 9 + 10 + local_resource( 11 + name="appview", 12 + serve_cmd="nix run .#watch-appview", 13 + serve_dir="..", 14 + deps=nix_globs, 15 + env=common_env, 16 + allow_parallel=True, 17 + ) 18 + 19 + local_resource( 20 + name="tailwind", 21 + serve_cmd="nix run .#watch-tailwind", 22 + serve_dir="..", 23 + deps=nix_globs, 24 + env=common_env, 25 + allow_parallel=True, 26 + ) 27 + 28 + local_resource( 29 + name="redis", 30 + serve_cmd="redis-server", 31 + serve_dir="..", 32 + deps=nix_globs, 33 + env=common_env, 34 + allow_parallel=True, 35 + ) 36 + 37 + local_resource( 38 + name="vm", 39 + serve_cmd="nix run --impure .#vm", 40 + serve_dir="..", 41 + deps=nix_globs, 42 + env=common_env, 43 + allow_parallel=True, 44 + )
+16
default.nix
···
··· 1 + # Default setup from https://git.lix.systems/lix-project/flake-compat 2 + let 3 + lockFile = builtins.fromJSON (builtins.readFile ./flake.lock); 4 + flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat}; 5 + flake-compat = builtins.fetchTarball { 6 + inherit (flake-compat-node.locked) url; 7 + sha256 = flake-compat-node.locked.narHash; 8 + }; 9 + 10 + flake = ( 11 + import flake-compat { 12 + src = ./.; 13 + } 14 + ); 15 + in 16 + flake.defaultNix
+53 -12
docs/hacking.md
··· 48 redis-server 49 ``` 50 51 - ## running a knot 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 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. 61 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 64 65 - You can now start a lightweight NixOS VM like so: 66 67 ```bash 68 nix run --impure .#vm ··· 74 with `ssh` exposed on port 2222. 75 76 Once the services are running, head to 77 - http://localhost:3000/knots and hit verify (and similarly, 78 - http://localhost:3000/spindles to verify your spindle). It 79 - should verify the ownership of the services instantly if 80 - everything went smoothly. 81 82 You can push repositories to this VM with this ssh config 83 block on your main machine: ··· 97 git push local-dev main 98 ``` 99 100 - ## running a spindle 101 102 The above VM should already be running a spindle on 103 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 119 # litecli has a nicer REPL interface: 120 litecli /var/lib/spindle/spindle.db 121 ```
··· 48 redis-server 49 ``` 50 51 + ## running knots and spindles 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 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`. 90 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). 96 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: 103 104 ```bash 105 nix run --impure .#vm ··· 111 with `ssh` exposed on port 2222. 112 113 Once the services are running, head to 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 117 118 You can push repositories to this VM with this ssh config 119 block on your main machine: ··· 133 git push local-dev main 134 ``` 135 136 + ### running a spindle 137 138 The above VM should already be running a spindle on 139 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 155 # litecli has a nicer REPL interface: 156 litecli /var/lib/spindle/spindle.db 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 +
+15
flake.lock
··· 1 { 2 "nodes": { 3 "flake-utils": { 4 "inputs": { 5 "systems": "systems" ··· 136 }, 137 "root": { 138 "inputs": { 139 "gomod2nix": "gomod2nix", 140 "htmx-src": "htmx-src", 141 "htmx-ws-src": "htmx-ws-src",
··· 1 { 2 "nodes": { 3 + "flake-compat": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1751685974, 7 + "narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=", 8 + "rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1", 9 + "type": "tarball", 10 + "url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz" 15 + } 16 + }, 17 "flake-utils": { 18 "inputs": { 19 "systems": "systems" ··· 150 }, 151 "root": { 152 "inputs": { 153 + "flake-compat": "flake-compat", 154 "gomod2nix": "gomod2nix", 155 "htmx-src": "htmx-src", 156 "htmx-ws-src": "htmx-ws-src",
+7 -1
flake.nix
··· 7 url = "github:nix-community/gomod2nix"; 8 inputs.nixpkgs.follows = "nixpkgs"; 9 }; 10 indigo = { 11 url = "github:oppiliappan/indigo"; 12 flake = false; ··· 50 inter-fonts-src, 51 sqlite-lib-src, 52 ibm-plex-mono-src, 53 }: let 54 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 146 nativeBuildInputs = [ 147 pkgs.go 148 pkgs.air 149 pkgs.gopls 150 pkgs.httpie 151 pkgs.litecli ··· 182 tailwind-watcher = 183 pkgs.writeShellScriptBin "run" 184 '' 185 - ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 186 ''; 187 in { 188 fmt = {
··· 7 url = "github:nix-community/gomod2nix"; 8 inputs.nixpkgs.follows = "nixpkgs"; 9 }; 10 + flake-compat = { 11 + url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"; 12 + flake = false; 13 + }; 14 indigo = { 15 url = "github:oppiliappan/indigo"; 16 flake = false; ··· 54 inter-fonts-src, 55 sqlite-lib-src, 56 ibm-plex-mono-src, 57 + ... 58 }: let 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 151 nativeBuildInputs = [ 152 pkgs.go 153 pkgs.air 154 + pkgs.tilt 155 pkgs.gopls 156 pkgs.httpie 157 pkgs.litecli ··· 188 tailwind-watcher = 189 pkgs.writeShellScriptBin "run" 190 '' 191 + ${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css 192 ''; 193 in { 194 fmt = {
+3 -6
input.css
··· 90 } 91 92 label { 93 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 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; ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 - background-color: #bcc0cc; 232 } 233 /* LineNumbersTable */ 234 .chroma .lnt { 235 white-space: pre; ··· 864 text-decoration: underline; 865 } 866 } 867 - 868 - .chroma .line:has(.ln:target) { 869 - @apply bg-amber-400/30 dark:bg-amber-500/20; 870 - }
··· 90 } 91 92 label { 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 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; ··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 + 234 /* LineNumbersTable */ 235 .chroma .lnt { 236 white-space: pre; ··· 865 text-decoration: underline; 866 } 867 }
-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 Dev bool `env:"DEV, default=false"` 28 } 29 30 func (s Server) Did() syntax.DID { 31 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 } ··· 34 type Config struct { 35 Repo Repo `env:",prefix=KNOT_REPO_"` 36 Server Server `env:",prefix=KNOT_SERVER_"` 37 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 38 } 39
··· 27 Dev bool `env:"DEV, default=false"` 28 } 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 + 36 func (s Server) Did() syntax.DID { 37 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 38 } ··· 40 type Config struct { 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 44 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46
+40
knotserver/db/pubkeys.go
··· 1 package db 2 3 import ( 4 "time" 5 6 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 return keys, nil 101 }
··· 1 package db 2 3 import ( 4 + "strconv" 5 "time" 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 100 101 return keys, nil 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 WriteBufferSize: 1024, 16 } 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 19 l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 ··· 83 } 84 } 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 87 events, err := h.db.GetEvents(*cursor) 88 if err != nil { 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
··· 15 WriteBufferSize: 1024, 16 } 17 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 ··· 83 } 84 } 85 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 events, err := h.db.GetEvents(*cursor) 88 if err != nil { 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 - }
···
+4 -1
knotserver/git/language.go
··· 3 import ( 4 "context" 5 "path" 6 7 "github.com/go-enry/go-enry/v2" 8 "github.com/go-git/go-git/v5/plumbing/object" ··· 20 return nil 21 } 22 23 - if enry.IsGenerated(filepath, content) { 24 return nil 25 } 26
··· 3 import ( 4 "context" 5 "path" 6 + "strings" 7 8 "github.com/go-enry/go-enry/v2" 9 "github.com/go-git/go-git/v5/plumbing/object" ··· 21 return nil 22 } 23 24 + if enry.IsGenerated(filepath, content) || 25 + enry.IsBinary(content) || 26 + strings.HasSuffix(filepath, "bun.lock") { 27 return nil 28 } 29
+58 -72
knotserver/git/merge.go
··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 ) 17 18 type MergeCheckCache struct { ··· 86 87 // MergeOptions specifies the configuration for a merge operation 88 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 94 } 95 96 func (e ErrMerge) Error() string { ··· 143 return tmpDir, nil 144 } 145 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 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 161 } 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 - } 171 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 - } 176 177 - commitArgs := []string{"-C", tmpDir, "commit"} 178 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 182 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 186 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 190 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 194 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 200 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) 205 } 206 } 207 208 cmd.Stderr = &stderr 209 210 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 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 } 222 ··· 227 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 return val 229 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 234 patchFile, err := g.createTempFileWithPatch(patchData) 235 if err != nil { ··· 249 } 250 defer os.RemoveAll(tmpDir) 251 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 253 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 return result 255 } 256 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 { 262 patchFile, err := g.createTempFileWithPatch(patchData) 263 if err != nil { 264 return &ErrMerge{ ··· 277 } 278 defer os.RemoveAll(tmpDir) 279 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 281 return err 282 } 283
··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 85 86 // MergeOptions specifies the configuration for a merge operation 87 type MergeOptions struct { 88 + CommitMessage string 89 + CommitBody string 90 + AuthorName string 91 + AuthorEmail string 92 + CommitterName string 93 + CommitterEmail string 94 + FormatPatch bool 95 } 96 97 func (e ErrMerge) Error() string { ··· 144 return tmpDir, nil 145 } 146 147 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 148 var stderr bytes.Buffer 149 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, 160 } 161 + } 162 + return nil 163 + } 164 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 + var stderr bytes.Buffer 167 + var cmd *exec.Cmd 168 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() 173 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 + } 184 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 + } 189 190 + commitArgs := []string{"-C", tmpDir, "commit"} 191 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 195 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 200 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 } 206 + 207 + cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 212 if err := cmd.Run(); err != nil { 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 214 } 215 ··· 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } 223 224 patchFile, err := g.createTempFileWithPatch(patchData) 225 if err != nil { ··· 239 } 240 defer os.RemoveAll(tmpDir) 241 242 + result := g.checkPatch(tmpDir, patchFile) 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 244 return result 245 } 246 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
+4 -4
knotserver/git.go
··· 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 } 57 } 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 } 106 } 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 d.RejectPush(w, r, name) 119 } 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8")
··· 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 } 57 } 58 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 } 106 } 107 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 d.RejectPush(w, r, name) 119 } 120 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 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 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 27 - func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 28 l := log.FromContext(ctx) 29 raw := json.RawMessage(event.Commit.Record) 30 did := event.Did ··· 46 return nil 47 } 48 49 - func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 50 l := log.FromContext(ctx) 51 raw := json.RawMessage(event.Commit.Record) 52 did := event.Did ··· 86 return nil 87 } 88 89 - func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 90 raw := json.RawMessage(event.Commit.Record) 91 did := event.Did 92 ··· 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) 101 l = l.With("target_repo", record.Target.Repo) 102 l = l.With("target_branch", record.Target.Branch) 103 ··· 214 } 215 216 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 218 raw := json.RawMessage(event.Commit.Record) 219 did := event.Did 220 ··· 275 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 276 } 277 278 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 279 l := log.FromContext(ctx) 280 281 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 318 return nil 319 } 320 321 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 322 if event.Kind != models.EventKindCommit { 323 return nil 324 }
··· 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 l := log.FromContext(ctx) 29 raw := json.RawMessage(event.Commit.Record) 30 did := event.Did ··· 46 return nil 47 } 48 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 l := log.FromContext(ctx) 51 raw := json.RawMessage(event.Commit.Record) 52 did := event.Did ··· 86 return nil 87 } 88 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 raw := json.RawMessage(event.Commit.Record) 91 did := event.Did 92 ··· 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 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 + 106 l = l.With("target_repo", record.Target.Repo) 107 l = l.With("target_branch", record.Target.Branch) 108 ··· 219 } 220 221 // duplicated from add collaborator 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 223 raw := json.RawMessage(event.Commit.Record) 224 did := event.Did 225 ··· 280 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 281 } 282 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 284 l := log.FromContext(ctx) 285 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 323 return nil 324 } 325 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 327 if event.Kind != models.EventKindCommit { 328 return nil 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 Usage: "run a knot server", 23 Action: Run, 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 - `, 38 } 39 } 40
··· 22 Usage: "run a knot server", 23 Action: Run, 24 Description: ` 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 + `, 41 } 42 } 43
+49
knotserver/xrpc/list_keys.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 12 + cursor := r.URL.Query().Get("cursor") 13 + 14 + limit := 100 // default 15 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 16 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 17 + limit = l 18 + } 19 + } 20 + 21 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 22 + if err != nil { 23 + x.Logger.Error("failed to get public keys", "error", err) 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InternalServerError"), 26 + xrpcerr.WithMessage("failed to retrieve public keys"), 27 + ), http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 32 + for _, key := range keys { 33 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 34 + Did: key.Did, 35 + Key: key.Key, 36 + CreatedAt: key.CreatedAt, 37 + }) 38 + } 39 + 40 + response := tangled.KnotListKeys_Output{ 41 + Keys: publicKeys, 42 + } 43 + 44 + if nextCursor != "" { 45 + response.Cursor = &nextCursor 46 + } 47 + 48 + writeJson(w, response) 49 + }
+3 -1
knotserver/xrpc/merge.go
··· 67 return 68 } 69 70 - mo := &git.MergeOptions{} 71 if data.AuthorName != nil { 72 mo.AuthorName = *data.AuthorName 73 } ··· 81 mo.CommitMessage = *data.CommitMessage 82 } 83 84 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 86 err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
··· 67 return 68 } 69 70 + mo := git.MergeOptions{} 71 if data.AuthorName != nil { 72 mo.AuthorName = *data.AuthorName 73 } ··· 81 mo.CommitMessage = *data.CommitMessage 82 } 83 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+22
knotserver/xrpc/owner.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/api/tangled" 7 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 + ) 9 + 10 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 11 + owner := x.Config.Server.Owner 12 + if owner == "" { 13 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 14 + return 15 + } 16 + 17 + response := tangled.Owner_Output{ 18 + Owner: owner, 19 + } 20 + 21 + writeJson(w, response) 22 + }
+81
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 := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + format := r.URL.Query().Get("format") 27 + if format == "" { 28 + format = "tar.gz" // default 29 + } 30 + 31 + prefix := r.URL.Query().Get("prefix") 32 + 33 + if format != "tar.gz" { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("InvalidRequest"), 36 + xrpcerr.WithMessage("only tar.gz format is supported"), 37 + ), http.StatusBadRequest) 38 + return 39 + } 40 + 41 + gr, err := git.Open(repoPath, ref) 42 + if err != nil { 43 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 + return 45 + } 46 + 47 + repoParts := strings.Split(repo, "/") 48 + repoName := repoParts[len(repoParts)-1] 49 + 50 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 51 + 52 + var archivePrefix string 53 + if prefix != "" { 54 + archivePrefix = prefix 55 + } else { 56 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 57 + } 58 + 59 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 60 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 61 + w.Header().Set("Content-Type", "application/gzip") 62 + 63 + gw := gzip.NewWriter(w) 64 + defer gw.Close() 65 + 66 + err = gr.WriteTar(gw, archivePrefix) 67 + if err != nil { 68 + // once we start writing to the body we can't report error anymore 69 + // so we are only left with logging the error 70 + x.Logger.Error("writing tar file", "error", err.Error()) 71 + return 72 + } 73 + 74 + err = gw.Flush() 75 + if err != nil { 76 + // once we start writing to the body we can't report error anymore 77 + // so we are only left with logging the error 78 + x.Logger.Error("flushing", "error", err.Error()) 79 + return 80 + } 81 + }
+143
knotserver/xrpc/repo_blob.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 18 + repo := r.URL.Query().Get("repo") 19 + repoPath, err := x.parseRepoParam(repo) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + ref := r.URL.Query().Get("ref") 26 + // ref can be empty (git.Open handles this) 27 + 28 + treePath := r.URL.Query().Get("path") 29 + if treePath == "" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("missing path parameter"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + raw := r.URL.Query().Get("raw") == "true" 38 + 39 + gr, err := git.Open(repoPath, ref) 40 + if err != nil { 41 + writeError(w, xrpcerr.RefNotFoundError, 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 + writeJson(w, response) 127 + } 128 + 129 + // isTextualMimeType returns true if the MIME type represents textual content 130 + // that should be served as text/plain for security reasons 131 + func isTextualMimeType(mimeType string) bool { 132 + textualTypes := []string{ 133 + "application/json", 134 + "application/xml", 135 + "application/yaml", 136 + "application/x-yaml", 137 + "application/toml", 138 + "application/javascript", 139 + "application/ecmascript", 140 + } 141 + 142 + return slices.Contains(textualTypes, mimeType) 143 + }
+85
knotserver/xrpc/repo_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "time" 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.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + ref, err := gr.Branch(branchName) 39 + if err != nil { 40 + x.Logger.Error("getting branch", "error", err.Error()) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("BranchNotFound"), 43 + xrpcerr.WithMessage("branch not found"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + commit, err := gr.Commit(ref.Hash()) 49 + if err != nil { 50 + x.Logger.Error("getting commit object", "error", err.Error()) 51 + writeError(w, xrpcerr.NewXrpcError( 52 + xrpcerr.WithTag("BranchNotFound"), 53 + xrpcerr.WithMessage("failed to get commit object"), 54 + ), http.StatusInternalServerError) 55 + return 56 + } 57 + 58 + defaultBranch, err := gr.FindMainBranch() 59 + isDefault := false 60 + if err != nil { 61 + x.Logger.Error("getting default branch", "error", err.Error()) 62 + } else if defaultBranch == branchName { 63 + isDefault = true 64 + } 65 + 66 + response := tangled.RepoBranch_Output{ 67 + Name: ref.Name().Short(), 68 + Hash: ref.Hash().String(), 69 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 70 + When: commit.Author.When.Format(time.RFC3339), 71 + IsDefault: &isDefault, 72 + } 73 + 74 + if commit.Message != "" { 75 + response.Message = &commit.Message 76 + } 77 + 78 + response.Author = &tangled.RepoBranch_Signature{ 79 + Name: commit.Author.Name, 80 + Email: commit.Author.Email, 81 + When: commit.Author.When.Format(time.RFC3339), 82 + } 83 + 84 + writeJson(w, response) 85 + }
+56
knotserver/xrpc/repo_branches.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoBranches(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 + cursor := r.URL.Query().Get("cursor") 21 + 22 + // limit := 50 // default 23 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 + // limit = l 26 + // } 27 + // } 28 + 29 + limit := 500 30 + 31 + gr, err := git.PlainOpen(repoPath) 32 + if err != nil { 33 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 34 + return 35 + } 36 + 37 + branches, _ := gr.Branches() 38 + 39 + offset := 0 40 + if cursor != "" { 41 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 + offset = o 43 + } 44 + } 45 + 46 + end := min(offset+limit, len(branches)) 47 + 48 + paginatedBranches := branches[offset:end] 49 + 50 + // Create response using existing types.RepoBranchesResponse 51 + response := types.RepoBranchesResponse{ 52 + Branches: paginatedBranches, 53 + } 54 + 55 + writeJson(w, response) 56 + }
+82
knotserver/xrpc/repo_compare.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoCompare(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 + rev1 := r.URL.Query().Get("rev1") 21 + if rev1 == "" { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("InvalidRequest"), 24 + xrpcerr.WithMessage("missing rev1 parameter"), 25 + ), http.StatusBadRequest) 26 + return 27 + } 28 + 29 + rev2 := r.URL.Query().Get("rev2") 30 + if rev2 == "" { 31 + writeError(w, xrpcerr.NewXrpcError( 32 + xrpcerr.WithTag("InvalidRequest"), 33 + xrpcerr.WithMessage("missing rev2 parameter"), 34 + ), http.StatusBadRequest) 35 + return 36 + } 37 + 38 + gr, err := git.PlainOpen(repoPath) 39 + if err != nil { 40 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 41 + return 42 + } 43 + 44 + commit1, err := gr.ResolveRevision(rev1) 45 + if err != nil { 46 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("RevisionNotFound"), 49 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 50 + ), http.StatusBadRequest) 51 + return 52 + } 53 + 54 + commit2, err := gr.ResolveRevision(rev2) 55 + if err != nil { 56 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 57 + writeError(w, xrpcerr.NewXrpcError( 58 + xrpcerr.WithTag("RevisionNotFound"), 59 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 60 + ), http.StatusBadRequest) 61 + return 62 + } 63 + 64 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 65 + if err != nil { 66 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 67 + writeError(w, xrpcerr.NewXrpcError( 68 + xrpcerr.WithTag("CompareError"), 69 + xrpcerr.WithMessage("error comparing revisions"), 70 + ), http.StatusBadRequest) 71 + return 72 + } 73 + 74 + response := types.RepoFormatPatchResponse{ 75 + Rev1: commit1.Hash.String(), 76 + Rev2: commit2.Hash.String(), 77 + FormatPatch: formatPatch, 78 + Patch: rawPatch, 79 + } 80 + 81 + writeJson(w, response) 82 + }
+41
knotserver/xrpc/repo_diff.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/knotserver/git" 7 + "tangled.sh/tangled.sh/core/types" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 12 + repo := r.URL.Query().Get("repo") 13 + repoPath, err := x.parseRepoParam(repo) 14 + if err != nil { 15 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 16 + return 17 + } 18 + 19 + ref := r.URL.Query().Get("ref") 20 + // ref can be empty (git.Open handles this) 21 + 22 + gr, err := git.Open(repoPath, ref) 23 + if err != nil { 24 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 25 + return 26 + } 27 + 28 + diff, err := gr.Diff() 29 + if err != nil { 30 + x.Logger.Error("getting diff", "error", err.Error()) 31 + writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + response := types.RepoCommitResponse{ 36 + Ref: ref, 37 + Diff: diff, 38 + } 39 + 40 + writeJson(w, response) 41 + }
+39
knotserver/xrpc/repo_get_default_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "time" 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.PlainOpen(repoPath) 21 + 22 + branch, err := gr.FindMainBranch() 23 + if err != nil { 24 + x.Logger.Error("getting default branch", "error", err.Error()) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("failed to get default branch"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + response := tangled.RepoGetDefaultBranch_Output{ 33 + Name: branch, 34 + Hash: "", 35 + When: time.UnixMicro(0).Format(time.RFC3339), 36 + } 37 + 38 + writeJson(w, response) 39 + }
+76
knotserver/xrpc/repo_languages.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "math" 6 + "net/http" 7 + "time" 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) RepoLanguages(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 + ref := r.URL.Query().Get("ref") 23 + 24 + gr, err := git.Open(repoPath, ref) 25 + if err != nil { 26 + x.Logger.Error("opening repo", "error", err.Error()) 27 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 28 + return 29 + } 30 + 31 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 32 + defer cancel() 33 + 34 + sizes, err := gr.AnalyzeLanguages(ctx) 35 + if err != nil { 36 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 37 + writeError(w, xrpcerr.NewXrpcError( 38 + xrpcerr.WithTag("InvalidRequest"), 39 + xrpcerr.WithMessage("failed to analyze repository languages"), 40 + ), http.StatusNoContent) 41 + return 42 + } 43 + 44 + var apiLanguages []*tangled.RepoLanguages_Language 45 + var totalSize int64 46 + 47 + for _, size := range sizes { 48 + totalSize += size 49 + } 50 + 51 + for name, size := range sizes { 52 + percentagef64 := float64(size) / float64(totalSize) * 100 53 + percentage := math.Round(percentagef64) 54 + 55 + lang := &tangled.RepoLanguages_Language{ 56 + Name: name, 57 + Size: size, 58 + Percentage: int64(percentage), 59 + } 60 + 61 + apiLanguages = append(apiLanguages, lang) 62 + } 63 + 64 + response := tangled.RepoLanguages_Output{ 65 + Ref: ref, 66 + Languages: apiLanguages, 67 + } 68 + 69 + if totalSize > 0 { 70 + response.TotalSize = &totalSize 71 + totalFiles := int64(len(sizes)) 72 + response.TotalFiles = &totalFiles 73 + } 74 + 75 + writeJson(w, response) 76 + }
+81
knotserver/xrpc/repo_log.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoLog(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 + ref := r.URL.Query().Get("ref") 21 + 22 + path := r.URL.Query().Get("path") 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 + return 36 + } 37 + 38 + offset := 0 39 + if cursor != "" { 40 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 41 + offset = o 42 + } 43 + } 44 + 45 + commits, err := gr.Commits(offset, limit) 46 + if err != nil { 47 + x.Logger.Error("fetching commits", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("PathNotFound"), 50 + xrpcerr.WithMessage("failed to read commit log"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + total, err := gr.TotalCommits() 56 + if err != nil { 57 + x.Logger.Error("fetching total commits", "error", err.Error()) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("InternalServerError"), 60 + xrpcerr.WithMessage("failed to fetch total commits"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // Create response using existing types.RepoLogResponse 66 + response := types.RepoLogResponse{ 67 + Commits: commits, 68 + Ref: ref, 69 + Page: (offset / limit) + 1, 70 + PerPage: limit, 71 + Total: total, 72 + } 73 + 74 + if path != "" { 75 + response.Description = path 76 + } 77 + 78 + response.Log = true 79 + 80 + writeJson(w, response) 81 + }
+86
knotserver/xrpc/repo_tags.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + "tangled.sh/tangled.sh/core/types" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + tags, err := gr.Tags() 40 + if err != nil { 41 + x.Logger.Warn("getting tags", "error", err.Error()) 42 + tags = []object.Tag{} 43 + } 44 + 45 + rtags := []*types.TagReference{} 46 + for _, tag := range tags { 47 + var target *object.Tag 48 + if tag.Target != plumbing.ZeroHash { 49 + target = &tag 50 + } 51 + tr := types.TagReference{ 52 + Tag: target, 53 + } 54 + 55 + tr.Reference = types.Reference{ 56 + Name: tag.Name, 57 + Hash: tag.Hash.String(), 58 + } 59 + 60 + if tag.Message != "" { 61 + tr.Message = tag.Message 62 + } 63 + 64 + rtags = append(rtags, &tr) 65 + } 66 + 67 + // apply pagination manually 68 + offset := 0 69 + if cursor != "" { 70 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 + offset = o 72 + } 73 + } 74 + 75 + // calculate end index 76 + end := min(offset+limit, len(rtags)) 77 + 78 + paginatedTags := rtags[offset:end] 79 + 80 + // Create response using existing types.RepoTagsResponse 81 + response := types.RepoTagsResponse{ 82 + Tags: paginatedTags, 83 + } 84 + 85 + writeJson(w, response) 86 + }
+89
knotserver/xrpc/repo_tree.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "path/filepath" 6 + "time" 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) RepoTree(w http.ResponseWriter, r *http.Request) { 14 + ctx := r.Context() 15 + 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + path := r.URL.Query().Get("path") 27 + // path can be empty (defaults to root) 28 + 29 + gr, err := git.Open(repoPath, ref) 30 + if err != nil { 31 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 32 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 33 + return 34 + } 35 + 36 + files, err := gr.FileTree(ctx, path) 37 + if err != nil { 38 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("PathNotFound"), 41 + xrpcerr.WithMessage("failed to read repository tree"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + // convert NiceTree -> tangled.RepoTree_TreeEntry 47 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 + for i, file := range files { 49 + entry := &tangled.RepoTree_TreeEntry{ 50 + Name: file.Name, 51 + Mode: file.Mode, 52 + Size: file.Size, 53 + Is_file: file.IsFile, 54 + Is_subtree: file.IsSubtree, 55 + } 56 + 57 + if file.LastCommit != nil { 58 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 59 + Hash: file.LastCommit.Hash.String(), 60 + Message: file.LastCommit.Message, 61 + When: file.LastCommit.When.Format(time.RFC3339), 62 + } 63 + } 64 + 65 + treeEntries[i] = entry 66 + } 67 + 68 + var parentPtr *string 69 + if path != "" { 70 + parentPtr = &path 71 + } 72 + 73 + var dotdotPtr *string 74 + if path != "" { 75 + dotdot := filepath.Dir(path) 76 + if dotdot != "." { 77 + dotdotPtr = &dotdot 78 + } 79 + } 80 + 81 + response := tangled.RepoTree_Output{ 82 + Ref: ref, 83 + Parent: parentPtr, 84 + Dotdot: dotdotPtr, 85 + Files: treeEntries, 86 + } 87 + 88 + writeJson(w, response) 89 + }
+60
knotserver/xrpc/version.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "runtime/debug" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + ) 10 + 11 + // version is set during build time. 12 + var version string 13 + 14 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 + if version == "" { 16 + info, ok := debug.ReadBuildInfo() 17 + if !ok { 18 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 19 + return 20 + } 21 + 22 + var modVer string 23 + var sha string 24 + var modified bool 25 + 26 + for _, mod := range info.Deps { 27 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 28 + modVer = mod.Version 29 + break 30 + } 31 + } 32 + 33 + for _, setting := range info.Settings { 34 + switch setting.Key { 35 + case "vcs.revision": 36 + sha = setting.Value 37 + case "vcs.modified": 38 + modified = setting.Value == "true" 39 + } 40 + } 41 + 42 + if modVer == "" { 43 + modVer = "unknown" 44 + } 45 + 46 + if sha == "" { 47 + version = modVer 48 + } else if modified { 49 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 50 + } else { 51 + version = fmt.Sprintf("%s (%s)", modVer, sha) 52 + } 53 + } 54 + 55 + response := tangled.KnotVersion_Output{ 56 + Version: version, 57 + } 58 + 59 + writeJson(w, response) 60 + }
+67
knotserver/xrpc/xrpc.go
··· 4 "encoding/json" 5 "log/slog" 6 "net/http" 7 8 "tangled.sh/tangled.sh/core/api/tangled" 9 "tangled.sh/tangled.sh/core/idresolver" 10 "tangled.sh/tangled.sh/core/jetstream" ··· 50 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 // - use ETags on clients to keep requests to a minimum 52 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 return r 54 } 55 56 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 57 w.Header().Set("Content-Type", "application/json") 58 w.WriteHeader(status) 59 json.NewEncoder(w).Encode(e) 60 }
··· 4 "encoding/json" 5 "log/slog" 6 "net/http" 7 + "strings" 8 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/idresolver" 12 "tangled.sh/tangled.sh/core/jetstream" ··· 52 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 53 // - use ETags on clients to keep requests to a minimum 54 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 55 + 56 + // repo query endpoints (no auth required) 57 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 58 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 59 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 60 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 61 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 62 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 63 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 64 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 65 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 66 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 67 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 68 + 69 + // knot query endpoints (no auth required) 70 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 71 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 72 + 73 + // service query endpoints (no auth required) 74 + r.Get("/"+tangled.OwnerNSID, x.Owner) 75 + 76 return r 77 } 78 79 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 80 + // the full repository path on disk 81 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 82 + if repo == "" { 83 + return "", xrpcerr.NewXrpcError( 84 + xrpcerr.WithTag("InvalidRequest"), 85 + xrpcerr.WithMessage("missing repo parameter"), 86 + ) 87 + } 88 + 89 + // Parse repo string (did/repoName format) 90 + parts := strings.SplitN(repo, "/", 2) 91 + if len(parts) != 2 { 92 + return "", xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InvalidRequest"), 94 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 95 + ) 96 + } 97 + 98 + did := parts[0] 99 + repoName := parts[1] 100 + 101 + // Construct repository path using the same logic as didPath 102 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 103 + if err != nil { 104 + return "", xrpcerr.RepoNotFoundError 105 + } 106 + 107 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 108 + if err != nil { 109 + return "", xrpcerr.RepoNotFoundError 110 + } 111 + 112 + return repoPath, nil 113 + } 114 + 115 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 116 w.Header().Set("Content-Type", "application/json") 117 w.WriteHeader(status) 118 json.NewEncoder(w).Encode(e) 119 } 120 + 121 + func writeJson(w http.ResponseWriter, response any) { 122 + w.Header().Set("Content-Type", "application/json") 123 + if err := json.NewEncoder(w).Encode(response); err != nil { 124 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 125 + return 126 + } 127 + }
+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 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["issue", "body", "createdAt"], 13 "properties": { 14 "issue": { 15 "type": "string", 16 "format": "at-uri" 17 }, 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "owner": { 23 - "type": "string", 24 - "format": "did" 25 - }, 26 "body": { 27 "type": "string" 28 }, 29 "createdAt": { 30 "type": "string", 31 "format": "datetime" 32 } 33 } 34 }
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "issue", 14 + "body", 15 + "createdAt" 16 + ], 17 "properties": { 18 "issue": { 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "body": { 23 "type": "string" 24 }, 25 "createdAt": { 26 "type": "string", 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 } 33 } 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 }, 35 "description": { 36 "type": "string", 37 - "format": "datetime", 38 "minGraphemes": 1, 39 "maxGraphemes": 140 40 },
··· 34 }, 35 "description": { 36 "type": "string", 37 "minGraphemes": 1, 38 "maxGraphemes": 140 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 [mod."github.com/whyrusleeping/cbor-gen"] 426 version = "v0.3.1" 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 [mod."github.com/yuin/goldmark"] 429 - version = "v1.4.15" 430 - hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 431 [mod."github.com/yuin/goldmark-highlighting/v2"] 432 version = "v2.0.0-20230729083705-37449abec8cc" 433 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
··· 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 version = "v0.3.1" 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=" 434 [mod."github.com/yuin/goldmark"] 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 version = "v2.0.0-20230729083705-37449abec8cc" 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+17 -12
nix/pkgs/knot-unwrapped.nix
··· 3 modules, 4 sqlite-lib, 5 src, 6 - }: 7 - buildGoApplication { 8 - pname = "knot"; 9 - version = "0.1.0"; 10 - inherit src modules; 11 12 - doCheck = false; 13 14 - subPackages = ["cmd/knot"]; 15 - tags = ["libsqlite3"]; 16 17 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 19 - CGO_ENABLED = 1; 20 - }
··· 3 modules, 4 sqlite-lib, 5 src, 6 + }: let 7 + version = "1.9.0-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 + 13 + doCheck = false; 14 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 17 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 21 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 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 - fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 + // fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
-3
spindle/server.go
··· 203 w.Write(motd) 204 }) 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 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 210 211 mux.Mount("/xrpc", s.XrpcRouter())
··· 203 w.Write(motd) 204 }) 205 mux.HandleFunc("/events", s.Events) 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 207 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 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 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) 41 42 return r 43 }
··· 35 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 37 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) 48 49 return r 50 }
test_file

This is a binary file and will not be displayed.

+15
xrpc/errors/errors.go
··· 51 WithMessage("actor DID not supplied"), 52 ) 53 54 var AuthError = func(err error) XrpcError { 55 return NewXrpcError( 56 WithTag("Auth"),
··· 51 WithMessage("actor DID not supplied"), 52 ) 53 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 59 + var RepoNotFoundError = NewXrpcError( 60 + WithTag("RepoNotFound"), 61 + WithMessage("failed to access repository"), 62 + ) 63 + 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 69 var AuthError = func(err error) XrpcError { 70 return NewXrpcError( 71 WithTag("Auth"),