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

Compare changes

Choose any two refs to compare.

Changed files
+6409 -3608
api
appview
cmd
appview
docs
knotclient
knotserver
legal
lexicons
nix
spindle
xrpc
errors
+16 -73
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 6
-
-
if t.Owner == nil {
-
fieldCount--
-
}
+
fieldCount := 5
-
if t.Repo == nil {
+
if t.ReplyTo == nil {
fieldCount--
···
return err
-
// t.Repo (string) (string)
-
if t.Repo != nil {
-
-
if len("repo") > 1000000 {
-
return xerrors.Errorf("Value in field \"repo\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("repo")); err != nil {
-
return err
-
}
-
-
if t.Repo == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if len(*t.Repo) > 1000000 {
-
return xerrors.Errorf("Value in field t.Repo was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
-
return err
-
}
-
}
-
}
-
// t.LexiconTypeID (string) (string)
if len("$type") > 1000000 {
return xerrors.Errorf("Value in field \"$type\" was too long")
···
return err
-
// t.Owner (string) (string)
-
if t.Owner != nil {
+
// t.ReplyTo (string) (string)
+
if t.ReplyTo != nil {
-
if len("owner") > 1000000 {
-
return xerrors.Errorf("Value in field \"owner\" was too long")
+
if len("replyTo") > 1000000 {
+
return xerrors.Errorf("Value in field \"replyTo\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil {
return err
-
if _, err := cw.WriteString(string("owner")); err != nil {
+
if _, err := cw.WriteString(string("replyTo")); err != nil {
return err
-
if t.Owner == nil {
+
if t.ReplyTo == nil {
if _, err := cw.Write(cbg.CborNull); err != nil {
return err
} else {
-
if len(*t.Owner) > 1000000 {
-
return xerrors.Errorf("Value in field t.Owner was too long")
+
if len(*t.ReplyTo) > 1000000 {
+
return xerrors.Errorf("Value in field t.ReplyTo was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil {
return err
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
+
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
return err
···
t.Body = string(sval)
-
// t.Repo (string) (string)
-
case "repo":
-
-
{
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Repo = (*string)(&sval)
-
}
-
}
// t.LexiconTypeID (string) (string)
case "$type":
···
t.Issue = string(sval)
-
// t.Owner (string) (string)
-
case "owner":
+
// t.ReplyTo (string) (string)
+
case "replyTo":
b, err := cr.ReadByte()
···
return err
-
t.Owner = (*string)(&sval)
+
t.ReplyTo = (*string)(&sval)
// t.CreatedAt (string) (string)
+1 -2
api/tangled/issuecomment.go
···
Body string `json:"body" cborgen:"body"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
Issue string `json:"issue" cborgen:"issue"`
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
+
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
}
+53
api/tangled/knotlistKeys.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.knot.listKeys
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
KnotListKeysNSID = "sh.tangled.knot.listKeys"
+
)
+
+
// KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call.
+
type KnotListKeys_Output struct {
+
// cursor: Pagination cursor for next page
+
Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
+
Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"`
+
}
+
+
// KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema.
+
type KnotListKeys_PublicKey struct {
+
// createdAt: Key upload timestamp
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
// did: DID associated with the public key
+
Did string `json:"did" cborgen:"did"`
+
// key: Public key contents
+
Key string `json:"key" cborgen:"key"`
+
}
+
+
// KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of keys to return
+
func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) {
+
var out KnotListKeys_Output
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+30
api/tangled/knotversion.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.knot.version
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
KnotVersionNSID = "sh.tangled.knot.version"
+
)
+
+
// KnotVersion_Output is the output of a sh.tangled.knot.version call.
+
type KnotVersion_Output struct {
+
Version string `json:"version" cborgen:"version"`
+
}
+
+
// KnotVersion calls the XRPC method "sh.tangled.knot.version".
+
func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) {
+
var out KnotVersion_Output
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+41
api/tangled/repoarchive.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.archive
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoArchiveNSID = "sh.tangled.repo.archive"
+
)
+
+
// RepoArchive calls the XRPC method "sh.tangled.repo.archive".
+
//
+
// format: Archive format
+
// prefix: Prefix for files in the archive
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if format != "" {
+
params["format"] = format
+
}
+
if prefix != "" {
+
params["prefix"] = prefix
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+80
api/tangled/repoblob.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.blob
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBlobNSID = "sh.tangled.repo.blob"
+
)
+
+
// RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema.
+
type RepoBlob_LastCommit struct {
+
Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Commit hash
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Commit message
+
Message string `json:"message" cborgen:"message"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Commit timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
+
type RepoBlob_Output struct {
+
// content: File content (base64 encoded for binary files)
+
Content string `json:"content" cborgen:"content"`
+
// encoding: Content encoding
+
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
+
// isBinary: Whether the file is binary
+
IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"`
+
LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
+
// mimeType: MIME type of the file
+
MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"`
+
// path: The file path
+
Path string `json:"path" cborgen:"path"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
// size: File size in bytes
+
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
+
}
+
+
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
+
type RepoBlob_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+
//
+
// path: Path to the file within the repository
+
// raw: Return raw file content instead of JSON response
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) {
+
var out RepoBlob_Output
+
+
params := map[string]interface{}{}
+
params["path"] = path
+
if raw {
+
params["raw"] = raw
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+59
api/tangled/repobranch.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.branch
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBranchNSID = "sh.tangled.repo.branch"
+
)
+
+
// RepoBranch_Output is the output of a sh.tangled.repo.branch call.
+
type RepoBranch_Output struct {
+
Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Latest commit hash on this branch
+
Hash string `json:"hash" cborgen:"hash"`
+
// isDefault: Whether this is the default branch
+
IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"`
+
// message: Latest commit message
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
// name: Branch name
+
Name string `json:"name" cborgen:"name"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Timestamp of latest commit
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema.
+
type RepoBranch_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBranch calls the XRPC method "sh.tangled.repo.branch".
+
//
+
// name: Branch name to get information for
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) {
+
var out RepoBranch_Output
+
+
params := map[string]interface{}{}
+
params["name"] = name
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+39
api/tangled/repobranches.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.branches
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBranchesNSID = "sh.tangled.repo.branches"
+
)
+
+
// RepoBranches calls the XRPC method "sh.tangled.repo.branches".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of branches to return
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+35
api/tangled/repocompare.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.compare
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoCompareNSID = "sh.tangled.repo.compare"
+
)
+
+
// RepoCompare calls the XRPC method "sh.tangled.repo.compare".
+
//
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
// rev1: First revision (commit, branch, or tag)
+
// rev2: Second revision (commit, branch, or tag)
+
func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
params["repo"] = repo
+
params["rev1"] = rev1
+
params["rev2"] = rev2
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+33
api/tangled/repodiff.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.diff
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoDiffNSID = "sh.tangled.repo.diff"
+
)
+
+
// RepoDiff calls the XRPC method "sh.tangled.repo.diff".
+
//
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+55
api/tangled/repogetDefaultBranch.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.getDefaultBranch
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch"
+
)
+
+
// RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call.
+
type RepoGetDefaultBranch_Output struct {
+
Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Latest commit hash on default branch
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Latest commit message
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
// name: Default branch name
+
Name string `json:"name" cborgen:"name"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Timestamp of latest commit
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema.
+
type RepoGetDefaultBranch_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch".
+
//
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) {
+
var out RepoGetDefaultBranch_Output
+
+
params := map[string]interface{}{}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+61
api/tangled/repolanguages.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.languages
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoLanguagesNSID = "sh.tangled.repo.languages"
+
)
+
+
// RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema.
+
type RepoLanguages_Language struct {
+
// color: Hex color code for this language
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
+
// extensions: File extensions associated with this language
+
Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"`
+
// fileCount: Number of files in this language
+
FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"`
+
// name: Programming language name
+
Name string `json:"name" cborgen:"name"`
+
// percentage: Percentage of total codebase (0-100)
+
Percentage int64 `json:"percentage" cborgen:"percentage"`
+
// size: Total size of files in this language (bytes)
+
Size int64 `json:"size" cborgen:"size"`
+
}
+
+
// RepoLanguages_Output is the output of a sh.tangled.repo.languages call.
+
type RepoLanguages_Output struct {
+
Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
// totalFiles: Total number of files analyzed
+
TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"`
+
// totalSize: Total size of all analyzed files in bytes
+
TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"`
+
}
+
+
// RepoLanguages calls the XRPC method "sh.tangled.repo.languages".
+
//
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) {
+
var out RepoLanguages_Output
+
+
params := map[string]interface{}{}
+
if ref != "" {
+
params["ref"] = ref
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+45
api/tangled/repolog.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.log
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoLogNSID = "sh.tangled.repo.log"
+
)
+
+
// RepoLog calls the XRPC method "sh.tangled.repo.log".
+
//
+
// cursor: Pagination cursor (commit SHA)
+
// limit: Maximum number of commits to return
+
// path: Path to filter commits by
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
if path != "" {
+
params["path"] = path
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+39
api/tangled/repotags.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.tags
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoTagsNSID = "sh.tangled.repo.tags"
+
)
+
+
// RepoTags calls the XRPC method "sh.tangled.repo.tags".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of tags to return
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+72
api/tangled/repotree.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.tree
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoTreeNSID = "sh.tangled.repo.tree"
+
)
+
+
// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
+
type RepoTree_LastCommit struct {
+
// hash: Commit hash
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Commit message
+
Message string `json:"message" cborgen:"message"`
+
// when: Commit timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoTree_Output is the output of a sh.tangled.repo.tree call.
+
type RepoTree_Output struct {
+
// dotdot: Parent directory path
+
Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
+
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
+
// parent: The parent path in the tree
+
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
}
+
+
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+
type RepoTree_TreeEntry struct {
+
// is_file: Whether this entry is a file
+
Is_file bool `json:"is_file" cborgen:"is_file"`
+
// is_subtree: Whether this entry is a directory/subtree
+
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
+
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
+
// mode: File mode
+
Mode string `json:"mode" cborgen:"mode"`
+
// name: Relative file or directory name
+
Name string `json:"name" cborgen:"name"`
+
// size: File size in bytes
+
Size int64 `json:"size" cborgen:"size"`
+
}
+
+
// RepoTree calls the XRPC method "sh.tangled.repo.tree".
+
//
+
// path: Path within the repository tree
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) {
+
var out RepoTree_Output
+
+
params := map[string]interface{}{}
+
if path != "" {
+
params["path"] = path
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+30
api/tangled/tangledowner.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.owner
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
OwnerNSID = "sh.tangled.owner"
+
)
+
+
// Owner_Output is the output of a sh.tangled.owner call.
+
type Owner_Output struct {
+
Owner string `json:"owner" cborgen:"owner"`
+
}
+
+
// Owner calls the XRPC method "sh.tangled.owner".
+
func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) {
+
var out Owner_Output
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+169
appview/db/db.go
···
return err
})
+
// repurpose the read-only column to "needs-upgrade"
+
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table registrations rename column read_only to needs_upgrade;
+
`)
+
return err
+
})
+
+
// require all knots to upgrade after the release of total xrpc
+
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
update registrations set needs_upgrade = 1;
+
`)
+
return err
+
})
+
+
// require all knots to upgrade after the release of total xrpc
+
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table spindles add column needs_upgrade integer not null default 0;
+
`)
+
if err != nil {
+
return err
+
}
+
+
_, err = tx.Exec(`
+
update spindles set needs_upgrade = 1;
+
`)
+
return err
+
})
+
+
// remove issue_at from issues and replace with generated column
+
//
+
// this requires a full table recreation because stored columns
+
// cannot be added via alter
+
//
+
// couple other changes:
+
// - columns renamed to be more consistent
+
// - adds edited and deleted fields
+
//
+
// disable foreign-keys for the next migration
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists issues_new (
+
-- identifiers
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text not null,
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
+
+
-- at identifiers
+
repo_at text not null,
+
+
-- content
+
issue_id integer not null,
+
title text not null,
+
body text not null,
+
open integer not null default 1,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
edited text, -- timestamp
+
deleted text, -- timestamp
+
+
unique(did, rkey),
+
unique(repo_at, issue_id),
+
unique(at_uri),
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
+
select
+
i.id,
+
i.owner_did,
+
i.rkey,
+
i.repo_at,
+
i.issue_id,
+
i.title,
+
i.body,
+
i.open,
+
i.created
+
from issues i;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table issues`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table issues_new rename to issues`)
+
return err
+
})
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
+
+
// - renames the comments table to 'issue_comments'
+
// - rework issue comments to update constraints:
+
// * unique(did, rkey)
+
// * remove comment-id and just use the global ID
+
// * foreign key (repo_at, issue_id)
+
// - new columns
+
// * column "reply_to" which can be any other comment
+
// * column "at-uri" which is a generated column
+
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table if not exists issue_comments (
+
-- identifiers
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text,
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
+
+
-- at identifiers
+
issue_at text not null,
+
reply_to text, -- at_uri of parent comment
+
+
-- content
+
body text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
edited text,
+
deleted text,
+
+
-- constraints
+
unique(did, rkey),
+
unique(at_uri),
+
foreign key (issue_at) references issues(at_uri) on delete cascade
+
);
+
`)
+
if err != nil {
+
return err
+
}
+
+
// transfer data
+
_, err = tx.Exec(`
+
insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
+
select
+
c.id,
+
c.owner_did,
+
c.rkey,
+
i.at_uri, -- get at_uri from issues table
+
c.body,
+
c.created,
+
c.edited,
+
c.deleted
+
from comments c
+
join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table comments`)
+
return err
+
})
+
return &DB{db}, nil
}
···
}
return nil
+
}
+
+
func (d *DB) Close() error {
+
return d.DB.Close()
}
type filter struct {
+410 -453
appview/db/issues.go
···
import (
"database/sql"
"fmt"
-
mathrand "math/rand/v2"
+
"maps"
+
"slices"
+
"sort"
"strings"
"time"
···
)
type Issue struct {
-
ID int64
-
RepoAt syntax.ATURI
-
OwnerDid string
-
IssueId int
-
Rkey string
-
Created time.Time
-
Title string
-
Body string
-
Open bool
+
Id int64
+
Did string
+
Rkey string
+
RepoAt syntax.ATURI
+
IssueId int
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
Title string
+
Body string
+
Open bool
// optionally, populate this when querying for reverse mappings
// like comment counts, parent repo etc.
-
Metadata *IssueMetadata
+
Comments []IssueComment
+
Repo *Repo
}
-
type IssueMetadata struct {
-
CommentCount int
-
Repo *Repo
-
// labels, assignee etc.
+
func (i *Issue) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
}
-
type Comment struct {
-
OwnerDid string
-
RepoAt syntax.ATURI
-
Rkey string
-
Issue int
-
CommentId int
-
Body string
-
Created *time.Time
-
Deleted *time.Time
-
Edited *time.Time
+
func (i *Issue) AsRecord() tangled.RepoIssue {
+
return tangled.RepoIssue{
+
Repo: i.RepoAt.String(),
+
Title: i.Title,
+
Body: &i.Body,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
}
}
-
func (i *Issue) AtUri() syntax.ATURI {
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
+
}
+
+
type CommentListItem struct {
+
Self *IssueComment
+
Replies []*IssueComment
+
}
+
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
+
+
// collect top level comments into the map
+
for _, comment := range i.Comments {
+
if comment.IsTopLevel() {
+
toplevel[comment.AtUri().String()] = &CommentListItem{
+
Self: &comment,
+
}
+
} else {
+
replies = append(replies, &comment)
+
}
+
}
+
+
for _, r := range replies {
+
parentAt := *r.ReplyTo
+
if parent, exists := toplevel[parentAt]; exists {
+
parent.Replies = append(parent.Replies, r)
+
}
+
}
+
+
var listing []CommentListItem
+
for _, v := range toplevel {
+
listing = append(listing, *v)
+
}
+
+
// sort everything
+
sortFunc := func(a, b *IssueComment) bool {
+
return a.Created.Before(b.Created)
+
}
+
sort.Slice(listing, func(i, j int) bool {
+
return sortFunc(listing[i].Self, listing[j].Self)
+
})
+
for _, r := range listing {
+
sort.Slice(r.Replies, func(i, j int) bool {
+
return sortFunc(r.Replies[i], r.Replies[j])
+
})
+
}
+
+
return listing
}
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
···
}
return Issue{
-
RepoAt: syntax.ATURI(record.Repo),
-
OwnerDid: did,
-
Rkey: rkey,
-
Created: created,
-
Title: record.Title,
-
Body: body,
-
Open: true, // new issues are open by default
+
RepoAt: syntax.ATURI(record.Repo),
+
Did: did,
+
Rkey: rkey,
+
Created: created,
+
Title: record.Title,
+
Body: body,
+
Open: true, // new issues are open by default
}
}
-
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
-
ownerDid := issueUri.Authority().String()
-
issueRkey := issueUri.RecordKey().String()
+
type IssueComment struct {
+
Id int64
+
Did string
+
Rkey string
+
IssueAt string
+
ReplyTo *string
+
Body string
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
+
}
-
var repoAt string
-
var issueId int
+
func (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
+
}
-
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
-
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
-
if err != nil {
-
return "", 0, err
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
}
+
}
-
return syntax.ATURI(repoAt), issueId, nil
+
func (i *IssueComment) IsTopLevel() bool {
+
return i.ReplyTo == nil
}
-
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
created, err := time.Parse(time.RFC3339, record.CreatedAt)
if err != nil {
created = time.Now()
}
ownerDid := did
-
if record.Owner != nil {
-
ownerDid = *record.Owner
-
}
-
issueUri, err := syntax.ParseATURI(record.Issue)
-
if err != nil {
-
return Comment{}, err
-
}
-
-
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
-
if err != nil {
-
return Comment{}, err
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
+
return nil, err
}
-
comment := Comment{
-
OwnerDid: ownerDid,
-
RepoAt: repoAt,
-
Rkey: rkey,
-
Body: record.Body,
-
Issue: issueId,
-
CommentId: mathrand.IntN(1000000),
-
Created: &created,
+
comment := IssueComment{
+
Did: ownerDid,
+
Rkey: rkey,
+
Body: record.Body,
+
IssueAt: record.Issue,
+
ReplyTo: record.ReplyTo,
+
Created: created,
}
-
return comment, nil
+
return &comment, nil
}
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
-
defer tx.Rollback()
-
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
+
// ensure sequence exists
_, err := tx.Exec(`
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
values (?, 1)
-
`, issue.RepoAt)
+
`, issue.RepoAt)
if err != nil {
return err
}
-
var nextId int
-
err = tx.QueryRow(`
-
update repo_issue_seqs
-
set next_issue_id = next_issue_id + 1
-
where repo_at = ?
-
returning next_issue_id - 1
-
`, issue.RepoAt).Scan(&nextId)
-
if err != nil {
+
issues, err := GetIssues(
+
tx,
+
FilterEq("did", issue.Did),
+
FilterEq("rkey", issue.Rkey),
+
)
+
switch {
+
case err != nil:
return err
-
}
-
-
issue.IssueId = nextId
+
case len(issues) == 0:
+
return createNewIssue(tx, issue)
+
case len(issues) != 1: // should be unreachable
+
return fmt.Errorf("invalid number of issues returned: %d", len(issues))
+
default:
+
// if content is identical, do not edit
+
existingIssue := issues[0]
+
if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
+
return nil
+
}
-
res, err := tx.Exec(`
-
insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
-
values (?, ?, ?, ?, ?, ?, ?)
-
`, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
-
if err != nil {
-
return err
+
issue.Id = existingIssue.Id
+
issue.IssueId = existingIssue.IssueId
+
return updateIssue(tx, issue)
}
+
}
-
lastID, err := res.LastInsertId()
+
func createNewIssue(tx *sql.Tx, issue *Issue) error {
+
// get next issue_id
+
var newIssueId int
+
err := tx.QueryRow(`
+
update repo_issue_seqs
+
set next_issue_id = next_issue_id + 1
+
where repo_at = ?
+
returning next_issue_id - 1
+
`, issue.RepoAt).Scan(&newIssueId)
if err != nil {
return err
}
-
issue.ID = lastID
-
if err := tx.Commit(); err != nil {
-
return err
-
}
+
// insert new issue
+
row := tx.QueryRow(`
+
insert into issues (repo_at, did, rkey, issue_id, title, body)
+
values (?, ?, ?, ?, ?, ?)
+
returning rowid, issue_id
+
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
-
return nil
+
return row.Scan(&issue.Id, &issue.IssueId)
}
-
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
-
var issueAt string
-
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
-
return issueAt, err
-
}
-
-
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
-
var ownerDid string
-
err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
-
return ownerDid, err
-
}
-
-
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
-
var issues []Issue
-
openValue := 0
-
if isOpen {
-
openValue = 1
-
}
-
-
rows, err := e.Query(
-
`
-
with numbered_issue as (
-
select
-
i.id,
-
i.owner_did,
-
i.rkey,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open,
-
count(c.id) as comment_count,
-
row_number() over (order by i.created desc) as row_num
-
from
-
issues i
-
left join
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
-
where
-
i.repo_at = ? and i.open = ?
-
group by
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
-
)
-
select
-
id,
-
owner_did,
-
rkey,
-
issue_id,
-
created,
-
title,
-
body,
-
open,
-
comment_count
-
from
-
numbered_issue
-
where
-
row_num between ? and ?`,
-
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var issue Issue
-
var createdAt string
-
var metadata IssueMetadata
-
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
-
if err != nil {
-
return nil, err
-
}
-
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
-
}
-
issue.Created = createdTime
-
issue.Metadata = &metadata
-
-
issues = append(issues, issue)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return issues, nil
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
+
// update existing issue
+
_, err := tx.Exec(`
+
update issues
+
set title = ?, body = ?, edited = ?
+
where did = ? and rkey = ?
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
+
return err
}
-
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
-
issues := make([]Issue, 0, limit)
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
+
issueMap := make(map[string]*Issue) // at-uri -> issue
var conditions []string
var args []any
+
for _, filter := range filters {
conditions = append(conditions, filter.Condition())
args = append(args, filter.Arg()...)
···
if conditions != nil {
whereClause = " where " + strings.Join(conditions, " and ")
}
-
limitClause := ""
-
if limit != 0 {
-
limitClause = fmt.Sprintf(" limit %d ", limit)
-
}
+
+
pLower := FilterGte("row_num", page.Offset+1)
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
+
+
args = append(args, pLower.Arg()...)
+
args = append(args, pUpper.Arg()...)
+
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
query := fmt.Sprintf(
-
`select
-
i.id,
-
i.owner_did,
-
i.repo_at,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open
-
from
-
issues i
+
`
+
select * from (
+
select
+
id,
+
did,
+
rkey,
+
repo_at,
+
issue_id,
+
title,
+
body,
+
open,
+
created,
+
edited,
+
deleted,
+
row_number() over (order by created desc) as row_num
+
from
+
issues
+
%s
+
) ranked_issues
%s
-
order by
-
i.created desc
-
%s`,
-
whereClause, limitClause)
+
`,
+
whereClause,
+
pagination,
+
)
rows, err := e.Query(query, args...)
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("failed to query issues table: %w", err)
}
defer rows.Close()
for rows.Next() {
var issue Issue
-
var issueCreatedAt string
+
var createdAt string
+
var editedAt, deletedAt sql.Null[string]
+
var rowNum int64
err := rows.Scan(
-
&issue.ID,
-
&issue.OwnerDid,
+
&issue.Id,
+
&issue.Did,
+
&issue.Rkey,
&issue.RepoAt,
&issue.IssueId,
-
&issueCreatedAt,
&issue.Title,
&issue.Body,
&issue.Open,
+
&createdAt,
+
&editedAt,
+
&deletedAt,
+
&rowNum,
)
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("failed to scan issue: %w", err)
+
}
+
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
issue.Created = t
+
}
+
+
if editedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
+
issue.Edited = &t
+
}
}
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
+
if deletedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
+
issue.Deleted = &t
+
}
}
-
issue.Created = issueCreatedTime
-
issues = append(issues, issue)
+
atUri := issue.AtUri().String()
+
issueMap[atUri] = &issue
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
// collect reverse repos
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
+
for _, issue := range issueMap {
+
repoAts = append(repoAts, string(issue.RepoAt))
}
-
return issues, nil
-
}
-
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
-
return GetIssuesWithLimit(e, 0, filters...)
-
}
-
-
// timeframe here is directly passed into the sql query filter, and any
-
// timeframe in the past should be negative; e.g.: "-3 months"
-
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
-
var issues []Issue
-
-
rows, err := e.Query(
-
`select
-
i.id,
-
i.owner_did,
-
i.rkey,
-
i.repo_at,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open,
-
r.did,
-
r.name,
-
r.knot,
-
r.rkey,
-
r.created
-
from
-
issues i
-
join
-
repos r on i.repo_at = r.at_uri
-
where
-
i.owner_did = ? and i.created >= date ('now', ?)
-
order by
-
i.created desc`,
-
ownerDid, timeframe)
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
if err != nil {
-
return nil, err
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
}
-
defer rows.Close()
-
for rows.Next() {
-
var issue Issue
-
var issueCreatedAt, repoCreatedAt string
-
var repo Repo
-
err := rows.Scan(
-
&issue.ID,
-
&issue.OwnerDid,
-
&issue.Rkey,
-
&issue.RepoAt,
-
&issue.IssueId,
-
&issueCreatedAt,
-
&issue.Title,
-
&issue.Body,
-
&issue.Open,
-
&repo.Did,
-
&repo.Name,
-
&repo.Knot,
-
&repo.Rkey,
-
&repoCreatedAt,
-
)
-
if err != nil {
-
return nil, err
-
}
+
repoMap := make(map[string]*Repo)
+
for i := range repos {
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
+
}
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
+
for issueAt, i := range issueMap {
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
+
i.Repo = r
+
} else {
+
// do not show up the issue if the repo is deleted
+
// TODO: foreign key where?
+
delete(issueMap, issueAt)
}
-
issue.Created = issueCreatedTime
+
}
-
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
-
if err != nil {
-
return nil, err
-
}
-
repo.Created = repoCreatedTime
+
// collect comments
+
issueAts := slices.Collect(maps.Keys(issueMap))
+
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query comments: %w", err)
+
}
-
issue.Metadata = &IssueMetadata{
-
Repo: &repo,
+
for i := range comments {
+
issueAt := comments[i].IssueAt
+
if issue, ok := issueMap[issueAt]; ok {
+
issue.Comments = append(issue.Comments, comments[i])
}
-
-
issues = append(issues, issue)
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
var issues []Issue
+
for _, i := range issueMap {
+
issues = append(issues, *i)
}
+
sort.Slice(issues, func(i, j int) bool {
+
return issues[i].Created.After(issues[j].Created)
+
})
+
return issues, nil
}
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
+
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
+
}
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
row := e.QueryRow(query, repoAt, issueId)
var issue Issue
var createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
if err != nil {
return nil, err
}
···
return &issue, nil
}
-
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
-
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
-
row := e.QueryRow(query, repoAt, issueId)
-
-
var issue Issue
-
var createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
+
result, err := e.Exec(
+
`insert into issue_comments (
+
did,
+
rkey,
+
issue_at,
+
body,
+
reply_to,
+
created,
+
edited
+
)
+
values (?, ?, ?, ?, ?, ?, null)
+
on conflict(did, rkey) do update set
+
issue_at = excluded.issue_at,
+
body = excluded.body,
+
edited = case
+
when
+
issue_comments.issue_at != excluded.issue_at
+
or issue_comments.body != excluded.body
+
or issue_comments.reply_to != excluded.reply_to
+
then ?
+
else issue_comments.edited
+
end`,
+
c.Did,
+
c.Rkey,
+
c.IssueAt,
+
c.Body,
+
c.ReplyTo,
+
c.Created.Format(time.RFC3339),
+
time.Now().Format(time.RFC3339),
+
)
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
id, err := result.LastInsertId()
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
issue.Created = createdTime
+
+
return id, nil
+
}
-
comments, err := GetComments(e, repoAt, issueId)
-
if err != nil {
-
return nil, nil, err
+
func DeleteIssueComments(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
return &issue, comments, nil
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
func NewIssueComment(e Execer, comment *Comment) error {
-
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
-
_, err := e.Exec(
-
query,
-
comment.OwnerDid,
-
comment.RepoAt,
-
comment.Rkey,
-
comment.Issue,
-
comment.CommentId,
-
comment.Body,
-
)
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
return err
}
-
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
-
var comments []Comment
+
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
+
var comments []IssueComment
+
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
rows, err := e.Query(`
+
query := fmt.Sprintf(`
select
-
owner_did,
-
issue_id,
-
comment_id,
+
id,
+
did,
rkey,
+
issue_at,
+
reply_to,
body,
created,
edited,
deleted
from
-
comments
-
where
-
repo_at = ? and issue_id = ?
-
order by
-
created asc`,
-
repoAt,
-
issueId,
-
)
-
if err == sql.ErrNoRows {
-
return []Comment{}, nil
-
}
+
issue_comments
+
%s
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
-
defer rows.Close()
for rows.Next() {
-
var comment Comment
-
var createdAt string
-
var deletedAt, editedAt, rkey sql.NullString
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
+
var comment IssueComment
+
var created string
+
var rkey, edited, deleted, replyTo sql.Null[string]
+
err := rows.Scan(
+
&comment.Id,
+
&comment.Did,
+
&rkey,
+
&comment.IssueAt,
+
&replyTo,
+
&comment.Body,
+
&created,
+
&edited,
+
&deleted,
+
)
if err != nil {
return nil, err
}
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
// this is a remnant from old times, newer comments always have rkey
+
if rkey.Valid {
+
comment.Rkey = rkey.V
}
-
comment.Created = &createdAtTime
-
if deletedAt.Valid {
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
-
if err != nil {
-
return nil, err
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
+
comment.Created = t
+
}
+
+
if edited.Valid {
+
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
+
comment.Edited = &t
}
-
comment.Deleted = &deletedTime
}
-
if editedAt.Valid {
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
-
if err != nil {
-
return nil, err
+
if deleted.Valid {
+
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
+
comment.Deleted = &t
}
-
comment.Edited = &editedTime
}
-
if rkey.Valid {
-
comment.Rkey = rkey.String
+
if replyTo.Valid {
+
comment.ReplyTo = &replyTo.V
}
comments = append(comments, comment)
}
-
if err := rows.Err(); err != nil {
+
if err = rows.Err(); err != nil {
return nil, err
}
return comments, nil
}
-
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
-
query := `
-
select
-
owner_did, body, rkey, created, deleted, edited
-
from
-
comments where repo_at = ? and issue_id = ? and comment_id = ?
-
`
-
row := e.QueryRow(query, repoAt, issueId, commentId)
-
-
var comment Comment
-
var createdAt string
-
var deletedAt, editedAt, rkey sql.NullString
-
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
-
if err != nil {
-
return nil, err
+
func DeleteIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
comment.Created = &createdTime
-
if deletedAt.Valid {
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
-
if err != nil {
-
return nil, err
-
}
-
comment.Deleted = &deletedTime
-
}
+
query := fmt.Sprintf(`delete from issues %s`, whereClause)
+
_, err := e.Exec(query, args...)
+
return err
+
}
-
if editedAt.Valid {
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
-
if err != nil {
-
return nil, err
-
}
-
comment.Edited = &editedTime
+
func CloseIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
if rkey.Valid {
-
comment.Rkey = rkey.String
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
comment.RepoAt = repoAt
-
comment.Issue = issueId
-
comment.CommentId = commentId
-
-
return &comment, nil
-
}
-
-
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = ?,
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where repo_at = ? and issue_id = ? and comment_id = ?
-
`, newBody, repoAt, issueId, commentId)
+
query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
-
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = "",
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where repo_at = ? and issue_id = ? and comment_id = ?
-
`, repoAt, issueId, commentId)
-
return err
-
}
+
func ReopenIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
-
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = ?,
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where owner_did = ? and rkey = ?
-
`, newBody, ownerDid, rkey)
-
return err
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = "",
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where owner_did = ? and rkey = ?
-
`, ownerDid, rkey)
-
return err
-
}
-
-
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
-
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
-
return err
-
}
-
-
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
-
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
-
return err
-
}
-
-
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
-
_, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
-
return err
-
}
-
-
func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
-
_, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
+
query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
+9 -5
appview/db/profile.go
···
*items = append(*items, &pull)
}
-
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
+
issues, err := GetIssues(
+
e,
+
FilterEq("did", forDid),
+
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
+
)
if err != nil {
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
}
···
*items = append(*items, &issue)
}
-
repos, err := GetAllReposByDid(e, forDid)
+
repos, err := GetRepos(e, 0, FilterEq("did", forDid))
if err != nil {
return nil, fmt.Errorf("error getting all repos by did: %w", err)
}
···
query = `select count(id) from pulls where owner_did = ? and state = ?`
args = append(args, did, PullOpen)
case VanityStatOpenIssueCount:
-
query = `select count(id) from issues where owner_did = ? and open = 1`
+
query = `select count(id) from issues where did = ? and open = 1`
args = append(args, did)
case VanityStatClosedIssueCount:
-
query = `select count(id) from issues where owner_did = ? and open = 0`
+
query = `select count(id) from issues where did = ? and open = 0`
args = append(args, did)
case VanityStatRepositoryCount:
query = `select count(id) from repos where did = ?`
···
}
// ensure all pinned repos are either own repos or collaborating repos
-
repos, err := GetAllReposByDid(e, profile.Did)
+
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
if err != nil {
log.Printf("getting repos for %s: %s", profile.Did, err)
}
+17 -17
appview/db/registration.go
···
// Registration represents a knot registration. Knot would've been a better
// name but we're stuck with this for historical reasons.
type Registration struct {
-
Id int64
-
Domain string
-
ByDid string
-
Created *time.Time
-
Registered *time.Time
-
ReadOnly bool
+
Id int64
+
Domain string
+
ByDid string
+
Created *time.Time
+
Registered *time.Time
+
NeedsUpgrade bool
}
func (r *Registration) Status() Status {
-
if r.ReadOnly {
-
return ReadOnly
+
if r.NeedsUpgrade {
+
return NeedsUpgrade
} else if r.Registered != nil {
return Registered
} else {
···
return r.Status() == Registered
}
-
func (r *Registration) IsReadOnly() bool {
-
return r.Status() == ReadOnly
+
func (r *Registration) IsNeedsUpgrade() bool {
+
return r.Status() == NeedsUpgrade
}
func (r *Registration) IsPending() bool {
···
const (
Registered Status = iota
Pending
-
ReadOnly
+
NeedsUpgrade
)
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
···
}
query := fmt.Sprintf(`
-
select id, domain, did, created, registered, read_only
+
select id, domain, did, created, registered, needs_upgrade
from registrations
%s
order by created
···
for rows.Next() {
var createdAt string
var registeredAt sql.Null[string]
-
var readOnly int
+
var needsUpgrade int
var reg Registration
-
err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly)
+
err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade)
if err != nil {
return nil, err
}
···
}
}
-
if readOnly != 0 {
-
reg.ReadOnly = true
+
if needsUpgrade != 0 {
+
reg.NeedsUpgrade = true
}
registrations = append(registrations, reg)
···
args = append(args, filter.Arg()...)
}
-
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0"
+
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0"
if len(conditions) > 0 {
query += " where " + strings.Join(conditions, " and ")
}
-67
appview/db/repos.go
···
return count, nil
}
-
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
-
var repos []Repo
-
-
rows, err := e.Query(
-
`select
-
r.did,
-
r.name,
-
r.knot,
-
r.rkey,
-
r.description,
-
r.created,
-
count(s.id) as star_count,
-
r.source
-
from
-
repos r
-
left join
-
stars s on r.at_uri = s.repo_at
-
where
-
r.did = ?
-
group by
-
r.at_uri
-
order by r.created desc`,
-
did)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var repo Repo
-
var repoStats RepoStats
-
var createdAt string
-
var nullableDescription sql.NullString
-
var nullableSource sql.NullString
-
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
-
if err != nil {
-
return nil, err
-
}
-
-
if nullableDescription.Valid {
-
repo.Description = nullableDescription.String
-
}
-
-
if nullableSource.Valid {
-
repo.Source = nullableSource.String
-
}
-
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
repo.Created = time.Now()
-
} else {
-
repo.Created = createdAtTime
-
}
-
-
repo.RepoStats = &repoStats
-
-
repos = append(repos, repo)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return repos, nil
-
}
-
func GetRepo(e Execer, did, name string) (*Repo, error) {
var repo Repo
var description, spindle sql.NullString
+14 -7
appview/db/spindle.go
···
)
type Spindle struct {
-
Id int
-
Owner syntax.DID
-
Instance string
-
Verified *time.Time
-
Created time.Time
+
Id int
+
Owner syntax.DID
+
Instance string
+
Verified *time.Time
+
Created time.Time
+
NeedsUpgrade bool
}
type SpindleMember struct {
···
}
query := fmt.Sprintf(
-
`select id, owner, instance, verified, created
+
`select id, owner, instance, verified, created, needs_upgrade
from spindles
%s
order by created
···
var spindle Spindle
var createdAt string
var verified sql.NullString
+
var needsUpgrade int
if err := rows.Scan(
&spindle.Id,
···
&spindle.Instance,
&verified,
&createdAt,
+
&needsUpgrade,
); err != nil {
return nil, err
}
···
spindle.Verified = &t
}
+
if needsUpgrade != 0 {
+
spindle.NeedsUpgrade = true
+
}
+
spindles = append(spindles, spindle)
}
···
whereClause = " where " + strings.Join(conditions, " and ")
}
-
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
+
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause)
res, err := e.Exec(query, args...)
if err != nil {
+29 -74
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
-
"strings"
+
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/serververify"
+
"tangled.sh/tangled.sh/core/appview/validator"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
)
···
IdResolver *idresolver.Resolver
Config *config.Config
Logger *slog.Logger
+
Validator *validator.Validator
}
type processFunc func(ctx context.Context, e *models.Event) error
···
}
switch e.Commit.Operation {
-
case models.CommitOperationCreate:
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
raw := json.RawMessage(e.Commit.Record)
record := tangled.RepoIssue{}
err = json.Unmarshal(raw, &record)
···
issue := db.IssueFromRecord(did, rkey, record)
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
-
return fmt.Errorf("title is empty after HTML sanitization")
-
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
+
return fmt.Errorf("failed to validate issue: %w", err)
}
tx, err := ddb.BeginTx(ctx, nil)
···
l.Error("failed to begin transaction", "err", err)
return err
}
+
defer tx.Rollback()
-
err = db.NewIssue(tx, &issue)
+
err = db.PutIssue(tx, &issue)
if err != nil {
l.Error("failed to create issue", "err", err)
return err
}
-
return nil
-
-
case models.CommitOperationUpdate:
-
raw := json.RawMessage(e.Commit.Record)
-
record := tangled.RepoIssue{}
-
err = json.Unmarshal(raw, &record)
+
err = tx.Commit()
if err != nil {
-
l.Error("invalid record", "err", err)
-
return err
-
}
-
-
body := ""
-
if record.Body != nil {
-
body = *record.Body
-
}
-
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
-
return fmt.Errorf("title is empty after HTML sanitization")
-
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
-
}
-
-
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
-
if err != nil {
-
l.Error("failed to update issue", "err", err)
+
l.Error("failed to commit txn", "err", err)
return err
}
return nil
case models.CommitOperationDelete:
-
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
+
if err := db.DeleteIssues(
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
l.Error("failed to delete", "err", err)
return fmt.Errorf("failed to delete issue record: %w", err)
}
···
return nil
}
-
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
+
return nil
}
func (i *Ingester) ingestIssueComment(e *models.Event) error {
···
}
switch e.Commit.Operation {
-
case models.CommitOperationCreate:
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
raw := json.RawMessage(e.Commit.Record)
record := tangled.RepoIssueComment{}
err = json.Unmarshal(raw, &record)
if err != nil {
-
l.Error("invalid record", "err", err)
-
return err
+
return fmt.Errorf("invalid record: %w", err)
}
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
if err != nil {
-
l.Error("failed to parse comment from record", "err", err)
-
return err
+
return fmt.Errorf("failed to parse comment from record: %w", err)
}
-
sanitizer := markup.NewSanitizer()
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
+
return fmt.Errorf("failed to validate comment: %w", err)
}
-
err = db.NewIssueComment(ddb, &comment)
+
_, err = db.AddIssueComment(ddb, *comment)
if err != nil {
-
l.Error("failed to create issue comment", "err", err)
-
return err
-
}
-
-
return nil
-
-
case models.CommitOperationUpdate:
-
raw := json.RawMessage(e.Commit.Record)
-
record := tangled.RepoIssueComment{}
-
err = json.Unmarshal(raw, &record)
-
if err != nil {
-
l.Error("invalid record", "err", err)
-
return err
-
}
-
-
sanitizer := markup.NewSanitizer()
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
-
return fmt.Errorf("body is empty after HTML sanitization")
-
}
-
-
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
-
if err != nil {
-
l.Error("failed to update issue comment", "err", err)
-
return err
+
return fmt.Errorf("failed to create issue comment: %w", err)
}
return nil
case models.CommitOperationDelete:
-
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
-
l.Error("failed to delete", "err", err)
+
if err := db.DeleteIssueComments(
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
return fmt.Errorf("failed to delete issue comment record: %w", err)
}
return nil
}
-
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
+
return nil
}
+477 -280
appview/issues/issues.go
···
package issues
import (
+
"context"
+
"database/sql"
+
"errors"
"fmt"
"log"
-
mathrand "math/rand/v2"
+
"log/slog"
"net/http"
"slices"
-
"strconv"
-
"strings"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
···
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
"tangled.sh/tangled.sh/core/appview/validator"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/idresolver"
+
tlog "tangled.sh/tangled.sh/core/log"
"tangled.sh/tangled.sh/core/tid"
)
···
db *db.DB
config *config.Config
notifier notify.Notifier
+
logger *slog.Logger
+
validator *validator.Validator
}
func New(
···
db *db.DB,
config *config.Config,
notifier notify.Notifier,
+
validator *validator.Validator,
) *Issues {
return &Issues{
oauth: oauth,
···
db: db,
config: config,
notifier: notifier,
+
logger: tlog.New("issues"),
+
validator: validator,
}
}
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "RepoSingleIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
-
return
-
}
-
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue and comments", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
if err != nil {
-
log.Println("failed to get issue reactions")
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
l.Error("failed to get issue reactions", "err", err)
}
userReactions := map[db.ReactionKind]bool{}
···
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
}
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
-
if err != nil {
-
log.Println("failed to resolve issue owner", err)
-
}
-
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Issue: issue,
-
Comments: comments,
-
-
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
-
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
CommentList: issue.CommentList(),
OrderedReactionKinds: db.OrderedReactionKinds,
Reactions: reactionCountMap,
UserReacted: userReactions,
})
-
}
-
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
+
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
-
return
-
}
+
switch r.Method {
+
case http.MethodGet:
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
})
+
case http.MethodPost:
+
noticeId := "issues"
+
newIssue := issue
+
newIssue.Title = r.FormValue("title")
+
newIssue.Body = r.FormValue("body")
-
collaborators, err := f.Collaborators(r.Context())
-
if err != nil {
-
log.Println("failed to fetch repo collaborators: %w", err)
-
}
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
-
return user.Did == collab.Did
-
})
-
isIssueOwner := user.Did == issue.OwnerDid
-
-
// TODO: make this more granular
-
if isIssueOwner || isCollaborator {
+
if err := rp.validator.ValidateIssue(newIssue); err != nil {
+
l.Error("validation error", "err", err)
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
+
return
+
}
-
closed := tangled.RepoIssueStateClosed
+
newRecord := newIssue.AsRecord()
+
// edit an atproto record
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
log.Println("failed to get authorized client", err)
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
return
}
+
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
+
if err != nil {
+
l.Error("failed to get record", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
+
return
+
}
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueStateNSID,
+
Collection: tangled.RepoIssueNSID,
Repo: user.Did,
-
Rkey: tid.TID(),
+
Rkey: newIssue.Rkey,
+
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueState{
-
Issue: issue.AtUri().String(),
-
State: closed,
-
},
+
Val: &newRecord,
},
})
+
if err != nil {
+
l.Error("failed to edit record on PDS", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
+
return
+
}
+
// modify on DB -- TODO: transact this cleverly
+
tx, err := rp.db.Begin()
if err != nil {
-
log.Println("failed to update issue state", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
l.Error("failed to edit issue on DB", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.PutIssue(tx, newIssue)
+
if err != nil {
+
log.Println("failed to edit issue", err)
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
l.Error("failed to edit issue", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
return
}
-
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
+
rp.pages.HxRefresh(w)
+
}
+
}
+
+
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DeleteIssue")
+
noticeId := "issue-actions-error"
+
+
user := rp.oauth.GetUser(r)
+
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
+
return
+
}
+
l = l.With("did", issue.Did, "rkey", issue.Rkey)
+
+
// delete from PDS
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
log.Println("failed to get authorized client", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
+
return
+
}
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.RepoIssueNSID,
+
Repo: issue.Did,
+
Rkey: issue.Rkey,
+
})
+
if err != nil {
+
// TODO: transact this better
+
l.Error("failed to delete issue from PDS", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
+
return
+
}
+
+
// delete from db
+
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
+
l.Error("failed to delete issue", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
+
return
+
}
+
+
// return to all issues page
+
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
+
}
+
+
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "CloseIssue")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
+
return
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
+
}
+
+
collaborators, err := f.Collaborators(r.Context())
+
if err != nil {
+
log.Println("failed to fetch repo collaborators: %w", err)
+
}
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
+
return user.Did == collab.Did
+
})
+
isIssueOwner := user.Did == issue.Did
+
+
// TODO: make this more granular
+
if isIssueOwner || isCollaborator {
+
err = db.CloseIssues(
+
rp.db,
+
db.FilterEq("id", issue.Id),
+
)
if err != nil {
log.Println("failed to close issue", err)
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
return
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
} else {
log.Println("user is not permitted to close issue")
···
}
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "ReopenIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
-
return
-
}
-
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
···
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
return user.Did == collab.Did
})
-
isIssueOwner := user.Did == issue.OwnerDid
+
isIssueOwner := user.Did == issue.Did
if isCollaborator || isIssueOwner {
-
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
+
err := db.ReopenIssues(
+
rp.db,
+
db.FilterEq("id", issue.Id),
+
)
if err != nil {
log.Println("failed to reopen issue", err)
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
return
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
} else {
log.Println("user is not the owner of the repo")
···
}
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "NewIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
switch r.Method {
-
case http.MethodPost:
-
body := r.FormValue("body")
-
if body == "" {
-
rp.pages.Notice(w, "issue", "Body is required")
-
return
-
}
+
body := r.FormValue("body")
+
if body == "" {
+
rp.pages.Notice(w, "issue", "Body is required")
+
return
+
}
-
commentId := mathrand.IntN(1000000)
-
rkey := tid.TID()
+
replyToUri := r.FormValue("reply-to")
+
var replyTo *string
+
if replyToUri != "" {
+
replyTo = &replyToUri
+
}
-
err := db.NewIssueComment(rp.db, &db.Comment{
-
OwnerDid: user.Did,
-
RepoAt: f.RepoAt(),
-
Issue: issueIdInt,
-
CommentId: commentId,
-
Body: body,
-
Rkey: rkey,
-
})
-
if err != nil {
-
log.Println("failed to create comment", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
+
comment := db.IssueComment{
+
Did: user.Did,
+
Rkey: tid.TID(),
+
IssueAt: issue.AtUri().String(),
+
ReplyTo: replyTo,
+
Body: body,
+
Created: time.Now(),
+
}
+
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
+
l.Error("failed to validate comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
+
record := comment.AsRecord()
-
createdAt := time.Now().Format(time.RFC3339)
-
ownerDid := user.Did
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue at", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
-
atUri := f.RepoAt().String()
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
+
// create a record first
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueCommentNSID,
+
Repo: comment.Did,
+
Rkey: comment.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
l.Error("failed to create comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
+
return
+
}
+
atUri := resp.Uri
+
defer func() {
+
if err := rollbackRecord(context.Background(), atUri, client); err != nil {
+
l.Error("rollback failed", "err", err)
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueCommentNSID,
-
Repo: user.Did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueComment{
-
Repo: &atUri,
-
Issue: issueAt,
-
Owner: &ownerDid,
-
Body: body,
-
CreatedAt: createdAt,
-
},
-
},
-
})
-
if err != nil {
-
log.Println("failed to create comment", err)
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
-
return
-
}
+
}()
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+
commentId, err := db.AddIssueComment(rp.db, comment)
+
if err != nil {
+
l.Error("failed to create comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
+
+
// reset atUri to make rollback a no-op
+
atUri = ""
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
}
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "IssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
-
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
return
}
+
comment := comments[0]
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
return
-
}
-
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
}
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
-
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
-
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
return
}
+
comment := comments[0]
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
return
-
}
-
-
if comment.OwnerDid != user.Did {
+
if comment.Did != user.Did {
+
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
return
}
···
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
case http.MethodPost:
// extract form value
···
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
rkey := comment.Rkey
-
// optimistic update
-
edited := time.Now()
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
now := time.Now()
+
newComment := comment
+
newComment.Body = newBody
+
newComment.Edited = &now
+
record := newComment.AsRecord()
+
+
_, err = db.AddIssueComment(rp.db, newComment)
if err != nil {
log.Println("failed to perferom update-description query", err)
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
}
// rkey is optional, it was introduced later
-
if comment.Rkey != "" {
+
if newComment.Rkey != "" {
// update the record on pds
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
if err != nil {
-
// failed to get record
-
log.Println(err, rkey)
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
return
}
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
-
record, _ := data.UnmarshalJSON(value)
-
-
repoAt := record["repo"].(string)
-
issueAt := record["issue"].(string)
-
createdAt := record["createdAt"].(string)
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
-
Rkey: rkey,
+
Rkey: newComment.Rkey,
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueComment{
-
Repo: &repoAt,
-
Issue: issueAt,
-
Owner: &comment.OwnerDid,
-
Body: newBody,
-
CreatedAt: createdAt,
-
},
+
Val: &record,
},
})
if err != nil {
-
log.Println(err)
+
l.Error("failed to update record on PDS", "err", err)
}
}
-
// optimistic update for htmx
-
comment.Body = newBody
-
comment.Edited = &edited
-
// return new comment body with htmx
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &newComment,
})
+
}
+
}
+
+
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
l.Error("failed to get repo and knot", "err", err)
return
+
}
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
}
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
+
if err != nil {
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
+
return
+
}
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
+
return
+
}
+
comment := comments[0]
+
+
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
Comment: &comment,
+
})
}
-
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "ReplyIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
+
return
+
}
+
comment := comments[0]
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
Comment: &comment,
+
})
+
}
+
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "DeleteIssueComment")
+
user := rp.oauth.GetUser(r)
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
+
}
+
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
+
if len(comments) != 1 {
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
+
return
+
}
+
comment := comments[0]
-
if comment.OwnerDid != user.Did {
+
if comment.Did != user.Did {
+
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
return
}
···
// optimistic deletion
deleted := time.Now()
-
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
+
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
if err != nil {
-
log.Println("failed to delete comment")
+
l.Error("failed to delete comment", "err", err)
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
return
}
···
return
}
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
-
Collection: tangled.GraphFollowNSID,
+
Collection: tangled.RepoIssueCommentNSID,
Repo: user.Did,
Rkey: comment.Rkey,
})
···
comment.Deleted = &deleted
// htmx fragment of comment after deletion
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
}
···
return
}
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
+
openVal := 0
+
if isOpen {
+
openVal = 1
+
}
+
issues, err := db.GetIssuesPaginated(
+
rp.db,
+
page,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("open", openVal),
+
)
if err != nil {
log.Println("failed to get issues", err)
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
}
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "NewIssue")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
···
RepoInfo: f.RepoInfo(user),
})
case http.MethodPost:
-
title := r.FormValue("title")
-
body := r.FormValue("body")
+
issue := &db.Issue{
+
RepoAt: f.RepoAt(),
+
Rkey: tid.TID(),
+
Title: r.FormValue("title"),
+
Body: r.FormValue("body"),
+
Did: user.Did,
+
Created: time.Now(),
+
}
-
if title == "" || body == "" {
-
rp.pages.Notice(w, "issues", "Title and body are required")
+
if err := rp.validator.ValidateIssue(issue); err != nil {
+
l.Error("validation error", "err", err)
+
rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
return
}
-
sanitizer := markup.NewSanitizer()
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
+
record := issue.AsRecord()
+
+
// create an atproto record
+
client, err := rp.oauth.AuthorizedClient(r)
+
if err != nil {
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoIssueNSID,
+
Repo: user.Did,
+
Rkey: issue.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
l.Error("failed to create issue", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
+
atUri := resp.Uri
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
return
}
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rollbackRecord(context.Background(), atUri, client)
-
issue := &db.Issue{
-
RepoAt: f.RepoAt(),
-
Rkey: tid.TID(),
-
Title: title,
-
Body: body,
-
OwnerDid: user.Did,
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if err := errors.Join(err1, err2); err != nil {
+
l.Error("failed to rollback txn", "err", err)
+
}
}
-
err = db.NewIssue(tx, issue)
+
defer rollback()
+
+
err = db.PutIssue(tx, issue)
if err != nil {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
-
return
-
}
-
atUri := f.RepoAt().String()
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueNSID,
-
Repo: user.Did,
-
Rkey: issue.Rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssue{
-
Repo: atUri,
-
Title: title,
-
Body: &body,
-
},
-
},
-
})
-
if err != nil {
+
if err = tx.Commit(); err != nil {
log.Println("failed to create issue", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
+
// everything is successful, do not rollback the atproto record
+
atUri = ""
rp.notifier.NewIssue(r.Context(), issue)
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
return
}
}
+
+
// this is used to rollback changes made to the PDS
+
//
+
// it is a no-op if the provided ATURI is empty
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
+
if aturi == "" {
+
return nil
+
}
+
+
parsed := syntax.ATURI(aturi)
+
+
collection := parsed.Collection().String()
+
repo := parsed.Authority().String()
+
rkey := parsed.RecordKey().String()
+
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
+
Collection: collection,
+
Repo: repo,
+
Rkey: rkey,
+
})
+
return err
+
}
+24 -10
appview/issues/router.go
···
r.Route("/", func(r chi.Router) {
r.With(middleware.Paginate).Get("/", i.RepoIssues)
-
r.Get("/{issue}", i.RepoSingleIssue)
+
+
r.Route("/{issue}", func(r chi.Router) {
+
r.Use(mw.ResolveIssue())
+
r.Get("/", i.RepoSingleIssue)
+
+
// authenticated routes
+
r.Group(func(r chi.Router) {
+
r.Use(middleware.AuthMiddleware(i.oauth))
+
r.Post("/comment", i.NewIssueComment)
+
r.Route("/comment/{commentId}/", func(r chi.Router) {
+
r.Get("/", i.IssueComment)
+
r.Delete("/", i.DeleteIssueComment)
+
r.Get("/edit", i.EditIssueComment)
+
r.Post("/edit", i.EditIssueComment)
+
r.Get("/reply", i.ReplyIssueComment)
+
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
+
})
+
r.Get("/edit", i.EditIssue)
+
r.Post("/edit", i.EditIssue)
+
r.Delete("/", i.DeleteIssue)
+
r.Post("/close", i.CloseIssue)
+
r.Post("/reopen", i.ReopenIssue)
+
})
+
})
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(i.oauth))
r.Get("/new", i.NewIssue)
r.Post("/new", i.NewIssue)
-
r.Post("/{issue}/comment", i.NewIssueComment)
-
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
-
r.Get("/", i.IssueComment)
-
r.Delete("/", i.DeleteIssueComment)
-
r.Get("/edit", i.EditIssueComment)
-
r.Post("/edit", i.EditIssueComment)
-
})
-
r.Post("/{issue}/close", i.CloseIssue)
-
r.Post("/{issue}/reopen", i.ReopenIssue)
})
})
+5 -34
appview/knots/knots.go
···
import (
"errors"
"fmt"
-
"log"
"log/slog"
"net/http"
"slices"
···
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/serververify"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
···
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
-
-
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
return r
}
···
if err != nil {
l.Error("verification failed", "err", err)
-
if errors.Is(err, serververify.FetchError) {
-
k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
+
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
+
k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!")
return
}
···
return
}
-
// if this knot was previously read-only, then emit a record too
+
// if this knot requires upgrade, then emit a record too
//
// this is part of migrating from the old knot system to the new one
-
if registration.ReadOnly {
+
if registration.NeedsUpgrade {
// re-announce by registering under same rkey
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
···
return
}
updatedRegistration := registrations[0]
-
-
log.Println(updatedRegistration)
w.Header().Set("HX-Reswap", "outerHTML")
k.Pages.KnotListing(w, pages.KnotListingParams{
···
// ok
k.Pages.HxRefresh(w)
}
-
-
func (k *Knots) banner(w http.ResponseWriter, r *http.Request) {
-
user := k.OAuth.GetUser(r)
-
l := k.Logger.With("handler", "removeMember")
-
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
-
-
registrations, err := db.GetRegistrations(
-
k.Db,
-
db.FilterEq("did", user.Did),
-
db.FilterEq("read_only", 1),
-
)
-
if err != nil {
-
l.Error("non-fatal: failed to get registrations")
-
return
-
}
-
-
if registrations == nil {
-
return
-
}
-
-
k.Pages.KnotBanner(w, pages.KnotBannerParams{
-
Registrations: registrations,
-
})
-
}
+40
appview/middleware/middleware.go
···
}
}
+
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
+
func (mw Middleware) ResolveIssue() middlewareFunc {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
f, err := mw.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to fully resolve repo", err)
+
mw.pages.ErrorKnot404(w)
+
return
+
}
+
+
issueIdStr := chi.URLParam(r, "issue")
+
issueId, err := strconv.Atoi(issueIdStr)
+
if err != nil {
+
log.Println("failed to fully resolve issue ID", err)
+
mw.pages.ErrorKnot404(w)
+
return
+
}
+
+
issues, err := db.GetIssues(
+
mw.db,
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("issue_id", issueId),
+
)
+
if err != nil {
+
log.Println("failed to get issues", "err", err)
+
return
+
}
+
if len(issues) != 1 {
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
+
return
+
}
+
issue := issues[0]
+
+
ctx := context.WithValue(r.Context(), "issue", &issue)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
})
+
}
+
}
+
// this should serve the go-import meta tag even if the path is technically
// a 404 like tangled.sh/oppi.li/go-git/v5
func (mw Middleware) GoImport() middlewareFunc {
+3
appview/pages/funcmap.go
···
"split": func(s string) []string {
return strings.Split(s, "\n")
},
+
"contains": func(s string, target string) bool {
+
return strings.Contains(s, target)
+
},
"resolve": func(s string) string {
identity, err := p.resolver.ResolveIdent(context.Background(), s)
+12
appview/pages/markup/format.go
···
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
}
+
// ReadmeFilenames contains the list of common README filenames to search for,
+
// in order of preference. Only includes well-supported formats.
+
var ReadmeFilenames = []string{
+
"README.md", "readme.md",
+
"README",
+
"readme",
+
"README.markdown",
+
"readme.markdown",
+
"README.txt",
+
"readme.txt",
+
}
+
func GetFormat(filename string) Format {
for format, extensions := range FileTypes {
for _, extension := range extensions {
+10 -8
appview/pages/markup/markdown.go
···
"github.com/yuin/goldmark/util"
htmlparse "golang.org/x/net/html"
+
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
)
···
actualPath := rctx.actualPath(dst)
+
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
+
+
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
+
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
+
parsedURL := &url.URL{
-
Scheme: scheme,
-
Host: rctx.Knot,
-
Path: path.Join("/",
-
rctx.RepoInfo.OwnerDid,
-
rctx.RepoInfo.Name,
-
"raw",
-
url.PathEscape(rctx.RepoInfo.Ref),
-
actualPath),
+
Scheme: scheme,
+
Host: rctx.Knot,
+
Path: path.Join("/xrpc", tangled.RepoBlobNSID),
+
RawQuery: query,
}
newPath := parsedURL.String()
return newPath
+62 -50
appview/pages/pages.go
···
return fragmentPaths, nil
}
-
func (p *Pages) fragments() (*template.Template, error) {
-
fragmentPaths, err := p.fragmentPaths()
-
if err != nil {
-
return nil, err
-
}
-
-
funcs := p.funcMap()
-
-
// parse all fragments together
-
allFragments := template.New("").Funcs(funcs)
-
for _, f := range fragmentPaths {
-
name := p.pathToName(f)
-
-
pf, err := template.New(name).
-
Funcs(funcs).
-
ParseFS(p.embedFS, f)
-
if err != nil {
-
return nil, err
-
}
-
-
allFragments, err = allFragments.AddParseTree(name, pf.Tree)
-
if err != nil {
-
return nil, err
-
}
-
}
-
-
return allFragments, nil
-
}
-
// parse without memoization
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
paths, err := p.fragmentPaths()
···
return p.execute("user/settings/emails", w, params)
}
-
type KnotBannerParams struct {
+
type UpgradeBannerParams struct {
Registrations []db.Registration
+
Spindles []db.Spindle
}
-
func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error {
-
return p.executePlain("knots/fragments/banner", w, params)
+
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
+
return p.executePlain("banner", w, params)
}
type KnotsParams struct {
···
VerifiedCommits commitverify.VerifiedCommits
Languages []types.RepoLanguageDetails
Pipelines map[string]db.Pipeline
+
NeedsKnotUpgrade bool
types.RepoIndexResponse
}
···
params.Active = "overview"
if params.IsEmpty {
return p.executeRepo("repo/empty", w, params)
+
}
+
+
if params.NeedsKnotUpgrade {
+
return p.executeRepo("repo/needsUpgrade", w, params)
}
p.rctx.RepoInfo = params.RepoInfo
···
ShowRendered bool
RenderToggle bool
RenderedContents template.HTML
-
types.RepoBlobResponse
+
*tangled.RepoBlob_Output
+
// Computed fields for template compatibility
+
Contents string
+
Lines int
+
SizeHint uint64
+
IsBinary bool
}
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
···
RepoInfo repoinfo.RepoInfo
Active string
Issue *db.Issue
-
Comments []db.Comment
+
CommentList []db.CommentListItem
IssueOwnerHandle string
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
+
}
-
State string
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/issues/issue", w, params)
+
}
+
+
type EditIssueParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Action string
+
}
+
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
+
params.Action = "edit"
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
}
type ThreadReactionFragmentParams struct {
···
return p.executePlain("repo/fragments/reaction", w, params)
}
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
-
params.Active = "issues"
-
if params.Issue.Open {
-
params.State = "open"
-
} else {
-
params.State = "closed"
-
}
-
return p.executeRepo("repo/issues/issue", w, params)
-
}
-
type RepoNewIssueParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue // existing issue if any -- passed when editing
Active string
+
Action string
}
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
params.Active = "issues"
+
params.Action = "create"
return p.executeRepo("repo/issues/new", w, params)
}
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
+
Comment *db.IssueComment
}
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}
-
type SingleIssueCommentParams struct {
+
type ReplyIssueCommentPlaceholderParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
+
Comment *db.IssueComment
}
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
+
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
+
return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
+
}
+
+
type ReplyIssueCommentParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.IssueComment
+
}
+
+
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
+
return p.executePlain("repo/issues/fragments/replyComment", w, params)
+
}
+
+
type IssueCommentBodyParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.IssueComment
+
}
+
+
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
}
type RepoNewPullParams struct {
+38
appview/pages/templates/banner.html
···
+
{{ define "banner" }}
+
<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">
+
<details class="group p-2">
+
<summary class="list-none cursor-pointer">
+
<div class="flex gap-4 items-center">
+
<span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span>
+
<span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span>
+
+
<span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span>
+
<span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span>
+
</div>
+
</summary>
+
+
{{ if .Registrations }}
+
<ul class="list-disc mx-12 my-2">
+
{{range .Registrations}}
+
<li>Knot: {{ .Domain }}</li>
+
{{ end }}
+
</ul>
+
{{ end }}
+
+
{{ if .Spindles }}
+
<ul class="list-disc mx-12 my-2">
+
{{range .Spindles}}
+
<li>Spindle: {{ .Instance }}</li>
+
{{ end }}
+
</ul>
+
{{ end }}
+
+
<div class="mx-6">
+
These services may not be fully accessible until upgraded.
+
<a class="underline text-red-800 dark:text-red-200"
+
href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md">
+
Click to read the upgrade guide</a>.
+
</div>
+
</details>
+
</div>
+
{{ end }}
+8
appview/pages/templates/fragments/logotype.html
···
+
{{ define "fragments/logotype" }}
+
<span class="flex items-center gap-2">
+
<span class="font-bold italic">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
+
alpha
+
</span>
+
<span>
+
{{ end }}
-9
appview/pages/templates/knots/fragments/banner.html
···
-
{{ define "knots/fragments/banner" }}
-
<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">
-
A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }})
-
that you administer is presently read-only. Consider upgrading this knot to
-
continue creating repositories on it.
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>.
-
</div>
-
{{ end }}
-
+2 -2
appview/pages/templates/knots/fragments/knotListing.html
···
</span>
{{ template "knots/fragments/addMemberModal" . }}
{{ block "knotDeleteButton" . }} {{ end }}
-
{{ else if .IsReadOnly }}
+
{{ else if .IsNeedsUpgrade }}
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
-
{{ i "shield-alert" "w-4 h-4" }} read-only
+
{{ i "shield-alert" "w-4 h-4" }} needs upgrade
</span>
{{ block "knotRetryButton" . }} {{ end }}
{{ block "knotDeleteButton" . }} {{ end }}
+3 -6
appview/pages/templates/knots/index.html
···
{{ define "title" }}knots{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom">
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
-
-
<span class="flex items-center gap-1 text-sm">
+
<span class="flex items-center gap-1">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
-
docs
-
</a>
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
</span>
</div>
+12 -4
appview/pages/templates/layouts/base.html
···
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
{{ block "extrameta" . }}{{ end }}
</head>
-
<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">
+
<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">
{{ block "topbarLayout" . }}
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
+
<header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;">
+
+
{{ if .LoggedInUser }}
+
<div id="upgrade-banner"
+
hx-get="/upgradeBanner"
+
hx-trigger="load"
+
hx-swap="innerHTML">
+
</div>
+
{{ end }}
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
{{ block "mainLayout" . }}
-
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
+
<div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4">
{{ block "contentLayout" . }}
<main class="col-span-1 md:col-span-8">
{{ block "content" . }}{{ end }}
···
{{ end }}
{{ block "footerLayout" . }}
-
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
+
<footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12">
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+1 -10
appview/pages/templates/layouts/fragments/topbar.html
···
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
<div class="flex justify-between p-0 items-center">
<div id="left-items">
-
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
-
tangled<sub>alpha</sub>
-
</a>
+
<a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a>
</div>
<div id="right-items" class="flex items-center gap-2">
···
</div>
</div>
</nav>
-
{{ if .LoggedInUser }}
-
<div id="upgrade-banner"
-
hx-get="/knots/upgradeBanner"
-
hx-trigger="load"
-
hx-swap="innerHTML">
-
</div>
-
{{ end }}
{{ end }}
{{ define "newButton" }}
+2 -2
appview/pages/templates/layouts/repobase.html
···
</section>
<section
-
class="w-full flex flex-col drop-shadow-sm"
+
class="w-full flex flex-col"
>
<nav class="w-full pl-4 overflow-auto">
<div class="flex z-60">
···
</div>
</nav>
<section
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
>
{{ block "repoContent" . }}{{ end }}
</section>
+6
appview/pages/templates/repo/fragments/diff.html
···
{{ $last := sub (len $diff) 1 }}
<div class="flex flex-col gap-4">
+
{{ if eq (len $diff) 0 }}
+
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
+
<p>No differences found between the selected revisions.</p>
+
</div>
+
{{ else }}
{{ range $idx, $hunk := $diff }}
{{ with $hunk }}
<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 }}">
···
</div>
</details>
{{ end }}
+
{{ end }}
{{ end }}
</div>
{{ end }}
+6
appview/pages/templates/repo/fragments/languageBall.html
···
+
{{ define "repo/fragments/languageBall" }}
+
<div
+
class="size-2 rounded-full"
+
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>
+
{{ end }}
+2 -7
appview/pages/templates/repo/index.html
···
></div>
{{ end }}
</summary>
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
{{ range $value := .Languages }}
<div
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
>
-
<div
-
class="rounded-full h-2 w-2"
-
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ $value.Color }} 70%, white), {{ $value.Color }} 30%, color-mix(in srgb, {{ $value.Color }} 85%, black));"
-
>
-
</div>
+
{{ template "repo/fragments/languageBall" $value.Name }}
<div>{{ or $value.Name "Other" }}
<span class="text-gray-500 dark:text-gray-400">
{{ if lt $value.Percentage 0.05 }}
···
</div>
</details>
{{ end }}
-
{{ define "branchSelector" }}
<div class="flex gap-2 items-center justify-between w-full">
+58
appview/pages/templates/repo/issues/fragments/commentList.html
···
+
{{ define "repo/issues/fragments/commentList" }}
+
<div class="flex flex-col gap-8">
+
{{ range $item := .CommentList }}
+
{{ template "commentListing" (list $ .) }}
+
{{ end }}
+
<div>
+
{{ end }}
+
+
{{ define "commentListing" }}
+
{{ $root := index . 0 }}
+
{{ $comment := index . 1 }}
+
{{ $params :=
+
(dict
+
"RepoInfo" $root.RepoInfo
+
"LoggedInUser" $root.LoggedInUser
+
"Issue" $root.Issue
+
"Comment" $comment.Self) }}
+
+
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
+
{{ template "topLevelComment" $params }}
+
+
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
+
{{ range $index, $reply := $comment.Replies }}
+
<div class="relative ">
+
<!-- Horizontal connector -->
+
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
+
+
<div class="pl-2">
+
{{
+
template "replyComment"
+
(dict
+
"RepoInfo" $root.RepoInfo
+
"LoggedInUser" $root.LoggedInUser
+
"Issue" $root.Issue
+
"Comment" $reply)
+
}}
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
+
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
+
</div>
+
{{ end }}
+
+
{{ define "topLevelComment" }}
+
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800">
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
+
</div>
+
{{ end }}
+
+
{{ define "replyComment" }}
+
<div class="p-4 w-full mx-auto overflow-hidden">
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
+
</div>
+
{{ end }}
+37 -45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
{{ define "repo/issues/fragments/editIssueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
+
<div id="comment-body-{{.Comment.Id}}" class="pt-2">
+
<textarea
+
id="edit-textarea-{{ .Comment.Id }}"
+
name="body"
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
+
rows="5"
+
autofocus>{{ .Comment.Body }}</textarea>
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
author
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ template "repo/fragments/time" .Created }}
-
</a>
-
-
<button
-
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-include="#edit-textarea-{{ .CommentId }}"
-
hx-target="#comment-container-{{ .CommentId }}"
-
hx-swap="outerHTML">
-
{{ i "check" "w-4 h-4" }}
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
<button
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
-
hx-target="#comment-container-{{ .CommentId }}"
-
hx-swap="outerHTML">
-
{{ i "x" "w-4 h-4" }}
-
</button>
-
<span id="comment-{{.CommentId}}-status"></span>
-
</div>
+
{{ template "editActions" $ }}
+
</div>
+
{{ end }}
-
<div>
-
<textarea
-
id="edit-textarea-{{ .CommentId }}"
-
name="body"
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
-
</div>
+
{{ define "editActions" }}
+
<div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2">
+
{{ template "cancel" . }}
+
{{ template "save" . }}
</div>
-
{{ end }}
+
{{ end }}
+
+
{{ define "save" }}
+
<button
+
class="btn-create py-0 flex gap-1 items-center group text-sm"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-include="#edit-textarea-{{ .Comment.Id }}"
+
hx-target="#comment-body-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "check" "size-4" }}
+
save
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
{{ end }}
+
{{ define "cancel" }}
+
<button
+
class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-target="#comment-body-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "x" "size-4" }}
+
cancel
+
</button>
+
{{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
···
-
{{ define "repo/issues/fragments/issueComment" }}
-
{{ with .Comment }}
-
<div id="comment-container-{{.CommentId}}">
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
-
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
author
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ if .Deleted }}
-
deleted {{ template "repo/fragments/time" .Deleted }}
-
{{ else if .Edited }}
-
edited {{ template "repo/fragments/time" .Edited }}
-
{{ else }}
-
{{ template "repo/fragments/time" .Created }}
-
{{ end }}
-
</a>
-
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
-
{{ if and $isCommentOwner (not .Deleted) }}
-
<button
-
class="btn px-2 py-1 text-sm"
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "pencil" "w-4 h-4" }}
-
</button>
-
<button
-
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
-
hx-confirm="Are you sure you want to delete your comment?"
-
hx-swap="outerHTML"
-
hx-target="#comment-container-{{.CommentId}}"
-
>
-
{{ i "trash-2" "w-4 h-4" }}
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
-
</div>
-
{{ if not .Deleted }}
-
<div class="prose dark:prose-invert">
-
{{ .Body | markdown }}
-
</div>
-
{{ end }}
-
</div>
-
{{ end }}
-
{{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
+
{{ define "repo/issues/fragments/issueCommentActions" }}
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
+
<div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2">
+
{{ template "edit" . }}
+
{{ template "delete" . }}
+
</div>
+
{{ end }}
+
{{ end }}
+
+
{{ define "edit" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}">
+
{{ i "pencil" "size-3" }}
+
edit
+
</a>
+
{{ end }}
+
+
{{ define "delete" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-confirm="Are you sure you want to delete your comment?"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}"
+
>
+
{{ i "trash-2" "size-3" }}
+
delete
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
+
{{ define "repo/issues/fragments/issueCommentBody" }}
+
<div id="comment-body-{{.Comment.Id}}">
+
{{ if not .Comment.Deleted }}
+
<div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div>
+
{{ else }}
+
<div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div>
+
{{ end }}
+
</div>
+
{{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
+
{{ define "repo/issues/fragments/issueCommentHeader" }}
+
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 ">
+
{{ template "user/fragments/picHandleLink" .Comment.Did }}
+
{{ template "hats" $ }}
+
{{ template "timestamp" . }}
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
+
{{ template "editIssueComment" . }}
+
{{ template "deleteIssueComment" . }}
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "hats" }}
+
{{ $isIssueAuthor := eq .Comment.Did .Issue.Did }}
+
{{ if $isIssueAuthor }}
+
(author)
+
{{ end }}
+
{{ end }}
+
+
{{ define "timestamp" }}
+
<a href="#{{ .Comment.Id }}"
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
+
id="{{ .Comment.Id }}">
+
{{ if .Comment.Deleted }}
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
+
{{ else if .Comment.Edited }}
+
edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }}
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Created }}
+
{{ end }}
+
</a>
+
{{ end }}
+
+
{{ define "editIssueComment" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
+
+
{{ define "deleteIssueComment" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
+
hx-confirm="Are you sure you want to delete your comment?"
+
hx-swap="outerHTML"
+
hx-target="#comment-body-{{.Comment.Id}}"
+
>
+
{{ i "trash-2" "size-3" }}
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
···
+
{{ define "repo/issues/fragments/newComment" }}
+
{{ if .LoggedInUser }}
+
<form
+
id="comment-form"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
>
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full">
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
+
</div>
+
<textarea
+
id="comment-textarea"
+
name="body"
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
+
placeholder="Add to the discussion. Markdown is supported."
+
onkeyup="updateCommentForm()"
+
rows="5"
+
></textarea>
+
<div id="issue-comment"></div>
+
<div id="issue-action" class="error"></div>
+
</div>
+
+
<div class="flex gap-2 mt-2">
+
<button
+
id="comment-button"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
type="submit"
+
hx-disabled-elt="#comment-button"
+
class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group"
+
disabled
+
>
+
{{ i "message-square-plus" "w-4 h-4" }}
+
comment
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
+
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
+
<button
+
id="close-button"
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-indicator="#close-spinner"
+
hx-trigger="click"
+
>
+
{{ i "ban" "w-4 h-4" }}
+
close
+
<span id="close-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
<div
+
id="close-with-comment"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
hx-trigger="click from:#close-button"
+
hx-disabled-elt="#close-with-comment"
+
hx-target="#issue-comment"
+
hx-indicator="#close-spinner"
+
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
+
hx-swap="none"
+
>
+
</div>
+
<div
+
id="close-issue"
+
hx-disabled-elt="#close-issue"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
+
hx-trigger="click from:#close-button"
+
hx-target="#issue-action"
+
hx-indicator="#close-spinner"
+
hx-swap="none"
+
>
+
</div>
+
<script>
+
document.addEventListener('htmx:configRequest', function(evt) {
+
if (evt.target.id === 'close-with-comment') {
+
const commentText = document.getElementById('comment-textarea').value.trim();
+
if (commentText === '') {
+
evt.detail.parameters = {};
+
evt.preventDefault();
+
}
+
}
+
});
+
</script>
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
+
hx-indicator="#reopen-spinner"
+
hx-swap="none"
+
>
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
+
reopen
+
<span id="reopen-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
{{ end }}
+
+
<script>
+
function updateCommentForm() {
+
const textarea = document.getElementById('comment-textarea');
+
const commentButton = document.getElementById('comment-button');
+
const closeButton = document.getElementById('close-button');
+
+
if (textarea.value.trim() !== '') {
+
commentButton.removeAttribute('disabled');
+
} else {
+
commentButton.setAttribute('disabled', '');
+
}
+
+
if (closeButton) {
+
if (textarea.value.trim() !== '') {
+
closeButton.innerHTML = `
+
{{ i "ban" "w-4 h-4" }}
+
<span>close with comment</span>
+
<span id="close-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>`;
+
} else {
+
closeButton.innerHTML = `
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
<span id="close-spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>`;
+
}
+
}
+
}
+
+
document.addEventListener('DOMContentLoaded', function() {
+
updateCommentForm();
+
});
+
</script>
+
</div>
+
</form>
+
{{ else }}
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
+
<a href="/login" class="underline">login</a> to join the discussion
+
</div>
+
{{ end }}
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
+
{{ define "repo/issues/fragments/putIssue" }}
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
+
<form
+
{{ if eq .Action "edit" }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
{{ else }}
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
+
{{ end }}
+
hx-swap="none"
+
hx-indicator="#spinner">
+
<div class="flex flex-col gap-2">
+
<div>
+
<label for="title">title</label>
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
+
</div>
+
<div>
+
<label for="body">body</label>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y"
+
placeholder="Describe your issue. Markdown is supported."
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+
</div>
+
<div class="flex justify-between">
+
<div id="issues" class="error"></div>
+
<div class="flex gap-2 items-center">
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
type="button"
+
{{ if .Issue }}
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
+
{{ else }}
+
href="/{{ .RepoInfo.FullName }}/issues"
+
{{ end }}
+
>
+
{{ i "x" "w-4 h-4" }}
+
cancel
+
</a>
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ if eq .Action "edit" }}
+
{{ i "pencil" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ else }}
+
{{ i "circle-plus" "w-4 h-4" }}
+
{{ .Action }} issue
+
{{ end }}
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
</div>
+
</form>
+
{{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
···
+
{{ define "repo/issues/fragments/replyComment" }}
+
<form
+
class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2"
+
id="reply-form-{{ .Comment.Id }}"
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
+
hx-on::after-request="if(event.detail.successful) this.reset()"
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
+
>
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
+
<textarea
+
id="reply-{{.Comment.Id}}-textarea"
+
name="body"
+
class="w-full p-2"
+
placeholder="Leave a reply..."
+
autofocus
+
rows="3"
+
hx-trigger="keydown[ctrlKey&&key=='Enter']"
+
hx-target="#reply-form-{{ .Comment.Id }}"
+
hx-get="#"
+
hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea>
+
+
<input
+
type="text"
+
id="reply-to"
+
name="reply-to"
+
required
+
value="{{ .Comment.AtUri }}"
+
class="hidden"
+
/>
+
{{ template "replyActions" . }}
+
</form>
+
{{ end }}
+
+
{{ define "replyActions" }}
+
<div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm">
+
{{ template "cancel" . }}
+
{{ template "reply" . }}
+
</div>
+
{{ end }}
+
+
{{ define "cancel" }}
+
<button
+
class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder"
+
hx-target="#reply-form-{{ .Comment.Id }}"
+
hx-swap="outerHTML">
+
{{ i "x" "size-4" }}
+
cancel
+
</button>
+
{{ end }}
+
+
{{ define "reply" }}
+
<button
+
id="reply-{{ .Comment.Id }}"
+
type="submit"
+
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
+
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
reply
+
</button>
+
{{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
+
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
+
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
+
{{ if .LoggedInUser }}
+
<img
+
src="{{ tinyAvatar .LoggedInUser.Did }}"
+
alt=""
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
+
/>
+
{{ end }}
+
<input
+
class="w-full py-2 border-none focus:outline-none"
+
placeholder="Leave a reply..."
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply"
+
hx-trigger="focus"
+
hx-target="closest div"
+
hx-swap="outerHTML"
+
>
+
</input>
+
</div>
+
{{ end }}
+95 -202
appview/pages/templates/repo/issues/issue.html
···
{{ end }}
{{ define "repoContent" }}
-
<header class="pb-4">
-
<h1 class="text-2xl">
-
{{ .Issue.Title | description }}
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
-
</h1>
-
</header>
+
<section id="issue-{{ .Issue.IssueId }}">
+
{{ template "issueHeader" .Issue }}
+
{{ template "issueInfo" . }}
+
{{ if .Issue.Body }}
+
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
+
{{ end }}
+
{{ template "issueReactions" . }}
+
</section>
+
{{ end }}
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
-
{{ $icon := "ban" }}
-
{{ if eq .State "open" }}
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
-
{{ $icon = "circle-dot" }}
-
{{ end }}
+
{{ define "issueHeader" }}
+
<header class="pb-2">
+
<h1 class="text-2xl">
+
{{ .Title | description }}
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
+
</h1>
+
</header>
+
{{ end }}
-
<section class="mt-2">
-
<div class="inline-flex items-center gap-2">
-
<div id="state"
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
-
<span class="text-white">{{ .State }}</span>
-
</div>
-
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
-
opened by
-
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
-
{{ template "user/fragments/picHandleLink" $owner }}
-
<span class="select-none before:content-['\00B7']"></span>
-
{{ template "repo/fragments/time" .Issue.Created }}
-
</span>
-
</div>
+
{{ define "issueInfo" }}
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
{{ if eq .Issue.State "open" }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "circle-dot" }}
+
{{ end }}
+
<div class="inline-flex items-center gap-2">
+
<div id="state"
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
+
<span class="text-white">{{ .Issue.State }}</span>
+
</div>
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
+
opened by
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
+
<span class="select-none before:content-['\00B7']"></span>
+
{{ if .Issue.Edited }}
+
edited {{ template "repo/fragments/time" .Issue.Edited }}
+
{{ else }}
+
{{ template "repo/fragments/time" .Issue.Created }}
+
{{ end }}
+
</span>
-
{{ if .Issue.Body }}
-
<article id="body" class="mt-8 prose dark:prose-invert">
-
{{ .Issue.Body | markdown }}
-
</article>
-
{{ end }}
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
+
{{ template "issueActions" . }}
+
{{ end }}
+
</div>
+
<div id="issue-actions-error" class="error"></div>
+
{{ end }}
-
<div class="flex items-center gap-2 mt-2">
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
-
{{ range $kind := .OrderedReactionKinds }}
-
{{
-
template "repo/fragments/reaction"
-
(dict
-
"Kind" $kind
-
"Count" (index $.Reactions $kind)
-
"IsReacted" (index $.UserReacted $kind)
-
"ThreadAt" $.Issue.AtUri)
-
}}
-
{{ end }}
-
</div>
-
</section>
+
{{ define "issueActions" }}
+
{{ template "editIssue" . }}
+
{{ template "deleteIssue" . }}
{{ end }}
-
{{ define "repoAfter" }}
-
<section id="comments" class="my-2 mt-2 space-y-2 relative">
-
{{ range $index, $comment := .Comments }}
-
<div
-
id="comment-{{ .CommentId }}"
-
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">
-
{{ if gt $index 0 }}
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
-
{{ end }}
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
-
</div>
-
{{ end }}
-
</section>
+
{{ define "editIssue" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
+
hx-swap="innerHTML"
+
hx-target="#issue-{{.Issue.IssueId}}">
+
{{ i "pencil" "size-3" }}
+
</a>
+
{{ end }}
-
{{ block "newComment" . }} {{ end }}
+
{{ define "deleteIssue" }}
+
<a
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
+
hx-confirm="Are you sure you want to delete your issue?"
+
hx-swap="none">
+
{{ i "trash-2" "size-3" }}
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+
{{ define "issueReactions" }}
+
<div class="flex items-center gap-2 mt-2">
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
+
{{ range $kind := .OrderedReactionKinds }}
+
{{
+
template "repo/fragments/reaction"
+
(dict
+
"Kind" $kind
+
"Count" (index $.Reactions $kind)
+
"IsReacted" (index $.UserReacted $kind)
+
"ThreadAt" $.Issue.AtUri)
+
}}
+
{{ end }}
+
</div>
{{ end }}
-
{{ define "newComment" }}
-
{{ if .LoggedInUser }}
-
<form
-
id="comment-form"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
-
hx-on::after-request="if(event.detail.successful) this.reset()"
-
>
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
-
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
-
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
-
</div>
-
<textarea
-
id="comment-textarea"
-
name="body"
-
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
-
placeholder="Add to the discussion. Markdown is supported."
-
onkeyup="updateCommentForm()"
-
></textarea>
-
<div id="issue-comment"></div>
-
<div id="issue-action" class="error"></div>
-
</div>
-
-
<div class="flex gap-2 mt-2">
-
<button
-
id="comment-button"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
-
type="submit"
-
hx-disabled-elt="#comment-button"
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"
-
disabled
-
>
-
{{ i "message-square-plus" "w-4 h-4" }}
-
comment
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
-
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
-
<button
-
id="close-button"
-
type="button"
-
class="btn flex items-center gap-2"
-
hx-indicator="#close-spinner"
-
hx-trigger="click"
-
>
-
{{ i "ban" "w-4 h-4" }}
-
close
-
<span id="close-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<div
-
id="close-with-comment"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
-
hx-trigger="click from:#close-button"
-
hx-disabled-elt="#close-with-comment"
-
hx-target="#issue-comment"
-
hx-indicator="#close-spinner"
-
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
-
hx-swap="none"
-
>
-
</div>
-
<div
-
id="close-issue"
-
hx-disabled-elt="#close-issue"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
-
hx-trigger="click from:#close-button"
-
hx-target="#issue-action"
-
hx-indicator="#close-spinner"
-
hx-swap="none"
-
>
-
</div>
-
<script>
-
document.addEventListener('htmx:configRequest', function(evt) {
-
if (evt.target.id === 'close-with-comment') {
-
const commentText = document.getElementById('comment-textarea').value.trim();
-
if (commentText === '') {
-
evt.detail.parameters = {};
-
evt.preventDefault();
-
}
-
}
-
});
-
</script>
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
-
<button
-
type="button"
-
class="btn flex items-center gap-2"
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
-
hx-indicator="#reopen-spinner"
-
hx-swap="none"
-
>
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
-
reopen
-
<span id="reopen-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
{{ end }}
-
-
<script>
-
function updateCommentForm() {
-
const textarea = document.getElementById('comment-textarea');
-
const commentButton = document.getElementById('comment-button');
-
const closeButton = document.getElementById('close-button');
-
-
if (textarea.value.trim() !== '') {
-
commentButton.removeAttribute('disabled');
-
} else {
-
commentButton.setAttribute('disabled', '');
-
}
+
{{ define "repoAfter" }}
+
<div class="flex flex-col gap-4 mt-4">
+
{{
+
template "repo/issues/fragments/commentList"
+
(dict
+
"RepoInfo" $.RepoInfo
+
"LoggedInUser" $.LoggedInUser
+
"Issue" $.Issue
+
"CommentList" $.Issue.CommentList)
+
}}
-
if (closeButton) {
-
if (textarea.value.trim() !== '') {
-
closeButton.innerHTML = `
-
{{ i "ban" "w-4 h-4" }}
-
<span>close with comment</span>
-
<span id="close-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>`;
-
} else {
-
closeButton.innerHTML = `
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
<span id="close-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>`;
-
}
-
}
-
}
+
{{ template "repo/issues/fragments/newComment" . }}
+
<div>
+
{{ end }}
-
document.addEventListener('DOMContentLoaded', function() {
-
updateCommentForm();
-
});
-
</script>
-
</div>
-
</form>
-
{{ else }}
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
-
<a href="/login" class="underline">login</a> to join the discussion
-
</div>
-
{{ end }}
-
{{ end }}
+42 -44
appview/pages/templates/repo/issues/issues.html
···
{{ end }}
{{ define "repoAfter" }}
-
<div class="flex flex-col gap-2 mt-2">
-
{{ range .Issues }}
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
-
<div class="pb-2">
-
<a
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
-
class="no-underline hover:underline"
-
>
-
{{ .Title | description }}
-
<span class="text-gray-500">#{{ .IssueId }}</span>
-
</a>
-
</div>
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
-
{{ $icon := "ban" }}
-
{{ $state := "closed" }}
-
{{ if .Open }}
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
-
{{ $icon = "circle-dot" }}
-
{{ $state = "open" }}
-
{{ end }}
+
<div class="flex flex-col gap-2 mt-2">
+
{{ range .Issues }}
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
+
<div class="pb-2">
+
<a
+
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
+
class="no-underline hover:underline"
+
>
+
{{ .Title | description }}
+
<span class="text-gray-500">#{{ .IssueId }}</span>
+
</a>
+
</div>
+
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
{{ $state := "closed" }}
+
{{ if .Open }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "circle-dot" }}
+
{{ $state = "open" }}
+
{{ end }}
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
-
<span class="text-white dark:text-white">{{ $state }}</span>
-
</span>
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
+
<span class="text-white dark:text-white">{{ $state }}</span>
+
</span>
-
<span class="ml-1">
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
-
</span>
+
<span class="ml-1">
+
{{ template "user/fragments/picHandleLink" .Did }}
+
</span>
-
<span class="before:content-['ยท']">
-
{{ template "repo/fragments/time" .Created }}
-
</span>
+
<span class="before:content-['ยท']">
+
{{ template "repo/fragments/time" .Created }}
+
</span>
-
<span class="before:content-['ยท']">
-
{{ $s := "s" }}
-
{{ if eq .Metadata.CommentCount 1 }}
-
{{ $s = "" }}
-
{{ end }}
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
-
</span>
-
</p>
+
<span class="before:content-['ยท']">
+
{{ $s := "s" }}
+
{{ if eq (len .Comments) 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
+
</span>
+
</p>
+
</div>
+
{{ end }}
</div>
-
{{ end }}
-
</div>
-
-
{{ block "pagination" . }} {{ end }}
-
+
{{ block "pagination" . }} {{ end }}
{{ end }}
{{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
···
{{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
-
class="mt-6 space-y-6"
-
hx-swap="none"
-
hx-indicator="#spinner"
-
>
-
<div class="flex flex-col gap-4">
-
<div>
-
<label for="title">title</label>
-
<input type="text" name="title" id="title" class="w-full" />
-
</div>
-
<div>
-
<label for="body">body</label>
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y"
-
placeholder="Describe your issue. Markdown is supported."
-
></textarea>
-
</div>
-
<div>
-
<button type="submit" class="btn-create flex items-center gap-2">
-
{{ i "circle-plus" "w-4 h-4" }}
-
create issue
-
<span id="create-pull-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</div>
-
</div>
-
<div id="issues" class="error"></div>
-
</form>
+
{{ template "repo/issues/fragments/putIssue" . }}
{{ end }}
+60
appview/pages/templates/repo/needsUpgrade.html
···
+
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
+
{{ define "extrameta" }}
+
{{ template "repo/fragments/meta" . }}
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
+
{{ end }}
+
{{ define "repoContent" }}
+
<main>
+
<div class="relative w-full h-96 flex items-center justify-center">
+
<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">
+
<!-- mimic the repo view here, placeholders are LLM generated -->
+
<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">
+
{{ $files :=
+
(list
+
"src"
+
"docs"
+
"config"
+
"lib"
+
"index.html"
+
"log.html"
+
"needsUpgrade.html"
+
"new.html"
+
"tags.html"
+
"tree.html")
+
}}
+
{{ range $files }}
+
<span>
+
{{ if (contains . ".") }}
+
{{ i "file" "size-4 inline-flex" }}
+
{{ else }}
+
{{ i "folder" "size-4 inline-flex fill-current" }}
+
{{ end }}
+
+
{{ . }}
+
</span>
+
{{ end }}
+
</div>
+
<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">
+
{{ $commits :=
+
(list
+
"Fix authentication bug in login flow"
+
"Add new dashboard widgets for metrics"
+
"Implement real-time notifications system")
+
}}
+
{{ range $commits }}
+
<div class="flex flex-col">
+
<span>{{ . }}</span>
+
<span class="text-xs">{{ . }}</span>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
<div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur">
+
<div class="text-center">
+
{{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }}
+
The knot hosting this repository needs an upgrade. This repository is currently unavailable.
+
</div>
+
</div>
+
</div>
+
</main>
+
{{ end }}
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
···
{{ define "spindleRightSide" }}
<div id="right-side" class="flex gap-2">
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
-
{{ if .Verified }}
+
+
{{ if .NeedsUpgrade }}
+
<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>
+
{{ block "spindleRetryButton" . }} {{ end }}
+
{{ else if .Verified }}
<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>
{{ template "spindles/fragments/addMemberModal" . }}
{{ else }}
<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>
{{ block "spindleRetryButton" . }} {{ end }}
{{ end }}
+
{{ block "spindleDeleteButton" . }} {{ end }}
</div>
{{ end }}
+3 -7
appview/pages/templates/spindles/index.html
···
{{ define "title" }}spindles{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom">
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
-
-
-
<span class="flex items-center gap-1 text-sm">
+
<span class="flex items-center gap-1">
{{ i "book" "w-3 h-3" }}
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
-
docs
-
</a>
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
</span>
</div>
+1 -1
appview/pages/templates/timeline/fragments/hero.html
···
<figure class="w-full hidden md:block md:w-auto">
<a href="https://tangled.sh/@tangled.sh/core" class="block">
-
<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 hover:shadow-md transition-shadow" />
+
<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" />
</a>
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
Monorepo for Tangled, built in the open with the community.
+3 -3
appview/pages/templates/timeline/home.html
···
{{ define "feature" }}
{{ $info := index . 0 }}
{{ $bullets := index . 1 }}
-
<div class="flex flex-col items-top gap-6 md:flex-row md:gap-12">
+
<div class="flex flex-col items-center gap-6 md:flex-row md:items-top">
<div class="flex-1">
<h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2>
<ul class="leading-normal">
···
</div>
<div class="flex-shrink-0 w-96 md:w-1/3">
<a href="{{ $info.image }}">
-
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded" />
+
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" />
</a>
</div>
</div>
{{ end }}
{{ define "features" }}
-
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4">
+
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm">
{{ template "feature" (list
(dict
"title" "lightweight git repo hosting"
+2 -4
appview/pages/templates/user/completeSignup.html
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
-
<h1
-
class="text-center text-2xl font-semibold italic dark:text-white"
-
>
-
tangled
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
+
{{ template "fragments/logotype" }}
</h1>
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
+1 -2
appview/pages/templates/user/fragments/repoCard.html
···
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
{{ with .Language }}
<div class="flex gap-2 items-center text-sm">
-
<div class="size-2 rounded-full"
-
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>
+
{{ template "repo/fragments/languageBall" . }}
<span>{{ . }}</span>
</div>
{{ end }}
+2 -2
appview/pages/templates/user/login.html
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
-
tangled
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
+
{{ template "fragments/logotype" }}
</h1>
<h2 class="text-center text-xl italic dark:text-white">
tightly-knit social coding.
+21 -10
appview/pages/templates/user/overview.html
···
</summary>
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
{{ range . }}
-
<div class="flex flex-wrap items-center gap-2">
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ if .Source }}
+
<div class="flex flex-wrap items-center justify-between gap-2">
+
<span class="flex items-center gap-2">
+
<span class="text-gray-500 dark:text-gray-400">
+
{{ if .Source }}
{{ i "git-fork" "w-4 h-4" }}
-
{{ else }}
+
{{ else }}
{{ i "book-plus" "w-4 h-4" }}
-
{{ end }}
+
{{ end }}
+
</span>
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
+
{{- .Repo.Name -}}
+
</a>
</span>
-
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
-
{{- .Repo.Name -}}
-
</a>
+
+
{{ with .Repo.RepoStats }}
+
{{ with .Language }}
+
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
+
{{ template "repo/fragments/languageBall" . }}
+
<span>{{ . }}</span>
+
</div>
+
{{end }}
+
{{end }}
</div>
{{ end }}
</div>
···
</summary>
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
{{ range $items }}
-
{{ $repoOwner := resolve .Metadata.Repo.Did }}
-
{{ $repoName := .Metadata.Repo.Name }}
+
{{ $repoOwner := resolve .Repo.Did }}
+
{{ $repoName := .Repo.Name }}
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
+2 -2
appview/pages/templates/user/settings/emails.html
···
<div class="p-6">
<p class="text-xl font-bold dark:text-white">Settings</p>
</div>
-
<div class="bg-white dark:bg-gray-800">
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="col-span-1">
{{ template "user/settings/fragments/sidebar" . }}
</div>
+2 -2
appview/pages/templates/user/settings/keys.html
···
<div class="p-6">
<p class="text-xl font-bold dark:text-white">Settings</p>
</div>
-
<div class="bg-white dark:bg-gray-800">
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="col-span-1">
{{ template "user/settings/fragments/sidebar" . }}
</div>
+2 -2
appview/pages/templates/user/settings/profile.html
···
<div class="p-6">
<p class="text-xl font-bold dark:text-white">Settings</p>
</div>
-
<div class="bg-white dark:bg-gray-800">
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="col-span-1">
{{ template "user/settings/fragments/sidebar" . }}
</div>
+3 -1
appview/pages/templates/user/signup.html
···
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
+
{{ template "fragments/logotype" }}
+
</h1>
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
<form
class="mt-4 max-w-sm mx-auto"
+1 -1
appview/posthog/notifier.go
···
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
err := n.client.Enqueue(posthog.Capture{
-
DistinctId: issue.OwnerDid,
+
DistinctId: issue.Did,
Event: "new_issue",
Properties: posthog.Properties{
"repo_at": issue.RepoAt.String(),
+230 -85
appview/pulls/pulls.go
···
import (
"database/sql"
+
"encoding/json"
"errors"
"fmt"
"log"
···
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/tid"
"tangled.sh/tangled.sh/core/types"
···
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
resubmitResult := pages.Unknown
if user.Did == pull.OwnerDid {
-
resubmitResult = s.resubmitCheck(f, pull, stack)
+
resubmitResult = s.resubmitCheck(r, f, pull, stack)
}
s.pages.PullActionsFragment(w, pages.PullActionsParams{
···
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
resubmitResult := pages.Unknown
if user != nil && user.Did == pull.OwnerDid {
-
resubmitResult = s.resubmitCheck(f, pull, stack)
+
resubmitResult = s.resubmitCheck(r, f, pull, stack)
}
repoInfo := f.RepoInfo(user)
···
return result
}
-
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
return pages.Unknown
}
···
repoName = f.Name
}
-
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
-
return pages.Unknown
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
+
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
return pages.Unknown
+
}
log.Println("failed to reach knotserver", err)
return pages.Unknown
}
+
targetBranch := branchResp
+
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
if pull.IsStacked() && stack != nil {
···
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
}
-
if latestSourceRev != result.Branch.Hash {
+
if latestSourceRev != targetBranch.Hash {
return pages.ShouldResubmit
}
···
switch r.Method {
case http.MethodGet:
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
s.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
s.pages.Error503(w)
+
return
+
}
+
log.Println("failed to fetch branches", err)
return
}
-
result, err := us.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
-
log.Println("failed to fetch branches", err)
+
var result types.RepoBranchesResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
+
s.pages.Error503(w)
return
}
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
-
return
-
}
+
// us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
// if err != nil {
+
// log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
// return
+
// }
-
caps, err := us.Capabilities()
-
if err != nil {
-
log.Println("error fetching knot caps", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
-
return
+
// TODO: make capabilities an xrpc call
+
caps := struct {
+
PullRequests struct {
+
FormatPatch bool
+
BranchSubmissions bool
+
ForkSubmissions bool
+
PatchSubmissions bool
+
}
+
}{
+
PullRequests: struct {
+
FormatPatch bool
+
BranchSubmissions bool
+
ForkSubmissions bool
+
PatchSubmissions bool
+
}{
+
FormatPatch: true,
+
BranchSubmissions: true,
+
ForkSubmissions: true,
+
PatchSubmissions: true,
+
},
}
+
+
// caps, err := us.Capabilities()
+
// if err != nil {
+
// log.Println("error fetching knot caps", f.Knot, err)
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
// return
+
// }
if !caps.PullRequests.FormatPatch {
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
···
sourceBranch string,
isStacked bool,
) {
-
// Generate a patch using /compare
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
log.Println("failed to compare", err)
s.pages.Notice(w, "pull", err.Error())
+
return
+
}
+
+
var comparison types.RepoFormatPatchResponse
+
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
+
log.Println("failed to decode XRPC compare response", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
return
}
···
oauth.WithLxm(tangled.RepoHiddenRefNSID),
oauth.WithDev(s.config.Core.Dev),
)
-
if err != nil {
-
log.Printf("failed to connect to knot server: %v", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
resp, err := tangled.RepoHiddenRef(
r.Context(),
···
// hiddenRef: hidden/feature-1/main (on repo-fork)
// targetBranch: main (on repo-1)
// sourceBranch: feature-1 (on repo-fork)
-
comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch)
+
forkScheme := "http"
+
if !s.config.Core.Dev {
+
forkScheme = "https"
+
}
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot)
+
forkXrpcc := &indigoxrpc.Client{
+
Host: forkHost,
+
}
+
+
forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name)
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
log.Println("failed to compare across branches", err)
s.pages.Notice(w, "pull", err.Error())
return
}
+
var comparison types.RepoFormatPatchResponse
+
if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
+
log.Println("failed to decode XRPC compare response for fork", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
sourceRev := comparison.Rev2
patch := comparison.Patch
···
return
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
s.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
s.pages.Error503(w)
+
return
+
}
+
log.Println("failed to fetch branches", err)
return
-
result, err := us.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
var result types.RepoBranchesResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
+
s.pages.Error503(w)
return
···
return
-
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
+
sourceScheme := "http"
+
if !s.config.Core.Dev {
+
sourceScheme = "https"
+
}
+
sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot)
+
sourceXrpcc := &indigoxrpc.Client{
+
Host: sourceHost,
+
}
+
+
sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name)
+
sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", repo.Knot)
-
s.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches for source", xrpcerr)
+
s.pages.Error503(w)
+
return
+
}
+
log.Println("failed to fetch source branches", err)
return
-
sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver for source branches", err)
+
// Decode source branches
+
var sourceBranches types.RepoBranchesResponse
+
if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil {
+
log.Println("failed to decode source branches XRPC response", err)
+
s.pages.Error503(w)
return
-
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
targetScheme := "http"
+
if !s.config.Core.Dev {
+
targetScheme = "https"
+
}
+
targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
+
targetXrpcc := &indigoxrpc.Client{
+
Host: targetHost,
+
}
+
+
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
if err != nil {
-
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
-
s.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches for target", xrpcerr)
+
s.pages.Error503(w)
+
return
+
}
+
log.Println("failed to fetch target branches", err)
return
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver for target branches", err)
+
// Decode target branches
+
var targetBranches types.RepoBranchesResponse
+
if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil {
+
log.Println("failed to decode target branches XRPC response", err)
+
s.pages.Error503(w)
return
-
sourceBranches := sourceResult.Branches
-
sort.Slice(sourceBranches, func(i int, j int) bool {
-
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
+
sort.Slice(sourceBranches.Branches, func(i int, j int) bool {
+
return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When)
})
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
RepoInfo: f.RepoInfo(user),
-
SourceBranches: sourceBranches,
-
TargetBranches: targetResult.Branches,
+
SourceBranches: sourceBranches.Branches,
+
TargetBranches: targetBranches.Branches,
})
···
return
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
log.Printf("compare request failed: %s", err)
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
+
}
+
+
var comparison types.RepoFormatPatchResponse
+
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
+
log.Println("failed to decode XRPC compare response", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
return
···
// extract patch by performing compare
-
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
+
forkScheme := "http"
+
if !s.config.Core.Dev {
+
forkScheme = "https"
+
}
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
if err != nil {
-
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
log.Printf("failed to compare branches: %s", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
+
var forkComparison types.RepoFormatPatchResponse
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
+
log.Println("failed to decode XRPC compare response for fork", err)
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
return
···
return
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
-
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
-
if err != nil {
-
log.Printf("failed to compare branches: %s", err)
-
s.pages.Notice(w, "resubmit-error", err.Error())
-
return
-
}
+
// Use the fork comparison we already made
+
comparison := forkComparison
sourceRev := comparison.Rev2
patch := comparison.Patch
+26 -8
appview/repo/artifact.go
···
package repo
import (
+
"context"
+
"encoding/json"
"fmt"
"log"
"net/http"
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
"github.com/go-git/go-git/v5/plumbing"
···
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/reporesolver"
-
"tangled.sh/tangled.sh/core/knotclient"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/tid"
"tangled.sh/tangled.sh/core/types"
)
···
return
}
-
tag, err := rp.resolveTag(f, tagParam)
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
log.Println("failed to resolve tag", err)
rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
···
return
}
-
tag, err := rp.resolveTag(f, tagParam)
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
if err != nil {
log.Println("failed to resolve tag", err)
rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
···
w.Write([]byte{})
}
-
func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
+
func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
tagParam, err := url.QueryUnescape(tagParam)
if err != nil {
return nil, err
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
-
if err != nil {
-
return nil, err
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
result, err := us.Tags(f.OwnerDid(), f.Name)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
return nil, xrpcerr
+
}
log.Println("failed to reach knotserver", err)
+
return nil, err
+
}
+
+
var result types.RepoTagsResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC tags response", err)
return nil, err
}
+7 -2
appview/repo/feed.go
···
"time"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"github.com/bluesky-social/indigo/atproto/syntax"
···
return nil, err
}
-
issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
+
issues, err := db.GetIssuesPaginated(
+
rp.db,
+
pagination.Page{Limit: feedLimitPerType},
+
db.FilterEq("repo_at", f.RepoAt()),
+
)
if err != nil {
return nil, err
}
···
}
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
-
owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
if err != nil {
return nil, err
}
+207 -22
appview/repo/index.go
···
package repo
import (
+
"errors"
+
"fmt"
"log"
"net/http"
"slices"
"sort"
"strings"
+
"sync"
+
"time"
+
"context"
+
"encoding/json"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-git/go-git/v5/plumbing"
+
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/commitverify"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/reporesolver"
-
"tangled.sh/tangled.sh/core/knotclient"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/types"
"github.com/go-chi/chi/v5"
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
rp.pages.Error503(w)
-
return
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
result, err := us.Index(f.OwnerDid(), f.Name, ref)
-
if err != nil {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
-
return
+
user := rp.oauth.GetUser(r)
+
repoInfo := f.RepoInfo(user)
+
+
// Build index response from multiple XRPC calls
+
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
+
log.Println("failed to call XRPC repo.index", err)
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
+
LoggedInUser: user,
+
NeedsKnotUpgrade: true,
+
RepoInfo: repoInfo,
+
})
+
return
+
} else {
+
rp.pages.Error503(w)
+
log.Println("failed to build index response", err)
+
return
+
}
}
tagMap := make(map[string][]string)
···
log.Println(err)
}
-
user := rp.oauth.GetUser(r)
-
repoInfo := f.RepoInfo(user)
-
// TODO: a bit dirty
-
languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "")
+
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
if err != nil {
log.Printf("failed to compute language percentages: %s", err)
// non-fatal
···
}
func (rp *Repo) getLanguageInfo(
+
ctx context.Context,
f *reporesolver.ResolvedRepo,
-
us *knotclient.UnsignedClient,
+
xrpcc *indigoxrpc.Client,
currentRef string,
isDefaultRef bool,
) ([]types.RepoLanguageDetails, error) {
···
)
if err != nil || langs == nil {
-
// non-fatal, fetch langs from ks
-
ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
+
// non-fatal, fetch langs from ks via XRPC
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.languages", xrpcerr)
+
return nil, xrpcerr
+
}
return nil, err
}
-
if ls == nil {
+
+
if ls == nil || ls.Languages == nil {
return nil, nil
}
-
for l, s := range ls.Languages {
+
for _, lang := range ls.Languages {
langs = append(langs, db.RepoLanguage{
RepoAt: f.RepoAt(),
Ref: currentRef,
IsDefaultRef: isDefaultRef,
-
Language: l,
-
Bytes: s,
+
Language: lang.Name,
+
Bytes: lang.Size,
})
}
···
return languageStats, nil
}
+
+
// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
+
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) {
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
+
// first get branches to determine the ref if not specified
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
+
if err != nil {
+
return nil, err
+
}
+
+
var branchesResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
+
return nil, err
+
}
+
+
// if no ref specified, use default branch or first available
+
if ref == "" && len(branchesResp.Branches) > 0 {
+
for _, branch := range branchesResp.Branches {
+
if branch.IsDefault {
+
ref = branch.Name
+
break
+
}
+
}
+
if ref == "" {
+
ref = branchesResp.Branches[0].Name
+
}
+
}
+
+
// check if repo is empty
+
if len(branchesResp.Branches) == 0 {
+
return &types.RepoIndexResponse{
+
IsEmpty: true,
+
Branches: branchesResp.Branches,
+
}, nil
+
}
+
+
// now run the remaining queries in parallel
+
var wg sync.WaitGroup
+
var errs error
+
+
var (
+
tagsResp types.RepoTagsResponse
+
treeResp *tangled.RepoTree_Output
+
logResp types.RepoLogResponse
+
readmeContent string
+
readmeFileName string
+
)
+
+
// tags
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
+
if err != nil {
+
errs = errors.Join(errs, err)
+
return
+
}
+
+
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
+
errs = errors.Join(errs, err)
+
}
+
}()
+
+
// tree/files
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
+
if err != nil {
+
errs = errors.Join(errs, err)
+
return
+
}
+
treeResp = resp
+
}()
+
+
// commits
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
+
if err != nil {
+
errs = errors.Join(errs, err)
+
return
+
}
+
+
if err := json.Unmarshal(logBytes, &logResp); err != nil {
+
errs = errors.Join(errs, err)
+
}
+
}()
+
+
// readme content
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
for _, filename := range markup.ReadmeFilenames {
+
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
+
if err != nil {
+
continue
+
}
+
+
if blobResp == nil {
+
continue
+
}
+
+
readmeContent = blobResp.Content
+
readmeFileName = filename
+
break
+
}
+
}()
+
+
wg.Wait()
+
+
if errs != nil {
+
return nil, errs
+
}
+
+
var files []types.NiceTree
+
if treeResp != nil && treeResp.Files != nil {
+
for _, file := range treeResp.Files {
+
niceFile := types.NiceTree{
+
IsFile: file.Is_file,
+
IsSubtree: file.Is_subtree,
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
+
}
+
if file.Last_commit != nil {
+
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
+
niceFile.LastCommit = &types.LastCommitInfo{
+
Hash: plumbing.NewHash(file.Last_commit.Hash),
+
Message: file.Last_commit.Message,
+
When: when,
+
}
+
}
+
files = append(files, niceFile)
+
}
+
}
+
+
result := &types.RepoIndexResponse{
+
IsEmpty: false,
+
Ref: ref,
+
Readme: readmeContent,
+
ReadmeFileName: readmeFileName,
+
Commits: logResp.Commits,
+
Description: logResp.Description,
+
Files: files,
+
Branches: branchesResp.Branches,
+
Tags: tagsResp.Tags,
+
TotalCommits: logResp.Total,
+
}
+
+
return result, nil
+
}
+374 -144
appview/repo/repo.go
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/commitverify"
"tangled.sh/tangled.sh/core/appview/config"
···
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
return
}
-
var uri string
-
if rp.config.Core.Dev {
-
uri = "http"
-
} else {
-
uri = "https"
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo)
+
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
+
return
}
-
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
+
+
// Set headers for file download
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam)
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
w.Header().Set("Content-Type", "application/gzip")
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
-
http.Redirect(w, r, url, http.StatusFound)
+
// Write the archive data directly
+
w.Write(archiveBytes)
}
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
···
ref := chi.URLParam(r, "ref")
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
limit := int64(60)
+
cursor := ""
+
if page > 1 {
+
// Convert page number to cursor (offset)
+
offset := (page - 1) * int(limit)
+
cursor = strconv.Itoa(offset)
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.log", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
-
if err != nil {
+
var xrpcResp types.RepoLogResponse
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
+
log.Println("failed to decode XRPC response", err)
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
return
}
-
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
-
return
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
}
tagMap := make(map[string][]string)
-
for _, tag := range tagResult.Tags {
-
hash := tag.Hash
-
if tag.Tag != nil {
-
hash = tag.Tag.Target.String()
+
if tagBytes != nil {
+
var tagResp types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
+
for _, tag := range tagResp.Tags {
+
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
+
}
}
-
tagMap[hash] = append(tagMap[hash], tag.Name)
}
-
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
-
return
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
}
-
for _, branch := range branchResult.Branches {
-
hash := branch.Hash
-
tagMap[hash] = append(tagMap[hash], branch.Name)
+
if branchBytes != nil {
+
var branchResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
+
for _, branch := range branchResp.Branches {
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
+
}
+
}
}
user := rp.oauth.GetUser(r)
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
if err != nil {
log.Println("failed to fetch email to did mapping", err)
}
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
if err != nil {
log.Println(err)
}
···
repoInfo := f.RepoInfo(user)
var shas []string
-
for _, c := range repolog.Commits {
+
for _, c := range xrpcResp.Commits {
shas = append(shas, c.Hash.String())
}
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
···
LoggedInUser: user,
TagMap: tagMap,
RepoInfo: repoInfo,
-
RepoLogResponse: *repolog,
+
RepoLogResponse: xrpcResp,
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
VerifiedCommits: vc,
Pipelines: pipelines,
···
return
}
ref := chi.URLParam(r, "ref")
-
protocol := "http"
-
if !rp.config.Core.Dev {
-
protocol = "https"
-
}
var diffOpts types.DiffOpts
if d := r.URL.Query().Get("diff"); d == "split" {
···
return
}
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
-
if err != nil {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
-
return
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
body, err := io.ReadAll(resp.Body)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
if err != nil {
-
log.Printf("Error reading response body: %v", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
var result types.RepoCommitResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
+
rp.pages.Error503(w)
return
}
···
ref := chi.URLParam(r, "ref")
treePath := chi.URLParam(r, "*")
-
protocol := "http"
-
if !rp.config.Core.Dev {
-
protocol = "https"
-
}
// if the tree path has a trailing slash, let's strip it
// so we don't 404
treePath = strings.TrimSuffix(treePath, "/")
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
if err != nil {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
// uhhh so knotserver returns a 500 if the entry isn't found in
-
// the requested tree path, so let's stick to not-OK here.
-
// we can fix this once we build out the xrpc apis for these operations.
-
if resp.StatusCode != http.StatusOK {
-
rp.pages.Error404(w)
-
return
+
// Convert XRPC response to internal types.RepoTreeResponse
+
files := make([]types.NiceTree, len(xrpcResp.Files))
+
for i, xrpcFile := range xrpcResp.Files {
+
file := types.NiceTree{
+
Name: xrpcFile.Name,
+
Mode: xrpcFile.Mode,
+
Size: int64(xrpcFile.Size),
+
IsFile: xrpcFile.Is_file,
+
IsSubtree: xrpcFile.Is_subtree,
+
}
+
+
// Convert last commit info if present
+
if xrpcFile.Last_commit != nil {
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
+
file.LastCommit = &types.LastCommitInfo{
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
+
Message: xrpcFile.Last_commit.Message,
+
When: commitWhen,
+
}
+
}
+
+
files[i] = file
}
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
-
return
+
result := types.RepoTreeResponse{
+
Ref: xrpcResp.Ref,
+
Files: files,
}
-
var result types.RepoTreeResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
return
+
if xrpcResp.Parent != nil {
+
result.Parent = *xrpcResp.Parent
+
}
+
if xrpcResp.Dotdot != nil {
+
result.DotDot = *xrpcResp.Dotdot
}
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
result, err := us.Tags(f.OwnerDid(), f.Name)
-
if err != nil {
+
var result types.RepoTagsResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
return
}
···
rp.pages.RepoTags(w, pages.RepoTagsParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
-
RepoTagsResponse: *result,
+
RepoTagsResponse: result,
ArtifactMap: artifactMap,
DanglingArtifacts: danglingArtifacts,
})
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
result, err := us.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
+
var result types.RepoBranchesResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
return
}
···
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
-
RepoBranchesResponse: *result,
+
RepoBranchesResponse: result,
})
}
···
ref := chi.URLParam(r, "ref")
filePath := chi.URLParam(r, "*")
-
protocol := "http"
+
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
}
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
-
if err != nil {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
-
return
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
if resp.StatusCode == http.StatusNotFound {
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
+
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Error404(w)
return
}
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
-
return
-
}
-
-
var result types.RepoBlobResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
return
-
}
+
// Use XRPC response directly instead of converting to internal types
var breadcrumbs [][]string
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
···
showRendered := false
renderToggle := false
-
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
+
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
renderToggle = true
showRendered = r.URL.Query().Get("code") != "true"
}
···
var isVideo bool
var contentSrc string
-
if result.IsBinary {
-
ext := strings.ToLower(filepath.Ext(result.Path))
+
if resp.IsBinary != nil && *resp.IsBinary {
+
ext := strings.ToLower(filepath.Ext(resp.Path))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
isImage = true
···
unsupported = true
}
-
// fetch the actual binary content like in RepoBlobRaw
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
+
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
contentSrc = blobURL
if !rp.config.Core.Dev {
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
}
}
+
lines := 0
+
if resp.IsBinary == nil || !*resp.IsBinary {
+
lines = strings.Count(resp.Content, "\n") + 1
+
}
+
+
var sizeHint uint64
+
if resp.Size != nil {
+
sizeHint = uint64(*resp.Size)
+
} else {
+
sizeHint = uint64(len(resp.Content))
+
}
+
user := rp.oauth.GetUser(r)
+
+
// Determine if content is binary (dereference pointer)
+
isBinary := false
+
if resp.IsBinary != nil {
+
isBinary = *resp.IsBinary
+
}
+
rp.pages.RepoBlob(w, pages.RepoBlobParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
RepoBlobResponse: result,
-
BreadCrumbs: breadcrumbs,
-
ShowRendered: showRendered,
-
RenderToggle: renderToggle,
-
Unsupported: unsupported,
-
IsImage: isImage,
-
IsVideo: isVideo,
-
ContentSrc: contentSrc,
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
BreadCrumbs: breadcrumbs,
+
ShowRendered: showRendered,
+
RenderToggle: renderToggle,
+
Unsupported: unsupported,
+
IsImage: isImage,
+
IsVideo: isVideo,
+
ContentSrc: contentSrc,
+
RepoBlob_Output: resp,
+
Contents: resp.Content,
+
Lines: lines,
+
SizeHint: sizeHint,
+
IsBinary: isBinary,
})
}
···
ref := chi.URLParam(r, "ref")
filePath := chi.URLParam(r, "*")
-
protocol := "http"
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
}
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
+
scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath))
req, err := http.NewRequest("GET", blobURL, nil)
if err != nil {
···
return
}
-
if strings.Contains(contentType, "text/plain") {
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
+
// serve all textual content as text/plain
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(body)
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
+
// serve images and videos with their original content type
w.Header().Set("Content-Type", contentType)
w.Write(body)
} else {
···
}
}
+
// isTextualMimeType returns true if the MIME type represents textual content
+
// that should be served as text/plain
+
func isTextualMimeType(mimeType string) bool {
+
textualTypes := []string{
+
"application/json",
+
"application/xml",
+
"application/yaml",
+
"application/x-yaml",
+
"application/toml",
+
"application/javascript",
+
"application/ecmascript",
+
"message/",
+
}
+
+
return slices.Contains(textualTypes, mimeType)
+
}
+
// modify the spindle configured for this repo
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
···
f, err := rp.repoResolver.Resolve(r)
user := rp.oauth.GetUser(r)
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error503(w)
return
-
result, err := us.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
+
var result types.RepoBranchesResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
return
···
return
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
rp.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
-
result, err := us.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
+
var branchResult types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
+
log.Println("failed to decode XRPC branches response", err)
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to reach knotserver", err)
return
-
branches := result.Branches
+
branches := branchResult.Branches
sortBranches(branches)
···
head = queryHead
-
tags, err := us.Tags(f.OwnerDid(), f.Name)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to reach knotserver", err)
+
return
+
}
+
+
var tags types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
+
log.Println("failed to decode XRPC tags response", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
···
return
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
rp.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
-
branches, err := us.Branches(f.OwnerDid(), f.Name)
-
if err != nil {
+
var branches types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
+
log.Println("failed to decode XRPC branches response", err)
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to reach knotserver", err)
return
-
tags, err := us.Tags(f.OwnerDid(), f.Name)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to reach knotserver", err)
return
-
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
+
var tags types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
+
log.Println("failed to decode XRPC tags response", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to compare", err)
return
+
+
var formatPatch types.RepoFormatPatchResponse
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
+
log.Println("failed to decode XRPC compare response", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
repoinfo := f.RepoInfo(user)
+11 -27
appview/serververify/verify.go
···
"context"
"errors"
"fmt"
-
"io"
-
"net/http"
-
"strings"
-
"time"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/rbac"
)
···
scheme = "http"
}
-
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
-
req, err := http.NewRequest("GET", url, nil)
-
if err != nil {
-
return "", err
-
}
-
-
client := &http.Client{
-
Timeout: 1 * time.Second,
-
}
-
-
resp, err := client.Do(req.WithContext(ctx))
-
if err != nil || resp.StatusCode != 200 {
-
return "", fmt.Errorf("failed to fetch /owner")
+
host := fmt.Sprintf("%s://%s", scheme, domain)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
-
if err != nil {
-
return "", fmt.Errorf("failed to read /owner response: %w", err)
+
res, err := tangled.Owner(ctx, xrpcc)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
return "", xrpcerr
}
-
did := strings.TrimSpace(string(body))
-
if did == "" {
-
return "", fmt.Errorf("empty DID in /owner response")
-
}
-
-
return did, nil
+
return res.Owner, nil
}
type OwnerMismatch struct {
···
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
observedOwner, err := fetchOwner(ctx, domain, dev)
if err != nil {
-
return fmt.Errorf("%w: %w", FetchError, err)
+
return err
}
if observedOwner != expectedOwner {
+4 -3
appview/spindles/spindles.go
···
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/serververify"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
if err != nil {
l.Error("verification failed", "err", err)
-
if errors.Is(err, serververify.FetchError) {
-
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
+
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
+
s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!")
return
}
···
}
w.Header().Set("HX-Reswap", "outerHTML")
-
s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]})
+
s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]})
}
func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
+14 -13
appview/state/profile.go
···
"github.com/gorilla/feeds"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
-
// "tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
loggedInUser := s.oauth.GetUser(r)
+
params := FollowsPageParams{
+
Card: profile,
+
}
follows, err := fetchFollows(s.db, profile.UserDid)
if err != nil {
l.Error("failed to fetch follows", "err", err)
-
return nil, err
+
return &params, err
}
if len(follows) == 0 {
-
return nil, nil
+
return &params, nil
}
followDids := make([]string, 0, len(follows))
···
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
if err != nil {
l.Error("failed to get profiles", "followDids", followDids, "err", err)
-
return nil, err
+
return &params, err
}
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
following, err := db.GetFollowing(s.db, loggedInUser.Did)
if err != nil {
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
-
return nil, err
+
return &params, err
}
loggedInUserFollowing = make(map[string]struct{}, len(following))
for _, follow := range following {
···
}
}
-
return &FollowsPageParams{
-
Follows: followCards,
-
Card: profile,
-
}, nil
+
params.Follows = followCards
+
+
return &params, nil
}
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
···
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
for _, issue := range issues {
-
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
if err != nil {
return err
}
···
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
return &feeds.Item{
-
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
-
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"},
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
+
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"},
Created: issue.Created,
Author: author,
}
···
log.Printf("getting profile data for %s: %s", user.Did, err)
}
-
repos, err := db.GetAllReposByDid(s.db, user.Did)
+
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
if err != nil {
log.Printf("getting repos for %s: %s", user.Did, err)
}
+2 -1
appview/state/router.go
···
r.Get("/", s.HomeOrTimeline)
r.Get("/timeline", s.Timeline)
+
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
···
}
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
return issues.Router(mw)
}
+46 -6
appview/state/state.go
···
"tangled.sh/tangled.sh/core/appview/pages"
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
"tangled.sh/tangled.sh/core/appview/validator"
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
···
knotstream *eventconsumer.Consumer
spindlestream *eventconsumer.Consumer
logger *slog.Logger
+
validator *validator.Validator
}
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
}
pgs := pages.NewPages(config, res)
-
cache := cache.New(config.Redis.Addr)
sess := session.New(cache)
-
oauth := oauth.NewOAuth(config, sess)
+
validator := validator.New(d)
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
if err != nil {
···
IdResolver: res,
Config: config,
Logger: tlog.New("ingester"),
+
Validator: validator,
}
err = jc.StartJetstream(ctx, ingester.Ingest())
if err != nil {
···
knotstream,
spindlestream,
slog.Default(),
+
validator,
}
return state, nil
}
+
func (s *State) Close() error {
+
// other close up logic goes here
+
return s.db.Close()
+
}
+
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
···
})
}
+
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
l := s.logger.With("handler", "UpgradeBanner")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
+
regs, err := db.GetRegistrations(
+
s.db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("needs_upgrade", 1),
+
)
+
if err != nil {
+
l.Error("non-fatal: failed to get registrations", "err", err)
+
}
+
+
spindles, err := db.GetSpindles(
+
s.db,
+
db.FilterEq("owner", user.Did),
+
db.FilterEq("needs_upgrade", 1),
+
)
+
if err != nil {
+
l.Error("non-fatal: failed to get spindles", "err", err)
+
}
+
+
if regs == nil && spindles == nil {
+
return
+
}
+
+
s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{
+
Registrations: regs,
+
Spindles: spindles,
+
})
+
}
+
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
-
timeline, err := db.MakeTimeline(s.db, 15)
+
timeline, err := db.MakeTimeline(s.db, 5)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
return
}
-
-
timeline = timeline[:5]
s.pages.Home(w, pages.TimelineParams{
LoggedInUser: nil,
···
for _, k := range pubKeys {
key := strings.TrimRight(k.Key, "\n")
-
w.Write([]byte(fmt.Sprintln(key)))
+
fmt.Fprintln(w, key)
}
}
+53
appview/validator/issue.go
···
+
package validator
+
+
import (
+
"fmt"
+
"strings"
+
+
"tangled.sh/tangled.sh/core/appview/db"
+
)
+
+
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
+
// if comments have parents, only ingest ones that are 1 level deep
+
if comment.ReplyTo != nil {
+
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
+
if err != nil {
+
return fmt.Errorf("failed to fetch parent comment: %w", err)
+
}
+
if len(parents) != 1 {
+
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
+
}
+
+
// depth check
+
parent := parents[0]
+
if parent.ReplyTo != nil {
+
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
+
}
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
+
if issue.Title == "" {
+
return fmt.Errorf("issue title is empty")
+
}
+
+
if issue.Body == "" {
+
return fmt.Errorf("issue body is empty")
+
}
+
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
+
return fmt.Errorf("title is empty after HTML sanitization")
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+18
appview/validator/validator.go
···
+
package validator
+
+
import (
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
)
+
+
type Validator struct {
+
db *db.DB
+
sanitizer markup.Sanitizer
+
}
+
+
func New(db *db.DB) *Validator {
+
return &Validator{
+
db: db,
+
sanitizer: markup.NewSanitizer(),
+
}
+
}
+11 -5
appview/xrpcclient/xrpc.go
···
"bytes"
"context"
"errors"
-
"fmt"
"io"
"net/http"
···
"github.com/bluesky-social/indigo/xrpc"
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
oauth "tangled.sh/icyphox.sh/atproto-oauth"
+
)
+
+
var (
+
ErrXrpcUnsupported = errors.New("xrpc not supported on this knot")
+
ErrXrpcUnauthorized = errors.New("unauthorized xrpc request")
+
ErrXrpcFailed = errors.New("xrpc request failed")
+
ErrXrpcInvalid = errors.New("invalid xrpc request")
)
type Client struct {
···
var xrpcerr *indigoxrpc.Error
if ok := errors.As(err, &xrpcerr); !ok {
-
return fmt.Errorf("Recieved invalid XRPC error response.")
+
return ErrXrpcInvalid
}
switch xrpcerr.StatusCode {
case http.StatusNotFound:
-
return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.")
+
return ErrXrpcUnsupported
case http.StatusUnauthorized:
-
return fmt.Errorf("Unauthorized XRPC request.")
+
return ErrXrpcUnauthorized
default:
-
return fmt.Errorf("Failed to perform operation. Try again later.")
+
return ErrXrpcFailed
}
}
+3
cmd/appview/main.go
···
}
state, err := state.Make(ctx, c)
+
defer func() {
+
log.Println(state.Close())
+
}()
if err != nil {
log.Fatal(err)
-35
docs/migrations/knot-1.7.0.md
···
-
# Upgrading from v1.7.0
-
-
After v1.7.0, knot secrets have been deprecated. You no
-
longer need a secret from the appview to run a knot. All
-
authorized commands to knots are managed via [Inter-Service
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
-
Knots will be read-only until upgraded.
-
-
Upgrading is quite easy, in essence:
-
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
-
environment variable entirely
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
-
your DID. You can find your DID in the
-
[settings](https://tangled.sh/settings) page.
-
- Restart your knot once you have replaced the environment
-
variable
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
-
hit the "retry" button to verify your knot. This simply
-
writes a `sh.tangled.knot` record to your PDS.
-
-
## Nix
-
-
If you use the nix module, simply bump the flake to the
-
latest revision, and change your config block like so:
-
-
```diff
-
services.tangled-knot = {
-
enable = true;
-
server = {
-
- secretFile = /path/to/secret;
-
+ owner = "did:plc:foo";
-
};
-
};
-
```
+60
docs/migrations.md
···
+
# Migrations
+
+
This document is laid out in reverse-chronological order.
+
Newer migration guides are listed first, and older guides
+
are further down the page.
+
+
## Upgrading from v1.8.x
+
+
After v1.8.2, the HTTP API for knot and spindles have been
+
deprecated and replaced with XRPC. Repositories on outdated
+
knots will not be viewable from the appview. Upgrading is
+
straightforward however.
+
+
For knots:
+
+
- Upgrade to latest tag (v1.9.0 or above)
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
+
hit the "retry" button to verify your knot
+
+
For spindles:
+
+
- Upgrade to latest tag (v1.9.0 or above)
+
- Head to the [spindle
+
dashboard](https://tangled.sh/spindles) and hit the
+
"retry" button to verify your spindle
+
+
## Upgrading from v1.7.x
+
+
After v1.7.0, knot secrets have been deprecated. You no
+
longer need a secret from the appview to run a knot. All
+
authorized commands to knots are managed via [Inter-Service
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
+
Knots will be read-only until upgraded.
+
+
Upgrading is quite easy, in essence:
+
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
+
environment variable entirely
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
+
your DID. You can find your DID in the
+
[settings](https://tangled.sh/settings) page.
+
- Restart your knot once you have replaced the environment
+
variable
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
+
hit the "retry" button to verify your knot. This simply
+
writes a `sh.tangled.knot` record to your PDS.
+
+
If you use the nix module, simply bump the flake to the
+
latest revision, and change your config block like so:
+
+
```diff
+
services.tangled-knot = {
+
enable = true;
+
server = {
+
- secretFile = /path/to/secret;
+
+ owner = "did:plc:foo";
+
};
+
};
+
```
+
+1 -1
input.css
···
}
label {
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
+
@apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
}
input {
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
-285
knotclient/unsigned.go
···
-
package knotclient
-
-
import (
-
"bytes"
-
"encoding/json"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
"net/url"
-
"strconv"
-
"time"
-
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
type UnsignedClient struct {
-
Url *url.URL
-
client *http.Client
-
}
-
-
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
-
client := &http.Client{
-
Timeout: 5 * time.Second,
-
}
-
-
scheme := "https"
-
if dev {
-
scheme = "http"
-
}
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
-
if err != nil {
-
return nil, err
-
}
-
-
unsignedClient := &UnsignedClient{
-
client: client,
-
Url: url,
-
}
-
-
return unsignedClient, nil
-
}
-
-
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
-
reqUrl := us.Url.JoinPath(endpoint)
-
-
// add query parameters
-
if query != nil {
-
reqUrl.RawQuery = query.Encode()
-
}
-
-
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
-
}
-
-
func do[T any](us *UnsignedClient, req *http.Request) (*T, error) {
-
resp, err := us.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
defer resp.Body.Close()
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
-
return nil, err
-
}
-
-
var result T
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Printf("Error unmarshalling response body: %v", err)
-
return nil, err
-
}
-
-
return &result, nil
-
}
-
-
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
-
if ref == "" {
-
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
-
}
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoIndexResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
-
-
query := url.Values{}
-
query.Add("page", strconv.Itoa(page))
-
query.Add("per_page", strconv.Itoa(60))
-
-
req, err := us.newRequest(Method, endpoint, query, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoLogResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoBranchesResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoTagsResponse](us, req)
-
}
-
-
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return do[types.RepoBranchResponse](us, req)
-
}
-
-
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
resp, err := us.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
defer resp.Body.Close()
-
-
var defaultBranch types.RepoDefaultBranchResponse
-
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
-
return nil, err
-
}
-
-
return &defaultBranch, nil
-
}
-
-
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
-
const (
-
Method = "GET"
-
Endpoint = "/capabilities"
-
)
-
-
req, err := us.newRequest(Method, Endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
resp, err := us.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
defer resp.Body.Close()
-
-
var capabilities types.Capabilities
-
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
-
return nil, err
-
}
-
-
return &capabilities, nil
-
}
-
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
-
const (
-
Method = "GET"
-
)
-
-
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
-
-
req, err := us.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, fmt.Errorf("Failed to create request.")
-
}
-
-
compareResp, err := us.client.Do(req)
-
if err != nil {
-
return nil, fmt.Errorf("Failed to create request.")
-
}
-
defer compareResp.Body.Close()
-
-
switch compareResp.StatusCode {
-
case 404:
-
case 400:
-
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
-
}
-
-
respBody, err := io.ReadAll(compareResp.Body)
-
if err != nil {
-
log.Println("failed to compare across branches")
-
return nil, fmt.Errorf("Failed to compare branches.")
-
}
-
defer compareResp.Body.Close()
-
-
var formatPatchResponse types.RepoFormatPatchResponse
-
err = json.Unmarshal(respBody, &formatPatchResponse)
-
if err != nil {
-
log.Println("failed to unmarshal format-patch response", err)
-
return nil, fmt.Errorf("failed to compare branches.")
-
}
-
-
return &formatPatchResponse, nil
-
}
-
-
func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
-
const (
-
Method = "GET"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
-
-
req, err := s.newRequest(Method, endpoint, nil, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
resp, err := s.client.Do(req)
-
if err != nil {
-
return nil, err
-
}
-
-
var result types.RepoLanguageResponse
-
if resp.StatusCode != http.StatusOK {
-
log.Println("failed to calculate languages", resp.Status)
-
return &types.RepoLanguageResponse{}, nil
-
}
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
return nil, err
-
}
-
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
return nil, err
-
}
-
-
return &result, nil
-
}
+40
knotserver/db/pubkeys.go
···
package db
import (
+
"strconv"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
···
return keys, nil
}
+
+
func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) {
+
var keys []PublicKey
+
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
+
offset = o
+
}
+
}
+
+
query := `select key, did, created from public_keys order by created desc limit ? offset ?`
+
rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results
+
if err != nil {
+
return nil, "", err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var publicKey PublicKey
+
if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil {
+
return nil, "", err
+
}
+
keys = append(keys, publicKey)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, "", err
+
}
+
+
// check if there are more results for pagination
+
var nextCursor string
+
if len(keys) > limit {
+
keys = keys[:limit] // remove the extra item
+
nextCursor = strconv.Itoa(offset + limit)
+
}
+
+
return keys, nextCursor, nil
+
}
+2 -2
knotserver/events.go
···
WriteBufferSize: 1024,
}
-
func (h *Handle) Events(w http.ResponseWriter, r *http.Request) {
+
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "OpLog")
l.Debug("received new connection")
···
}
}
-
func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error {
+
func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error {
events, err := h.db.GetEvents(*cursor)
if err != nil {
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-48
knotserver/file.go
···
-
package knotserver
-
-
import (
-
"bytes"
-
"io"
-
"log/slog"
-
"net/http"
-
"strings"
-
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
func countLines(r io.Reader) (int, error) {
-
buf := make([]byte, 32*1024)
-
bufLen := 0
-
count := 0
-
nl := []byte{'\n'}
-
-
for {
-
c, err := r.Read(buf)
-
if c > 0 {
-
bufLen += c
-
}
-
count += bytes.Count(buf[:c], nl)
-
-
switch {
-
case err == io.EOF:
-
/* handle last line not having a newline at the end */
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
-
count++
-
}
-
return count, nil
-
case err != nil:
-
return 0, err
-
}
-
}
-
}
-
-
func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) {
-
lc, err := countLines(strings.NewReader(resp.Contents))
-
if err != nil {
-
// Non-fatal, we'll just skip showing line numbers in the template.
-
l.Warn("counting lines", "error", err)
-
}
-
-
resp.Lines = lc
-
writeJSON(w, resp)
-
}
+4 -4
knotserver/git.go
···
"tangled.sh/tangled.sh/core/knotserver/git/service"
)
-
func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) {
+
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
did := chi.URLParam(r, "did")
name := chi.URLParam(r, "name")
repoName, err := securejoin.SecureJoin(did, name)
···
}
}
-
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
+
func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
did := chi.URLParam(r, "did")
name := chi.URLParam(r, "name")
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
···
}
}
-
func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) {
+
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
did := chi.URLParam(r, "did")
name := chi.URLParam(r, "name")
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
···
d.RejectPush(w, r, name)
}
-
func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
+
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
// A text/plain response will cause git to print each line of the body
// prefixed with "remote: ".
w.Header().Set("content-type", "text/plain; charset=UTF-8")
-1069
knotserver/handler.go
···
-
package knotserver
-
-
import (
-
"compress/gzip"
-
"context"
-
"crypto/sha256"
-
"encoding/json"
-
"errors"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"path/filepath"
-
"strconv"
-
"strings"
-
"sync"
-
"time"
-
-
securejoin "github.com/cyphar/filepath-securejoin"
-
"github.com/gliderlabs/ssh"
-
"github.com/go-chi/chi/v5"
-
"github.com/go-git/go-git/v5/plumbing"
-
"github.com/go-git/go-git/v5/plumbing/object"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
-
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
-
}
-
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
-
w.Header().Set("Content-Type", "application/json")
-
-
capabilities := map[string]any{
-
"pull_requests": map[string]any{
-
"format_patch": true,
-
"patch_submissions": true,
-
"branch_submissions": true,
-
"fork_submissions": true,
-
},
-
"xrpc": true,
-
}
-
-
jsonData, err := json.Marshal(capabilities)
-
if err != nil {
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
-
return
-
}
-
-
w.Write(jsonData)
-
}
-
-
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
l := h.l.With("path", path, "handler", "RepoIndex")
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
plain, err2 := git.PlainOpen(path)
-
if err2 != nil {
-
l.Error("opening repo", "error", err2.Error())
-
notFound(w)
-
return
-
}
-
branches, _ := plain.Branches()
-
-
log.Println(err)
-
-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
-
resp := types.RepoIndexResponse{
-
IsEmpty: true,
-
Branches: branches,
-
}
-
writeJSON(w, resp)
-
return
-
} else {
-
l.Error("opening repo", "error", err.Error())
-
notFound(w)
-
return
-
}
-
}
-
-
var (
-
commits []*object.Commit
-
total int
-
branches []types.Branch
-
files []types.NiceTree
-
tags []object.Tag
-
)
-
-
var wg sync.WaitGroup
-
errorsCh := make(chan error, 5)
-
-
wg.Add(1)
-
go func() {
-
defer wg.Done()
-
cs, err := gr.Commits(0, 60)
-
if err != nil {
-
errorsCh <- fmt.Errorf("commits: %w", err)
-
return
-
}
-
commits = cs
-
}()
-
-
wg.Add(1)
-
go func() {
-
defer wg.Done()
-
t, err := gr.TotalCommits()
-
if err != nil {
-
errorsCh <- fmt.Errorf("calculating total: %w", err)
-
return
-
}
-
total = t
-
}()
-
-
wg.Add(1)
-
go func() {
-
defer wg.Done()
-
bs, err := gr.Branches()
-
if err != nil {
-
errorsCh <- fmt.Errorf("fetching branches: %w", err)
-
return
-
}
-
branches = bs
-
}()
-
-
wg.Add(1)
-
go func() {
-
defer wg.Done()
-
ts, err := gr.Tags()
-
if err != nil {
-
errorsCh <- fmt.Errorf("fetching tags: %w", err)
-
return
-
}
-
tags = ts
-
}()
-
-
wg.Add(1)
-
go func() {
-
defer wg.Done()
-
fs, err := gr.FileTree(r.Context(), "")
-
if err != nil {
-
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
-
return
-
}
-
files = fs
-
}()
-
-
wg.Wait()
-
close(errorsCh)
-
-
// show any errors
-
for err := range errorsCh {
-
l.Error("loading repo", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
rtags := []*types.TagReference{}
-
for _, tag := range tags {
-
var target *object.Tag
-
if tag.Target != plumbing.ZeroHash {
-
target = &tag
-
}
-
tr := types.TagReference{
-
Tag: target,
-
}
-
-
tr.Reference = types.Reference{
-
Name: tag.Name,
-
Hash: tag.Hash.String(),
-
}
-
-
if tag.Message != "" {
-
tr.Message = tag.Message
-
}
-
-
rtags = append(rtags, &tr)
-
}
-
-
var readmeContent string
-
var readmeFile string
-
for _, readme := range h.c.Repo.Readme {
-
content, _ := gr.FileContent(readme)
-
if len(content) > 0 {
-
readmeContent = string(content)
-
readmeFile = readme
-
}
-
}
-
-
if ref == "" {
-
mainBranch, err := gr.FindMainBranch()
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("finding main branch", "error", err.Error())
-
return
-
}
-
ref = mainBranch
-
}
-
-
resp := types.RepoIndexResponse{
-
IsEmpty: false,
-
Ref: ref,
-
Commits: commits,
-
Description: getDescription(path),
-
Readme: readmeContent,
-
ReadmeFileName: readmeFile,
-
Files: files,
-
Branches: branches,
-
Tags: rtags,
-
TotalCommits: total,
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
-
treePath := chi.URLParam(r, "*")
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
files, err := gr.FileTree(r.Context(), treePath)
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("file tree", "error", err.Error())
-
return
-
}
-
-
resp := types.RepoTreeResponse{
-
Ref: ref,
-
Parent: treePath,
-
Description: getDescription(path),
-
DotDot: filepath.Dir(treePath),
-
Files: files,
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
-
treePath := chi.URLParam(r, "*")
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
contents, err := gr.RawContent(treePath)
-
if err != nil {
-
writeError(w, err.Error(), http.StatusBadRequest)
-
l.Error("file content", "error", err.Error())
-
return
-
}
-
-
mimeType := http.DetectContentType(contents)
-
-
// exception for svg
-
if filepath.Ext(treePath) == ".svg" {
-
mimeType = "image/svg+xml"
-
}
-
-
contentHash := sha256.Sum256(contents)
-
eTag := fmt.Sprintf("\"%x\"", contentHash)
-
-
// allow image, video, and text/plain files to be served directly
-
switch {
-
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
-
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
-
w.WriteHeader(http.StatusNotModified)
-
return
-
}
-
w.Header().Set("ETag", eTag)
-
-
case strings.HasPrefix(mimeType, "text/plain"):
-
w.Header().Set("Cache-Control", "public, no-cache")
-
-
default:
-
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
-
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
-
return
-
}
-
-
w.Header().Set("Content-Type", mimeType)
-
w.Write(contents)
-
}
-
-
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
-
treePath := chi.URLParam(r, "*")
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
var isBinaryFile bool = false
-
contents, err := gr.FileContent(treePath)
-
if errors.Is(err, git.ErrBinaryFile) {
-
isBinaryFile = true
-
} else if errors.Is(err, object.ErrFileNotFound) {
-
notFound(w)
-
return
-
} else if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
bytes := []byte(contents)
-
// safe := string(sanitize(bytes))
-
sizeHint := len(bytes)
-
-
resp := types.RepoBlobResponse{
-
Ref: ref,
-
Contents: string(bytes),
-
Path: treePath,
-
IsBinary: isBinaryFile,
-
SizeHint: uint64(sizeHint),
-
}
-
-
h.showFile(resp, w, l)
-
}
-
-
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
-
name := chi.URLParam(r, "name")
-
file := chi.URLParam(r, "file")
-
-
l := h.l.With("handler", "Archive", "name", name, "file", file)
-
-
// TODO: extend this to add more files compression (e.g.: xz)
-
if !strings.HasSuffix(file, ".tar.gz") {
-
notFound(w)
-
return
-
}
-
-
ref := strings.TrimSuffix(file, ".tar.gz")
-
-
unescapedRef, err := url.PathUnescape(ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
-
-
// This allows the browser to use a proper name for the file when
-
// downloading
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
-
setContentDisposition(w, filename)
-
setGZipMIME(w)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.Open(path, unescapedRef)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
gw := gzip.NewWriter(w)
-
defer gw.Close()
-
-
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
-
err = gr.WriteTar(gw, prefix)
-
if err != nil {
-
// once we start writing to the body we can't report error anymore
-
// so we are only left with printing the error.
-
l.Error("writing tar file", "error", err.Error())
-
return
-
}
-
-
err = gw.Flush()
-
if err != nil {
-
// once we start writing to the body we can't report error anymore
-
// so we are only left with printing the error.
-
l.Error("flushing?", "error", err.Error())
-
return
-
}
-
}
-
-
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
-
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
// Get page parameters
-
page := 1
-
pageSize := 30
-
-
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
-
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
-
page = p
-
}
-
}
-
-
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
-
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
-
pageSize = ps
-
}
-
}
-
-
// convert to offset/limit
-
offset := (page - 1) * pageSize
-
limit := pageSize
-
-
commits, err := gr.Commits(offset, limit)
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("fetching commits", "error", err.Error())
-
return
-
}
-
-
total := len(commits)
-
-
resp := types.RepoLogResponse{
-
Commits: commits,
-
Ref: ref,
-
Description: getDescription(path),
-
Log: true,
-
Total: total,
-
Page: page,
-
PerPage: pageSize,
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
l := h.l.With("handler", "Diff", "ref", ref)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
diff, err := gr.Diff()
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("getting diff", "error", err.Error())
-
return
-
}
-
-
resp := types.RepoCommitResponse{
-
Ref: ref,
-
Diff: diff,
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
l := h.l.With("handler", "Refs")
-
-
gr, err := git.Open(path, "")
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
tags, err := gr.Tags()
-
if err != nil {
-
// Non-fatal, we *should* have at least one branch to show.
-
l.Warn("getting tags", "error", err.Error())
-
}
-
-
rtags := []*types.TagReference{}
-
for _, tag := range tags {
-
var target *object.Tag
-
if tag.Target != plumbing.ZeroHash {
-
target = &tag
-
}
-
tr := types.TagReference{
-
Tag: target,
-
}
-
-
tr.Reference = types.Reference{
-
Name: tag.Name,
-
Hash: tag.Hash.String(),
-
}
-
-
if tag.Message != "" {
-
tr.Message = tag.Message
-
}
-
-
rtags = append(rtags, &tr)
-
}
-
-
resp := types.RepoTagsResponse{
-
Tags: rtags,
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
-
gr, err := git.PlainOpen(path)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
branches, _ := gr.Branches()
-
-
resp := types.RepoBranchesResponse{
-
Branches: branches,
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
branchName := chi.URLParam(r, "branch")
-
branchName, _ = url.PathUnescape(branchName)
-
-
l := h.l.With("handler", "Branch")
-
-
gr, err := git.PlainOpen(path)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
ref, err := gr.Branch(branchName)
-
if err != nil {
-
l.Error("getting branch", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
commit, err := gr.Commit(ref.Hash())
-
if err != nil {
-
l.Error("getting commit object", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
defaultBranch, err := gr.FindMainBranch()
-
isDefault := false
-
if err != nil {
-
l.Error("getting default branch", "error", err.Error())
-
// do not quit though
-
} else if defaultBranch == branchName {
-
isDefault = true
-
}
-
-
resp := types.RepoBranchResponse{
-
Branch: types.Branch{
-
Reference: types.Reference{
-
Name: ref.Name().Short(),
-
Hash: ref.Hash().String(),
-
},
-
Commit: commit,
-
IsDefault: isDefault,
-
},
-
}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "Keys")
-
-
switch r.Method {
-
case http.MethodGet:
-
keys, err := h.db.GetAllPublicKeys()
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("getting public keys", "error", err.Error())
-
return
-
}
-
-
data := make([]map[string]any, 0)
-
for _, key := range keys {
-
j := key.JSON()
-
data = append(data, j)
-
}
-
writeJSON(w, data)
-
return
-
-
case http.MethodPut:
-
pk := db.PublicKey{}
-
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
-
if err != nil {
-
writeError(w, "invalid pubkey", http.StatusBadRequest)
-
}
-
-
if err := h.db.AddPublicKey(pk); err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("adding public key", "error", err.Error())
-
return
-
}
-
-
w.WriteHeader(http.StatusNoContent)
-
return
-
}
-
}
-
-
// func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
-
// l := h.l.With("handler", "RepoForkSync")
-
//
-
// data := struct {
-
// Did string `json:"did"`
-
// Source string `json:"source"`
-
// Name string `json:"name,omitempty"`
-
// HiddenRef string `json:"hiddenref"`
-
// }{}
-
//
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
// writeError(w, "invalid request body", http.StatusBadRequest)
-
// return
-
// }
-
//
-
// did := data.Did
-
// source := data.Source
-
//
-
// if did == "" || source == "" {
-
// l.Error("invalid request body, empty did or name")
-
// w.WriteHeader(http.StatusBadRequest)
-
// return
-
// }
-
//
-
// var name string
-
// if data.Name != "" {
-
// name = data.Name
-
// } else {
-
// name = filepath.Base(source)
-
// }
-
//
-
// branch := chi.URLParam(r, "branch")
-
// branch, _ = url.PathUnescape(branch)
-
//
-
// relativeRepoPath := filepath.Join(did, name)
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
//
-
// gr, err := git.PlainOpen(repoPath)
-
// if err != nil {
-
// log.Println(err)
-
// notFound(w)
-
// return
-
// }
-
//
-
// forkCommit, err := gr.ResolveRevision(branch)
-
// if err != nil {
-
// l.Error("error resolving ref revision", "msg", err.Error())
-
// writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
-
// return
-
// }
-
//
-
// sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
-
// if err != nil {
-
// l.Error("error resolving hidden ref revision", "msg", err.Error())
-
// writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
-
// return
-
// }
-
//
-
// status := types.UpToDate
-
// if forkCommit.Hash.String() != sourceCommit.Hash.String() {
-
// isAncestor, err := forkCommit.IsAncestor(sourceCommit)
-
// if err != nil {
-
// log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
-
// return
-
// }
-
//
-
// if isAncestor {
-
// status = types.FastForwardable
-
// } else {
-
// status = types.Conflict
-
// }
-
// }
-
//
-
// w.Header().Set("Content-Type", "application/json")
-
// json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
-
// }
-
-
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
l := h.l.With("handler", "RepoLanguages")
-
-
gr, err := git.Open(repoPath, ref)
-
if err != nil {
-
l.Error("opening repo", "error", err.Error())
-
notFound(w)
-
return
-
}
-
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
-
defer cancel()
-
-
sizes, err := gr.AnalyzeLanguages(ctx)
-
if err != nil {
-
l.Error("failed to analyze languages", "error", err.Error())
-
writeError(w, err.Error(), http.StatusNoContent)
-
return
-
}
-
-
resp := types.RepoLanguageResponse{Languages: sizes}
-
-
writeJSON(w, resp)
-
}
-
-
// func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
-
// l := h.l.With("handler", "RepoForkSync")
-
//
-
// data := struct {
-
// Did string `json:"did"`
-
// Source string `json:"source"`
-
// Name string `json:"name,omitempty"`
-
// }{}
-
//
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
// writeError(w, "invalid request body", http.StatusBadRequest)
-
// return
-
// }
-
//
-
// did := data.Did
-
// source := data.Source
-
//
-
// if did == "" || source == "" {
-
// l.Error("invalid request body, empty did or name")
-
// w.WriteHeader(http.StatusBadRequest)
-
// return
-
// }
-
//
-
// var name string
-
// if data.Name != "" {
-
// name = data.Name
-
// } else {
-
// name = filepath.Base(source)
-
// }
-
//
-
// branch := chi.URLParam(r, "branch")
-
// branch, _ = url.PathUnescape(branch)
-
//
-
// relativeRepoPath := filepath.Join(did, name)
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
//
-
// gr, err := git.Open(repoPath, branch)
-
// if err != nil {
-
// log.Println(err)
-
// notFound(w)
-
// return
-
// }
-
//
-
// err = gr.Sync()
-
// if err != nil {
-
// l.Error("error syncing repo fork", "error", err.Error())
-
// writeError(w, err.Error(), http.StatusInternalServerError)
-
// return
-
// }
-
//
-
// w.WriteHeader(http.StatusNoContent)
-
// }
-
-
// func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
-
// l := h.l.With("handler", "RepoFork")
-
//
-
// data := struct {
-
// Did string `json:"did"`
-
// Source string `json:"source"`
-
// Name string `json:"name,omitempty"`
-
// }{}
-
//
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
// writeError(w, "invalid request body", http.StatusBadRequest)
-
// return
-
// }
-
//
-
// did := data.Did
-
// source := data.Source
-
//
-
// if did == "" || source == "" {
-
// l.Error("invalid request body, empty did or name")
-
// w.WriteHeader(http.StatusBadRequest)
-
// return
-
// }
-
//
-
// var name string
-
// if data.Name != "" {
-
// name = data.Name
-
// } else {
-
// name = filepath.Base(source)
-
// }
-
//
-
// relativeRepoPath := filepath.Join(did, name)
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
//
-
// err := git.Fork(repoPath, source)
-
// if err != nil {
-
// l.Error("forking repo", "error", err.Error())
-
// writeError(w, err.Error(), http.StatusInternalServerError)
-
// return
-
// }
-
//
-
// // add perms for this user to access the repo
-
// err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
-
// if err != nil {
-
// l.Error("adding repo permissions", "error", err.Error())
-
// writeError(w, err.Error(), http.StatusInternalServerError)
-
// return
-
// }
-
//
-
// hook.SetupRepo(
-
// hook.Config(
-
// hook.WithScanPath(h.c.Repo.ScanPath),
-
// hook.WithInternalApi(h.c.Server.InternalListenAddr),
-
// ),
-
// repoPath,
-
// )
-
//
-
// w.WriteHeader(http.StatusNoContent)
-
// }
-
-
// func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
-
// l := h.l.With("handler", "RemoveRepo")
-
//
-
// data := struct {
-
// Did string `json:"did"`
-
// Name string `json:"name"`
-
// }{}
-
//
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
// writeError(w, "invalid request body", http.StatusBadRequest)
-
// return
-
// }
-
//
-
// did := data.Did
-
// name := data.Name
-
//
-
// if did == "" || name == "" {
-
// l.Error("invalid request body, empty did or name")
-
// w.WriteHeader(http.StatusBadRequest)
-
// return
-
// }
-
//
-
// relativeRepoPath := filepath.Join(did, name)
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
// err := os.RemoveAll(repoPath)
-
// if err != nil {
-
// l.Error("removing repo", "error", err.Error())
-
// writeError(w, err.Error(), http.StatusInternalServerError)
-
// return
-
// }
-
//
-
// w.WriteHeader(http.StatusNoContent)
-
//
-
// }
-
-
// func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
//
-
// data := types.MergeRequest{}
-
//
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
// writeError(w, err.Error(), http.StatusBadRequest)
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
-
// return
-
// }
-
//
-
// mo := &git.MergeOptions{
-
// AuthorName: data.AuthorName,
-
// AuthorEmail: data.AuthorEmail,
-
// CommitBody: data.CommitBody,
-
// CommitMessage: data.CommitMessage,
-
// }
-
//
-
// patch := data.Patch
-
// branch := data.Branch
-
// gr, err := git.Open(path, branch)
-
// if err != nil {
-
// notFound(w)
-
// return
-
// }
-
//
-
// mo.FormatPatch = patchutil.IsFormatPatch(patch)
-
//
-
// if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
-
// var mergeErr *git.ErrMerge
-
// if errors.As(err, &mergeErr) {
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
-
// for i, conflict := range mergeErr.Conflicts {
-
// conflicts[i] = types.ConflictInfo{
-
// Filename: conflict.Filename,
-
// Reason: conflict.Reason,
-
// }
-
// }
-
// response := types.MergeCheckResponse{
-
// IsConflicted: true,
-
// Conflicts: conflicts,
-
// Message: mergeErr.Message,
-
// }
-
// writeConflict(w, response)
-
// h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
-
// } else {
-
// writeError(w, err.Error(), http.StatusBadRequest)
-
// h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
-
// }
-
// return
-
// }
-
//
-
// w.WriteHeader(http.StatusOK)
-
// }
-
-
// func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
//
-
// var data struct {
-
// Patch string `json:"patch"`
-
// Branch string `json:"branch"`
-
// }
-
//
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
// writeError(w, err.Error(), http.StatusBadRequest)
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
-
// return
-
// }
-
//
-
// patch := data.Patch
-
// branch := data.Branch
-
// gr, err := git.Open(path, branch)
-
// if err != nil {
-
// notFound(w)
-
// return
-
// }
-
//
-
// err = gr.MergeCheck([]byte(patch), branch)
-
// if err == nil {
-
// response := types.MergeCheckResponse{
-
// IsConflicted: false,
-
// }
-
// writeJSON(w, response)
-
// return
-
// }
-
//
-
// var mergeErr *git.ErrMerge
-
// if errors.As(err, &mergeErr) {
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
-
// for i, conflict := range mergeErr.Conflicts {
-
// conflicts[i] = types.ConflictInfo{
-
// Filename: conflict.Filename,
-
// Reason: conflict.Reason,
-
// }
-
// }
-
// response := types.MergeCheckResponse{
-
// IsConflicted: true,
-
// Conflicts: conflicts,
-
// Message: mergeErr.Message,
-
// }
-
// writeConflict(w, response)
-
// h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
-
// return
-
// }
-
// writeError(w, err.Error(), http.StatusInternalServerError)
-
// h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
-
// }
-
-
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
-
rev1 := chi.URLParam(r, "rev1")
-
rev1, _ = url.PathUnescape(rev1)
-
-
rev2 := chi.URLParam(r, "rev2")
-
rev2, _ = url.PathUnescape(rev2)
-
-
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.PlainOpen(path)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
commit1, err := gr.ResolveRevision(rev1)
-
if err != nil {
-
l.Error("error resolving revision 1", "msg", err.Error())
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
-
return
-
}
-
-
commit2, err := gr.ResolveRevision(rev2)
-
if err != nil {
-
l.Error("error resolving revision 2", "msg", err.Error())
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
-
return
-
}
-
-
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
-
if err != nil {
-
l.Error("error comparing revisions", "msg", err.Error())
-
writeError(w, "error comparing revisions", http.StatusBadRequest)
-
return
-
}
-
-
writeJSON(w, types.RepoFormatPatchResponse{
-
Rev1: commit1.Hash.String(),
-
Rev2: commit2.Hash.String(),
-
FormatPatch: formatPatch,
-
Patch: rawPatch,
-
})
-
}
-
-
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "DefaultBranch")
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
-
gr, err := git.Open(path, "")
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
branch, err := gr.FindMainBranch()
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("getting default branch", "error", err.Error())
-
return
-
}
-
-
writeJSON(w, types.RepoDefaultBranchResponse{
-
Branch: branch,
-
})
-
}
+11 -6
knotserver/ingester.go
···
"tangled.sh/tangled.sh/core/workflow"
)
-
func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error {
+
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
l := log.FromContext(ctx)
raw := json.RawMessage(event.Commit.Record)
did := event.Did
···
return nil
}
-
func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error {
+
func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error {
l := log.FromContext(ctx)
raw := json.RawMessage(event.Commit.Record)
did := event.Did
···
return nil
}
-
func (h *Handle) processPull(ctx context.Context, event *models.Event) error {
+
func (h *Knot) processPull(ctx context.Context, event *models.Event) error {
raw := json.RawMessage(event.Commit.Record)
did := event.Did
···
l := log.FromContext(ctx)
l = l.With("handler", "processPull")
l = l.With("did", did)
+
+
if record.Target == nil {
+
return fmt.Errorf("ignoring pull record: target repo is nil")
+
}
+
l = l.With("target_repo", record.Target.Repo)
l = l.With("target_branch", record.Target.Branch)
···
}
// duplicated from add collaborator
-
func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error {
+
func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error {
raw := json.RawMessage(event.Commit.Record)
did := event.Did
···
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
}
-
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
+
func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error {
l := log.FromContext(ctx)
keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did)
···
return nil
}
-
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
+
func (h *Knot) processMessages(ctx context.Context, event *models.Event) error {
if event.Kind != models.EventKindCommit {
return nil
}
+152
knotserver/router.go
···
+
package knotserver
+
+
import (
+
"context"
+
"fmt"
+
"log/slog"
+
"net/http"
+
+
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/idresolver"
+
"tangled.sh/tangled.sh/core/jetstream"
+
"tangled.sh/tangled.sh/core/knotserver/config"
+
"tangled.sh/tangled.sh/core/knotserver/db"
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
+
tlog "tangled.sh/tangled.sh/core/log"
+
"tangled.sh/tangled.sh/core/notifier"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
+
)
+
+
type Knot struct {
+
c *config.Config
+
db *db.DB
+
jc *jetstream.JetstreamClient
+
e *rbac.Enforcer
+
l *slog.Logger
+
n *notifier.Notifier
+
resolver *idresolver.Resolver
+
}
+
+
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) {
+
r := chi.NewRouter()
+
+
h := Knot{
+
c: c,
+
db: db,
+
e: e,
+
l: l,
+
jc: jc,
+
n: n,
+
resolver: idresolver.DefaultResolver(),
+
}
+
+
err := e.AddKnot(rbac.ThisServer)
+
if err != nil {
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
+
}
+
+
// configure owner
+
if err = h.configureOwner(); err != nil {
+
return nil, err
+
}
+
h.l.Info("owner set", "did", h.c.Server.Owner)
+
h.jc.AddDid(h.c.Server.Owner)
+
+
// configure known-dids in jetstream consumer
+
dids, err := h.db.GetAllDids()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
+
}
+
for _, d := range dids {
+
jc.AddDid(d)
+
}
+
+
err = h.jc.StartJetstream(ctx, h.processMessages)
+
if err != nil {
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
+
}
+
+
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
+
})
+
+
r.Route("/{did}", func(r chi.Router) {
+
r.Route("/{name}", func(r chi.Router) {
+
// routes for git operations
+
r.Get("/info/refs", h.InfoRefs)
+
r.Post("/git-upload-pack", h.UploadPack)
+
r.Post("/git-receive-pack", h.ReceivePack)
+
})
+
})
+
+
// xrpc apis
+
r.Mount("/xrpc", h.XrpcRouter())
+
+
// Socket that streams git oplogs
+
r.Get("/events", h.Events)
+
+
return r, nil
+
}
+
+
func (h *Knot) XrpcRouter() http.Handler {
+
logger := tlog.New("knots")
+
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
+
+
xrpc := &xrpc.Xrpc{
+
Config: h.c,
+
Db: h.db,
+
Ingester: h.jc,
+
Enforcer: h.e,
+
Logger: logger,
+
Notifier: h.n,
+
Resolver: h.resolver,
+
ServiceAuth: serviceAuth,
+
}
+
return xrpc.Router()
+
}
+
+
func (h *Knot) configureOwner() error {
+
cfgOwner := h.c.Server.Owner
+
+
rbacDomain := "thisserver"
+
+
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
+
if err != nil {
+
return err
+
}
+
+
switch len(existing) {
+
case 0:
+
// no owner configured, continue
+
case 1:
+
// find existing owner
+
existingOwner := existing[0]
+
+
// no ownership change, this is okay
+
if existingOwner == h.c.Server.Owner {
+
break
+
}
+
+
// remove existing owner
+
if err = h.db.RemoveDid(existingOwner); err != nil {
+
return err
+
}
+
if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
+
return err
+
}
+
+
default:
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
+
}
+
+
if err = h.db.AddDid(cfgOwner); err != nil {
+
return fmt.Errorf("failed to add owner to DB: %w", err)
+
}
+
if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
+
return fmt.Errorf("failed to add owner to RBAC: %w", err)
+
}
+
+
return nil
+
}
-237
knotserver/routes.go
···
-
package knotserver
-
-
import (
-
"context"
-
"fmt"
-
"log/slog"
-
"net/http"
-
"runtime/debug"
-
-
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/jetstream"
-
"tangled.sh/tangled.sh/core/knotserver/config"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
-
tlog "tangled.sh/tangled.sh/core/log"
-
"tangled.sh/tangled.sh/core/notifier"
-
"tangled.sh/tangled.sh/core/rbac"
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
-
)
-
-
type Handle struct {
-
c *config.Config
-
db *db.DB
-
jc *jetstream.JetstreamClient
-
e *rbac.Enforcer
-
l *slog.Logger
-
n *notifier.Notifier
-
resolver *idresolver.Resolver
-
}
-
-
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) {
-
r := chi.NewRouter()
-
-
h := Handle{
-
c: c,
-
db: db,
-
e: e,
-
l: l,
-
jc: jc,
-
n: n,
-
resolver: idresolver.DefaultResolver(),
-
}
-
-
err := e.AddKnot(rbac.ThisServer)
-
if err != nil {
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
-
}
-
-
// configure owner
-
if err = h.configureOwner(); err != nil {
-
return nil, err
-
}
-
h.l.Info("owner set", "did", h.c.Server.Owner)
-
h.jc.AddDid(h.c.Server.Owner)
-
-
// configure known-dids in jetstream consumer
-
dids, err := h.db.GetAllDids()
-
if err != nil {
-
return nil, fmt.Errorf("failed to get all dids: %w", err)
-
}
-
for _, d := range dids {
-
jc.AddDid(d)
-
}
-
-
err = h.jc.StartJetstream(ctx, h.processMessages)
-
if err != nil {
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
-
}
-
-
r.Get("/", h.Index)
-
r.Get("/capabilities", h.Capabilities)
-
r.Get("/version", h.Version)
-
r.Get("/owner", func(w http.ResponseWriter, r *http.Request) {
-
w.Write([]byte(h.c.Server.Owner))
-
})
-
r.Route("/{did}", func(r chi.Router) {
-
// Repo routes
-
r.Route("/{name}", func(r chi.Router) {
-
-
r.Route("/languages", func(r chi.Router) {
-
r.Get("/", h.RepoLanguages)
-
r.Get("/{ref}", h.RepoLanguages)
-
})
-
-
r.Get("/", h.RepoIndex)
-
r.Get("/info/refs", h.InfoRefs)
-
r.Post("/git-upload-pack", h.UploadPack)
-
r.Post("/git-receive-pack", h.ReceivePack)
-
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
-
-
r.Route("/tree/{ref}", func(r chi.Router) {
-
r.Get("/", h.RepoIndex)
-
r.Get("/*", h.RepoTree)
-
})
-
-
r.Route("/blob/{ref}", func(r chi.Router) {
-
r.Get("/*", h.Blob)
-
})
-
-
r.Route("/raw/{ref}", func(r chi.Router) {
-
r.Get("/*", h.BlobRaw)
-
})
-
-
r.Get("/log/{ref}", h.Log)
-
r.Get("/archive/{file}", h.Archive)
-
r.Get("/commit/{ref}", h.Diff)
-
r.Get("/tags", h.Tags)
-
r.Route("/branches", func(r chi.Router) {
-
r.Get("/", h.Branches)
-
r.Get("/{branch}", h.Branch)
-
r.Get("/default", h.DefaultBranch)
-
})
-
})
-
})
-
-
// xrpc apis
-
r.Mount("/xrpc", h.XrpcRouter())
-
-
// Socket that streams git oplogs
-
r.Get("/events", h.Events)
-
-
// All public keys on the knot.
-
r.Get("/keys", h.Keys)
-
-
return r, nil
-
}
-
-
func (h *Handle) XrpcRouter() http.Handler {
-
logger := tlog.New("knots")
-
-
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
-
-
xrpc := &xrpc.Xrpc{
-
Config: h.c,
-
Db: h.db,
-
Ingester: h.jc,
-
Enforcer: h.e,
-
Logger: logger,
-
Notifier: h.n,
-
Resolver: h.resolver,
-
ServiceAuth: serviceAuth,
-
}
-
return xrpc.Router()
-
}
-
-
// version is set during build time.
-
var version string
-
-
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
-
if version == "" {
-
info, ok := debug.ReadBuildInfo()
-
if !ok {
-
http.Error(w, "failed to read build info", http.StatusInternalServerError)
-
return
-
}
-
-
var modVer string
-
var sha string
-
var modified bool
-
-
for _, mod := range info.Deps {
-
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
-
modVer = mod.Version
-
break
-
}
-
}
-
-
for _, setting := range info.Settings {
-
switch setting.Key {
-
case "vcs.revision":
-
sha = setting.Value
-
case "vcs.modified":
-
modified = setting.Value == "true"
-
}
-
}
-
-
if modVer == "" {
-
modVer = "unknown"
-
}
-
-
if sha == "" {
-
version = modVer
-
} else if modified {
-
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
-
} else {
-
version = fmt.Sprintf("%s (%s)", modVer, sha)
-
}
-
}
-
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
-
fmt.Fprintf(w, "knotserver/%s", version)
-
}
-
-
func (h *Handle) configureOwner() error {
-
cfgOwner := h.c.Server.Owner
-
-
rbacDomain := "thisserver"
-
-
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
-
if err != nil {
-
return err
-
}
-
-
switch len(existing) {
-
case 0:
-
// no owner configured, continue
-
case 1:
-
// find existing owner
-
existingOwner := existing[0]
-
-
// no ownership change, this is okay
-
if existingOwner == h.c.Server.Owner {
-
break
-
}
-
-
// remove existing owner
-
if err = h.db.RemoveDid(existingOwner); err != nil {
-
return err
-
}
-
if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
-
return err
-
}
-
-
default:
-
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
-
}
-
-
if err = h.db.AddDid(cfgOwner); err != nil {
-
return fmt.Errorf("failed to add owner to DB: %w", err)
-
}
-
if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
-
return fmt.Errorf("failed to add owner to RBAC: %w", err)
-
}
-
-
return nil
-
}
+16 -13
knotserver/server.go
···
Usage: "run a knot server",
Action: Run,
Description: `
-
Environment variables:
-
KNOT_SERVER_SECRET (required)
-
KNOT_SERVER_HOSTNAME (required)
-
KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555)
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444)
-
KNOT_SERVER_DB_PATH (default: knotserver.db)
-
KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe)
-
KNOT_SERVER_DEV (default: false)
-
KNOT_REPO_SCAN_PATH (default: /home/git)
-
KNOT_REPO_README (comma-separated list)
-
KNOT_REPO_MAIN_BRANCH (default: main)
-
APPVIEW_ENDPOINT (default: https://tangled.sh)
-
`,
+
Environment variables:
+
KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555)
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444)
+
KNOT_SERVER_DB_PATH (default: knotserver.db)
+
KNOT_SERVER_HOSTNAME (required)
+
KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe)
+
KNOT_SERVER_OWNER (required)
+
KNOT_SERVER_LOG_DIDS (default: true)
+
KNOT_SERVER_DEV (default: false)
+
KNOT_REPO_SCAN_PATH (default: /home/git)
+
KNOT_REPO_README (comma-separated list)
+
KNOT_REPO_MAIN_BRANCH (default: main)
+
KNOT_GIT_USER_NAME (default: Tangled)
+
KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh)
+
APPVIEW_ENDPOINT (default: https://tangled.sh)
+
`,
}
}
+58
knotserver/xrpc/list_keys.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+
cursor := r.URL.Query().Get("cursor")
+
+
limit := 100 // default
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
+
limit = l
+
}
+
}
+
+
keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor)
+
if err != nil {
+
x.Logger.Error("failed to get public keys", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to retrieve public keys"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys))
+
for _, key := range keys {
+
publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{
+
Did: key.Did,
+
Key: key.Key,
+
CreatedAt: key.CreatedAt,
+
})
+
}
+
+
response := tangled.KnotListKeys_Output{
+
Keys: publicKeys,
+
}
+
+
if nextCursor != "" {
+
response.Cursor = &nextCursor
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+31
knotserver/xrpc/owner.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+
owner := x.Config.Server.Owner
+
if owner == "" {
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.Owner_Output{
+
Owner: owner,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+80
knotserver/xrpc/repo_archive.go
···
+
package xrpc
+
+
import (
+
"compress/gzip"
+
"fmt"
+
"net/http"
+
"strings"
+
+
"github.com/go-git/go-git/v5/plumbing"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+
repo, repoPath, unescapedRef, err := x.parseStandardParams(r)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
format := r.URL.Query().Get("format")
+
if format == "" {
+
format = "tar.gz" // default
+
}
+
+
prefix := r.URL.Query().Get("prefix")
+
+
if format != "tar.gz" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("only tar.gz format is supported"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, unescapedRef)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
repoParts := strings.Split(repo, "/")
+
repoName := repoParts[len(repoParts)-1]
+
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
+
+
var archivePrefix string
+
if prefix != "" {
+
archivePrefix = prefix
+
} else {
+
archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename)
+
}
+
+
filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename)
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
w.Header().Set("Content-Type", "application/gzip")
+
+
gw := gzip.NewWriter(w)
+
defer gw.Close()
+
+
err = gr.WriteTar(gw, archivePrefix)
+
if err != nil {
+
// once we start writing to the body we can't report error anymore
+
// so we are only left with logging the error
+
x.Logger.Error("writing tar file", "error", err.Error())
+
return
+
}
+
+
err = gw.Flush()
+
if err != nil {
+
// once we start writing to the body we can't report error anymore
+
// so we are only left with logging the error
+
x.Logger.Error("flushing", "error", err.Error())
+
return
+
}
+
}
+151
knotserver/xrpc/repo_blob.go
···
+
package xrpc
+
+
import (
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
"slices"
+
"strings"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
+
_, repoPath, ref, err := x.parseStandardParams(r)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
treePath := r.URL.Query().Get("path")
+
if treePath == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing path parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
raw := r.URL.Query().Get("raw") == "true"
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
contents, err := gr.RawContent(treePath)
+
if err != nil {
+
x.Logger.Error("file content", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("FileNotFound"),
+
xrpcerr.WithMessage("file not found at the specified path"),
+
), http.StatusNotFound)
+
return
+
}
+
+
mimeType := http.DetectContentType(contents)
+
+
if filepath.Ext(treePath) == ".svg" {
+
mimeType = "image/svg+xml"
+
}
+
+
if raw {
+
contentHash := sha256.Sum256(contents)
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
+
+
switch {
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
+
w.WriteHeader(http.StatusNotModified)
+
return
+
}
+
w.Header().Set("ETag", eTag)
+
w.Header().Set("Content-Type", mimeType)
+
+
case strings.HasPrefix(mimeType, "text/"):
+
w.Header().Set("Cache-Control", "public, no-cache")
+
// serve all text content as text/plain
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
+
case isTextualMimeType(mimeType):
+
// handle textual application types (json, xml, etc.) as text/plain
+
w.Header().Set("Cache-Control", "public, no-cache")
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
+
default:
+
x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
+
), http.StatusForbidden)
+
return
+
}
+
w.Write(contents)
+
return
+
}
+
+
isTextual := func(mt string) bool {
+
return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
+
}
+
+
var content string
+
var encoding string
+
+
isBinary := !isTextual(mimeType)
+
+
if isBinary {
+
content = base64.StdEncoding.EncodeToString(contents)
+
encoding = "base64"
+
} else {
+
content = string(contents)
+
encoding = "utf-8"
+
}
+
+
response := tangled.RepoBlob_Output{
+
Ref: ref,
+
Path: treePath,
+
Content: content,
+
Encoding: &encoding,
+
Size: &[]int64{int64(len(contents))}[0],
+
IsBinary: &isBinary,
+
}
+
+
if mimeType != "" {
+
response.MimeType = &mimeType
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+
+
// isTextualMimeType returns true if the MIME type represents textual content
+
// that should be served as text/plain for security reasons
+
func isTextualMimeType(mimeType string) bool {
+
textualTypes := []string{
+
"application/json",
+
"application/xml",
+
"application/yaml",
+
"application/x-yaml",
+
"application/toml",
+
"application/javascript",
+
"application/ecmascript",
+
}
+
+
return slices.Contains(textualTypes, mimeType)
+
}
+96
knotserver/xrpc/repo_branch.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
name := r.URL.Query().Get("name")
+
if name == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing name parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
branchName, _ := url.PathUnescape(name)
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
ref, err := gr.Branch(branchName)
+
if err != nil {
+
x.Logger.Error("getting branch", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("BranchNotFound"),
+
xrpcerr.WithMessage("branch not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
commit, err := gr.Commit(ref.Hash())
+
if err != nil {
+
x.Logger.Error("getting commit object", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("BranchNotFound"),
+
xrpcerr.WithMessage("failed to get commit object"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
defaultBranch, err := gr.FindMainBranch()
+
isDefault := false
+
if err != nil {
+
x.Logger.Error("getting default branch", "error", err.Error())
+
} else if defaultBranch == branchName {
+
isDefault = true
+
}
+
+
response := tangled.RepoBranch_Output{
+
Name: ref.Name().Short(),
+
Hash: ref.Hash().String(),
+
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
+
IsDefault: &isDefault,
+
}
+
+
if commit.Message != "" {
+
response.Message = &commit.Message
+
}
+
+
response.Author = &tangled.RepoBranch_Signature{
+
Name: commit.Author.Name,
+
Email: commit.Author.Email,
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+72
knotserver/xrpc/repo_branches.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
cursor := r.URL.Query().Get("cursor")
+
+
// limit := 50 // default
+
// if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
// if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
// limit = l
+
// }
+
// }
+
+
limit := 500
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
branches, _ := gr.Branches()
+
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) {
+
offset = o
+
}
+
}
+
+
end := offset + limit
+
if end > len(branches) {
+
end = len(branches)
+
}
+
+
paginatedBranches := branches[offset:end]
+
+
// Create response using existing types.RepoBranchesResponse
+
response := types.RepoBranchesResponse{
+
Branches: paginatedBranches,
+
}
+
+
// Write JSON response directly
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+98
knotserver/xrpc/repo_compare.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/url"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
rev1Param := r.URL.Query().Get("rev1")
+
if rev1Param == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing rev1 parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rev2Param := r.URL.Query().Get("rev2")
+
if rev2Param == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing rev2 parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rev1, _ := url.PathUnescape(rev1Param)
+
rev2, _ := url.PathUnescape(rev2Param)
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
commit1, err := gr.ResolveRevision(rev1)
+
if err != nil {
+
x.Logger.Error("error resolving revision 1", "msg", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RevisionNotFound"),
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)),
+
), http.StatusBadRequest)
+
return
+
}
+
+
commit2, err := gr.ResolveRevision(rev2)
+
if err != nil {
+
x.Logger.Error("error resolving revision 2", "msg", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RevisionNotFound"),
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
+
if err != nil {
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("CompareError"),
+
xrpcerr.WithMessage("error comparing revisions"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
resp := types.RepoFormatPatchResponse{
+
Rev1: commit1.Hash.String(),
+
Rev2: commit2.Hash.String(),
+
FormatPatch: formatPatch,
+
Patch: rawPatch,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+65
knotserver/xrpc/repo_diff.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
ref, _ := url.QueryUnescape(refParam)
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
diff, err := gr.Diff()
+
if err != nil {
+
x.Logger.Error("getting diff", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("failed to generate diff"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
resp := types.RepoCommitResponse{
+
Ref: ref,
+
Diff: diff,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+54
knotserver/xrpc/repo_get_default_branch.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, "")
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
branch, err := gr.FindMainBranch()
+
if err != nil {
+
x.Logger.Error("getting default branch", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("failed to get default branch"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.RepoGetDefaultBranch_Output{
+
Name: branch,
+
Hash: "",
+
When: "1970-01-01T00:00:00.000Z",
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+93
knotserver/xrpc/repo_languages.go
···
+
package xrpc
+
+
import (
+
"context"
+
"encoding/json"
+
"math"
+
"net/http"
+
"net/url"
+
"time"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
refParam = "HEAD" // default
+
}
+
ref, _ := url.PathUnescape(refParam)
+
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
x.Logger.Error("opening repo", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
+
defer cancel()
+
+
sizes, err := gr.AnalyzeLanguages(ctx)
+
if err != nil {
+
x.Logger.Error("failed to analyze languages", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("failed to analyze repository languages"),
+
), http.StatusNoContent)
+
return
+
}
+
+
var apiLanguages []*tangled.RepoLanguages_Language
+
var totalSize int64
+
+
for _, size := range sizes {
+
totalSize += size
+
}
+
+
for name, size := range sizes {
+
percentagef64 := float64(size) / float64(totalSize) * 100
+
percentage := math.Round(percentagef64)
+
+
lang := &tangled.RepoLanguages_Language{
+
Name: name,
+
Size: size,
+
Percentage: int64(percentage),
+
}
+
+
apiLanguages = append(apiLanguages, lang)
+
}
+
+
response := tangled.RepoLanguages_Output{
+
Ref: ref,
+
Languages: apiLanguages,
+
}
+
+
if totalSize > 0 {
+
response.TotalSize = &totalSize
+
totalFiles := int64(len(sizes))
+
response.TotalFiles = &totalFiles
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+111
knotserver/xrpc/repo_log.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
path := r.URL.Query().Get("path")
+
cursor := r.URL.Query().Get("cursor")
+
+
limit := 50 // default
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
limit = l
+
}
+
}
+
+
ref, err := url.QueryUnescape(refParam)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
+
offset = o
+
}
+
}
+
+
commits, err := gr.Commits(offset, limit)
+
if err != nil {
+
x.Logger.Error("fetching commits", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("PathNotFound"),
+
xrpcerr.WithMessage("failed to read commit log"),
+
), http.StatusNotFound)
+
return
+
}
+
+
total, err := gr.TotalCommits()
+
if err != nil {
+
x.Logger.Error("fetching total commits", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to fetch total commits"),
+
), http.StatusNotFound)
+
return
+
}
+
+
// Create response using existing types.RepoLogResponse
+
response := types.RepoLogResponse{
+
Commits: commits,
+
Ref: ref,
+
Page: (offset / limit) + 1,
+
PerPage: limit,
+
Total: total,
+
}
+
+
if path != "" {
+
response.Description = path
+
}
+
+
response.Log = true
+
+
// Write JSON response directly
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+99
knotserver/xrpc/repo_tags.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/go-git/go-git/v5/plumbing/object"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
cursor := r.URL.Query().Get("cursor")
+
+
limit := 50 // default
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
limit = l
+
}
+
}
+
+
gr, err := git.Open(repoPath, "")
+
if err != nil {
+
x.Logger.Error("failed to open", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
tags, err := gr.Tags()
+
if err != nil {
+
x.Logger.Warn("getting tags", "error", err.Error())
+
tags = []object.Tag{}
+
}
+
+
rtags := []*types.TagReference{}
+
for _, tag := range tags {
+
var target *object.Tag
+
if tag.Target != plumbing.ZeroHash {
+
target = &tag
+
}
+
tr := types.TagReference{
+
Tag: target,
+
}
+
+
tr.Reference = types.Reference{
+
Name: tag.Name,
+
Hash: tag.Hash.String(),
+
}
+
+
if tag.Message != "" {
+
tr.Message = tag.Message
+
}
+
+
rtags = append(rtags, &tr)
+
}
+
+
// apply pagination manually
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) {
+
offset = o
+
}
+
}
+
+
// calculate end index
+
end := min(offset+limit, len(rtags))
+
+
paginatedTags := rtags[offset:end]
+
+
// Create response using existing types.RepoTagsResponse
+
response := types.RepoTagsResponse{
+
Tags: paginatedTags,
+
}
+
+
// Write JSON response directly
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+116
knotserver/xrpc/repo_tree.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
"path/filepath"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
+
ctx := r.Context()
+
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
path := r.URL.Query().Get("path")
+
// path can be empty (defaults to root)
+
+
ref, err := url.QueryUnescape(refParam)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
files, err := gr.FileTree(ctx, path)
+
if err != nil {
+
x.Logger.Error("failed to get file tree", "error", err, "path", path)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("PathNotFound"),
+
xrpcerr.WithMessage("failed to read repository tree"),
+
), http.StatusNotFound)
+
return
+
}
+
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
+
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
+
for i, file := range files {
+
entry := &tangled.RepoTree_TreeEntry{
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
+
Is_file: file.IsFile,
+
Is_subtree: file.IsSubtree,
+
}
+
+
if file.LastCommit != nil {
+
entry.Last_commit = &tangled.RepoTree_LastCommit{
+
Hash: file.LastCommit.Hash.String(),
+
Message: file.LastCommit.Message,
+
When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"),
+
}
+
}
+
+
treeEntries[i] = entry
+
}
+
+
var parentPtr *string
+
if path != "" {
+
parentPtr = &path
+
}
+
+
var dotdotPtr *string
+
if path != "" {
+
dotdot := filepath.Dir(path)
+
if dotdot != "." {
+
dotdotPtr = &dotdot
+
}
+
}
+
+
response := tangled.RepoTree_Output{
+
Ref: ref,
+
Parent: parentPtr,
+
Dotdot: dotdotPtr,
+
Files: treeEntries,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+70
knotserver/xrpc/version.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"runtime/debug"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
// version is set during build time.
+
var version string
+
+
func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) {
+
if version == "" {
+
info, ok := debug.ReadBuildInfo()
+
if !ok {
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
+
return
+
}
+
+
var modVer string
+
var sha string
+
var modified bool
+
+
for _, mod := range info.Deps {
+
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
+
modVer = mod.Version
+
break
+
}
+
}
+
+
for _, setting := range info.Settings {
+
switch setting.Key {
+
case "vcs.revision":
+
sha = setting.Value
+
case "vcs.modified":
+
modified = setting.Value == "true"
+
}
+
}
+
+
if modVer == "" {
+
modVer = "unknown"
+
}
+
+
if sha == "" {
+
version = modVer
+
} else if modified {
+
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
+
} else {
+
version = fmt.Sprintf("%s (%s)", modVer, sha)
+
}
+
}
+
+
response := tangled.KnotVersion_Output{
+
Version: version,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+88
knotserver/xrpc/xrpc.go
···
"encoding/json"
"log/slog"
"net/http"
+
"net/url"
+
"strings"
+
securejoin "github.com/cyphar/filepath-securejoin"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/jetstream"
···
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
// - use ETags on clients to keep requests to a minimum
r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
+
+
// repo query endpoints (no auth required)
+
r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
+
r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
+
r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
+
r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
+
r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
+
r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
+
r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
+
r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
+
r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
+
r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
+
r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
+
+
// knot query endpoints (no auth required)
+
r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
+
r.Get("/"+tangled.KnotVersionNSID, x.Version)
+
+
// service query endpoints (no auth required)
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
+
return r
+
}
+
+
// parseRepoParam parses a repo parameter in 'did/repoName' format and returns
+
// the full repository path on disk
+
func (x *Xrpc) parseRepoParam(repo string) (string, error) {
+
if repo == "" {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing repo parameter"),
+
)
+
}
+
+
// Parse repo string (did/repoName format)
+
parts := strings.Split(repo, "/")
+
if len(parts) < 2 {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
+
)
+
}
+
+
did := strings.Join(parts[:len(parts)-1], "/")
+
repoName := parts[len(parts)-1]
+
+
// Construct repository path using the same logic as didPath
+
didRepoPath, err := securejoin.SecureJoin(did, repoName)
+
if err != nil {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("failed to access repository"),
+
)
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
+
if err != nil {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("failed to access repository"),
+
)
+
}
+
+
return repoPath, nil
+
}
+
+
// parseStandardParams parses common query parameters used by most handlers
+
func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
+
// Parse repo parameter
+
repo = r.URL.Query().Get("repo")
+
repoPath, err = x.parseRepoParam(repo)
+
if err != nil {
+
return "", "", "", err
+
}
+
+
// Parse and unescape ref parameter
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
return "", "", "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
)
+
}
+
+
ref, _ = url.QueryUnescape(refParam)
+
return repo, repoPath, ref, nil
}
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+26 -22
legal/privacy.md
···
## 2. Data Location and Hosting
-
> **EU Data Hosting**
-
>
-
> **All Tangled service data is hosted within the European Union.**
-
> Specifically:
-
>
-
> - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
-
> (*.tngl.sh) are located in Finland
-
> - **Application Data:** All other service data is stored on EU-based
-
> servers
-
> - **Data Processing:** All data processing occurs within EU
-
> jurisdiction
+
### EU Data Hosting
-
> **External PDS Notice**
-
>
-
> **Important:** 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.
+
**All Tangled service data is hosted within the European Union.**
+
Specifically:
+
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
+
(*.tngl.sh) are located in Finland
+
- **Application Data:** All other service data is stored on EU-based
+
servers
+
- **Data Processing:** All data processing occurs within EU
+
jurisdiction
+
+
### External PDS Notice
+
+
**Important:** 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.
## 3. Third-Party Data Processors
···
- **Purpose:** Sending transactional emails (account verification,
notifications)
- **Data Shared:** Email address and necessary message content
-
- **Location:** EU-compliant email delivery service
### Cloudflare (Image Caching)
- **Purpose:** Caching and optimizing image delivery
- **Data Shared:** Public images and associated metadata for caching
purposes
-
- **Location:** Global CDN with EU data protection compliance
+
+
### Posthog (Usage Metrics Tracking)
+
+
- **Purpose:** Tracking usage and platform metrics
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
+
information
## 4. How We Use Your Information
···
---
This Privacy Policy complies with the EU General Data Protection
-
Regulation (GDPR) and other applicable data protection laws.
+
Regulation (GDPR) and other applicable data protection laws.
+9 -9
lexicons/issue/comment.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": ["issue", "body", "createdAt"],
+
"required": [
+
"issue",
+
"body",
+
"createdAt"
+
],
"properties": {
"issue": {
"type": "string",
"format": "at-uri"
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"body": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "datetime"
+
},
+
"replyTo": {
+
"type": "string",
+
"format": "at-uri"
}
}
}
+73
lexicons/knot/listKeys.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot.listKeys",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "List all public keys stored in the knot server",
+
"parameters": {
+
"type": "params",
+
"properties": {
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of keys to return",
+
"minimum": 1,
+
"maximum": 1000,
+
"default": 100
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["keys"],
+
"properties": {
+
"keys": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#publicKey"
+
}
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor for next page"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InternalServerError",
+
"description": "Failed to retrieve public keys"
+
}
+
]
+
},
+
"publicKey": {
+
"type": "object",
+
"required": ["did", "key", "createdAt"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID associated with the public key"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 4096,
+
"description": "Public key contents"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Key upload timestamp"
+
}
+
}
+
}
+
}
+
}
+25
lexicons/knot/version.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot.version",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get the version of a knot",
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"version"
+
],
+
"properties": {
+
"version": {
+
"type": "string"
+
}
+
}
+
}
+
},
+
"errors": []
+
}
+
}
+
}
+31
lexicons/owner.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.owner",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get the owner of a service",
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"owner"
+
],
+
"properties": {
+
"owner": {
+
"type": "string",
+
"format": "did"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "OwnerNotFound",
+
"description": "Owner is not set for this service"
+
}
+
]
+
}
+
}
+
}
+55
lexicons/repo/archive.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.archive",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"format": {
+
"type": "string",
+
"description": "Archive format",
+
"enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"],
+
"default": "tar.gz"
+
},
+
"prefix": {
+
"type": "string",
+
"description": "Prefix for files in the archive"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*",
+
"description": "Binary archive data"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
},
+
{
+
"name": "ArchiveError",
+
"description": "Failed to create archive"
+
}
+
]
+
}
+
}
+
}
+138
lexicons/repo/blob.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.blob",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref", "path"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"path": {
+
"type": "string",
+
"description": "Path to the file within the repository"
+
},
+
"raw": {
+
"type": "boolean",
+
"description": "Return raw file content instead of JSON response",
+
"default": false
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["ref", "path", "content"],
+
"properties": {
+
"ref": {
+
"type": "string",
+
"description": "The git reference used"
+
},
+
"path": {
+
"type": "string",
+
"description": "The file path"
+
},
+
"content": {
+
"type": "string",
+
"description": "File content (base64 encoded for binary files)"
+
},
+
"encoding": {
+
"type": "string",
+
"description": "Content encoding",
+
"enum": ["utf-8", "base64"]
+
},
+
"size": {
+
"type": "integer",
+
"description": "File size in bytes"
+
},
+
"isBinary": {
+
"type": "boolean",
+
"description": "Whether the file is binary"
+
},
+
"mimeType": {
+
"type": "string",
+
"description": "MIME type of the file"
+
},
+
"lastCommit": {
+
"type": "ref",
+
"ref": "#lastCommit"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "FileNotFound",
+
"description": "File not found at the specified path"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"lastCommit": {
+
"type": "object",
+
"required": ["hash", "message", "when"],
+
"properties": {
+
"hash": {
+
"type": "string",
+
"description": "Commit hash"
+
},
+
"shortHash": {
+
"type": "string",
+
"description": "Short commit hash"
+
},
+
"message": {
+
"type": "string",
+
"description": "Commit message"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "#signature"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Commit timestamp"
+
}
+
}
+
},
+
"signature": {
+
"type": "object",
+
"required": ["name", "email", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Author name"
+
},
+
"email": {
+
"type": "string",
+
"description": "Author email"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Author timestamp"
+
}
+
}
+
}
+
}
+
}
+94
lexicons/repo/branch.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.branch",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "name"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"name": {
+
"type": "string",
+
"description": "Branch name to get information for"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["name", "hash", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Branch name"
+
},
+
"hash": {
+
"type": "string",
+
"description": "Latest commit hash on this branch"
+
},
+
"shortHash": {
+
"type": "string",
+
"description": "Short commit hash"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of latest commit"
+
},
+
"message": {
+
"type": "string",
+
"description": "Latest commit message"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "#signature"
+
},
+
"isDefault": {
+
"type": "boolean",
+
"description": "Whether this is the default branch"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "BranchNotFound",
+
"description": "Branch not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"signature": {
+
"type": "object",
+
"required": ["name", "email", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Author name"
+
},
+
"email": {
+
"type": "string",
+
"description": "Author email"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Author timestamp"
+
}
+
}
+
}
+
}
+
}
+43
lexicons/repo/branches.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.branches",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of branches to return",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
+49
lexicons/repo/compare.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.compare",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "rev1", "rev2"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"rev1": {
+
"type": "string",
+
"description": "First revision (commit, branch, or tag)"
+
},
+
"rev2": {
+
"type": "string",
+
"description": "Second revision (commit, branch, or tag)"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*",
+
"description": "Compare output in application/json"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RevisionNotFound",
+
"description": "One or both revisions not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
},
+
{
+
"name": "CompareError",
+
"description": "Failed to compare revisions"
+
}
+
]
+
}
+
}
+
}
+40
lexicons/repo/diff.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.diff",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
+82
lexicons/repo/getDefaultBranch.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.getDefaultBranch",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["name", "hash", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Default branch name"
+
},
+
"hash": {
+
"type": "string",
+
"description": "Latest commit hash on default branch"
+
},
+
"shortHash": {
+
"type": "string",
+
"description": "Short commit hash"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp of latest commit"
+
},
+
"message": {
+
"type": "string",
+
"description": "Latest commit message"
+
},
+
"author": {
+
"type": "ref",
+
"ref": "#signature"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"signature": {
+
"type": "object",
+
"required": ["name", "email", "when"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Author name"
+
},
+
"email": {
+
"type": "string",
+
"description": "Author email"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Author timestamp"
+
}
+
}
+
}
+
}
+
}
+99
lexicons/repo/languages.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.languages",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)",
+
"default": "HEAD"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["ref", "languages"],
+
"properties": {
+
"ref": {
+
"type": "string",
+
"description": "The git reference used"
+
},
+
"languages": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#language"
+
}
+
},
+
"totalSize": {
+
"type": "integer",
+
"description": "Total size of all analyzed files in bytes"
+
},
+
"totalFiles": {
+
"type": "integer",
+
"description": "Total number of files analyzed"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"language": {
+
"type": "object",
+
"required": ["name", "size", "percentage"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Programming language name"
+
},
+
"size": {
+
"type": "integer",
+
"description": "Total size of files in this language (bytes)"
+
},
+
"percentage": {
+
"type": "integer",
+
"description": "Percentage of total codebase (0-100)"
+
},
+
"fileCount": {
+
"type": "integer",
+
"description": "Number of files in this language"
+
},
+
"color": {
+
"type": "string",
+
"description": "Hex color code for this language"
+
},
+
"extensions": {
+
"type": "array",
+
"items": {
+
"type": "string"
+
},
+
"description": "File extensions associated with this language"
+
}
+
}
+
}
+
}
+
}
+60
lexicons/repo/log.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.log",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"path": {
+
"type": "string",
+
"description": "Path to filter commits by",
+
"default": ""
+
},
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of commits to return",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor (commit SHA)"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "PathNotFound",
+
"description": "Path not found in repository"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
-1
lexicons/repo/repo.json
···
},
"description": {
"type": "string",
-
"format": "datetime",
"minGraphemes": 1,
"maxGraphemes": 140
},
+43
lexicons/repo/tags.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.tags",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"limit": {
+
"type": "integer",
+
"description": "Maximum number of tags to return",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50
+
},
+
"cursor": {
+
"type": "string",
+
"description": "Pagination cursor"
+
}
+
}
+
},
+
"output": {
+
"encoding": "*/*"
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
}
+
}
+
}
+123
lexicons/repo/tree.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.tree",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": ["repo", "ref"],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
+
},
+
"ref": {
+
"type": "string",
+
"description": "Git reference (branch, tag, or commit SHA)"
+
},
+
"path": {
+
"type": "string",
+
"description": "Path within the repository tree",
+
"default": ""
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["ref", "files"],
+
"properties": {
+
"ref": {
+
"type": "string",
+
"description": "The git reference used"
+
},
+
"parent": {
+
"type": "string",
+
"description": "The parent path in the tree"
+
},
+
"dotdot": {
+
"type": "string",
+
"description": "Parent directory path"
+
},
+
"files": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#treeEntry"
+
}
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "RepoNotFound",
+
"description": "Repository not found or access denied"
+
},
+
{
+
"name": "RefNotFound",
+
"description": "Git reference not found"
+
},
+
{
+
"name": "PathNotFound",
+
"description": "Path not found in repository tree"
+
},
+
{
+
"name": "InvalidRequest",
+
"description": "Invalid request parameters"
+
}
+
]
+
},
+
"treeEntry": {
+
"type": "object",
+
"required": ["name", "mode", "size", "is_file", "is_subtree"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "Relative file or directory name"
+
},
+
"mode": {
+
"type": "string",
+
"description": "File mode"
+
},
+
"size": {
+
"type": "integer",
+
"description": "File size in bytes"
+
},
+
"is_file": {
+
"type": "boolean",
+
"description": "Whether this entry is a file"
+
},
+
"is_subtree": {
+
"type": "boolean",
+
"description": "Whether this entry is a directory/subtree"
+
},
+
"last_commit": {
+
"type": "ref",
+
"ref": "#lastCommit"
+
}
+
}
+
},
+
"lastCommit": {
+
"type": "object",
+
"required": ["hash", "message", "when"],
+
"properties": {
+
"hash": {
+
"type": "string",
+
"description": "Commit hash"
+
},
+
"message": {
+
"type": "string",
+
"description": "Commit message"
+
},
+
"when": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Commit timestamp"
+
}
+
}
+
}
+
}
+
}
+8 -2
nix/gomod2nix.toml
···
[mod."github.com/whyrusleeping/cbor-gen"]
version = "v0.3.1"
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
+
[mod."github.com/wyatt915/goldmark-treeblood"]
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
+
[mod."github.com/wyatt915/treeblood"]
+
version = "v0.1.15"
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
[mod."github.com/yuin/goldmark"]
-
version = "v1.4.15"
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
+
version = "v1.7.12"
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
[mod."github.com/yuin/goldmark-highlighting/v2"]
version = "v2.0.0-20230729083705-37449abec8cc"
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+17 -12
nix/pkgs/knot-unwrapped.nix
···
modules,
sqlite-lib,
src,
-
}:
-
buildGoApplication {
-
pname = "knot";
-
version = "0.1.0";
-
inherit src modules;
+
}: let
+
version = "1.9.0-alpha";
+
in
+
buildGoApplication {
+
pname = "knot";
+
inherit src version modules;
+
+
doCheck = false;
-
doCheck = false;
+
subPackages = ["cmd/knot"];
+
tags = ["libsqlite3"];
-
subPackages = ["cmd/knot"];
-
tags = ["libsqlite3"];
+
ldflags = [
+
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
+
];
-
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
-
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
-
CGO_ENABLED = 1;
-
}
+
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
+
CGO_ENABLED = 1;
+
}
-3
spindle/server.go
···
w.Write(motd)
})
mux.HandleFunc("/events", s.Events)
-
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
-
w.Write([]byte(s.cfg.Server.Owner))
-
})
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
mux.Mount("/xrpc", s.XrpcRouter())
+31
spindle/xrpc/owner.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+
owner := x.Config.Server.Owner
+
if owner == "" {
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.Owner_Output{
+
Owner: owner,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+10 -3
spindle/xrpc/xrpc.go
···
func (x *Xrpc) Router() http.Handler {
r := chi.NewRouter()
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
-
r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
+
r.Group(func(r chi.Router) {
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
+
+
r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
+
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
+
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
+
})
+
+
// service query endpoints (no auth required)
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
return r
}
+5
xrpc/errors/errors.go
···
WithMessage("actor DID not supplied"),
)
+
var OwnerNotFoundError = NewXrpcError(
+
WithTag("OwnerNotFound"),
+
WithMessage("owner not set for this service"),
+
)
+
var AuthError = func(err error) XrpcError {
return NewXrpcError(
WithTag("Auth"),