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

Compare changes

Choose any two refs to compare.

Changed files
+8399 -4996
api
appview
db
issues
knots
middleware
oauth
handler
pages
posthog
pulls
repo
serververify
spindles
state
strings
validator
xrpcclient
cmd
appview
docs
knotclient
knotserver
legal
lexicons
nix
patchutil
spindle
xrpc
errors
+17 -228
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 5
if t.Body == nil {
fieldCount--
···
return err
-
// t.Owner (string) (string)
-
if len("owner") > 1000000 {
-
return xerrors.Errorf("Value in field \"owner\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("owner")); err != nil {
-
return err
-
}
-
-
if len(t.Owner) > 1000000 {
-
return xerrors.Errorf("Value in field t.Owner was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.Owner)); err != nil {
-
return err
-
}
-
// t.Title (string) (string)
if len("title") > 1000000 {
return xerrors.Errorf("Value in field \"title\" was too long")
···
return err
-
// t.IssueId (int64) (int64)
-
if len("issueId") > 1000000 {
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("issueId")); err != nil {
-
return err
-
}
-
-
if t.IssueId >= 0 {
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
-
return err
-
}
-
} else {
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
-
return err
-
}
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
t.LexiconTypeID = string(sval)
-
// t.Owner (string) (string)
-
case "owner":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Owner = string(sval)
-
}
// t.Title (string) (string)
case "title":
···
t.Title = string(sval)
-
// t.IssueId (int64) (int64)
-
case "issueId":
-
{
-
maj, extra, err := cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
var extraI int64
-
switch maj {
-
case cbg.MajUnsignedInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 positive overflow")
-
}
-
case cbg.MajNegativeInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 negative overflow")
-
}
-
extraI = -1 - extraI
-
default:
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
-
}
-
-
t.IssueId = int64(extraI)
-
}
// t.CreatedAt (string) (string)
case "createdAt":
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 5
-
if t.CommentId == nil {
-
fieldCount--
-
}
-
-
if t.Owner == nil {
-
fieldCount--
-
}
-
-
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.CommentId (int64) (int64)
-
if t.CommentId != nil {
-
-
if len("commentId") > 1000000 {
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("commentId")); err != nil {
-
return err
-
}
-
-
if t.CommentId == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if *t.CommentId >= 0 {
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
-
return err
-
}
-
} else {
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
-
return err
-
}
-
}
-
}
-
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
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.CommentId (int64) (int64)
-
case "commentId":
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
maj, extra, err := cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
var extraI int64
-
switch maj {
-
case cbg.MajUnsignedInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 positive overflow")
-
}
-
case cbg.MajNegativeInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 negative overflow")
-
}
-
extraI = -1 - extraI
-
default:
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
-
}
-
-
t.CommentId = (*int64)(&extraI)
+
t.ReplyTo = (*string)(&sval)
// t.CreatedAt (string) (string)
+1 -3
api/tangled/issuecomment.go
···
type RepoIssueComment struct {
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
Body string `json:"body" cborgen:"body"`
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
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
+
}
-2
api/tangled/repoissue.go
···
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
-
Owner string `json:"owner" cborgen:"owner"`
Repo string `json:"repo" cborgen:"repo"`
Title string `json:"title" cborgen:"title"`
}
+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 {
+4 -4
appview/db/follow.go
···
}
type FollowStats struct {
-
Followers int
-
Following int
+
Followers int64
+
Following int64
}
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
-
followers, following := 0, 0
+
var followers, following int64
err := e.QueryRow(
`SELECT
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
···
for rows.Next() {
var did string
-
var followers, following int
+
var followers, following int64
if err := rows.Scan(&did, &followers, &following); err != nil {
return nil, err
}
+430 -368
appview/db/issues.go
···
import (
"database/sql"
"fmt"
+
"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 NewIssue(tx *sql.Tx, issue *Issue) error {
-
defer tx.Rollback()
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
-
_, err := tx.Exec(`
-
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
-
values (?, 1)
-
`, issue.RepoAt)
-
if err != nil {
-
return err
+
// 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)
+
}
}
-
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 {
-
return err
+
for _, r := range replies {
+
parentAt := *r.ReplyTo
+
if parent, exists := toplevel[parentAt]; exists {
+
parent.Replies = append(parent.Replies, r)
+
}
}
-
issue.IssueId = nextId
+
var listing []CommentListItem
+
for _, v := range toplevel {
+
listing = append(listing, *v)
+
}
-
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
+
// 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])
+
})
}
-
lastID, err := res.LastInsertId()
+
return listing
+
}
+
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
if err != nil {
-
return err
+
created = time.Now()
}
-
issue.ID = lastID
-
if err := tx.Commit(); err != nil {
-
return err
+
body := ""
+
if record.Body != nil {
+
body = *record.Body
}
-
return nil
+
return Issue{
+
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 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
+
type IssueComment struct {
+
Id int64
+
Did string
+
Rkey string
+
IssueAt string
+
ReplyTo *string
+
Body string
+
Created time.Time
+
Edited *time.Time
+
Deleted *time.Time
}
-
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 (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
}
-
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
-
var issues []Issue
-
openValue := 0
-
if isOpen {
-
openValue = 1
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
}
+
}
-
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)
+
func (i *IssueComment) IsTopLevel() bool {
+
return i.ReplyTo == nil
+
}
+
+
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 _, err = syntax.ParseATURI(record.Issue); 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
-
}
+
comment := IssueComment{
+
Did: ownerDid,
+
Rkey: rkey,
+
Body: record.Body,
+
IssueAt: record.Issue,
+
ReplyTo: record.ReplyTo,
+
Created: created,
+
}
+
+
return &comment, nil
+
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
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)
+
if err != nil {
+
return err
+
}
+
+
issues, err := GetIssues(
+
tx,
+
FilterEq("did", issue.Did),
+
FilterEq("rkey", issue.Rkey),
+
)
+
switch {
+
case err != nil:
+
return err
+
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
}
-
issue.Created = createdTime
-
issue.Metadata = &metadata
-
issues = append(issues, issue)
+
issue.Id = existingIssue.Id
+
issue.IssueId = existingIssue.IssueId
+
return updateIssue(tx, issue)
}
+
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
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
}
-
return issues, nil
+
// 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 row.Scan(&issue.Id, &issue.IssueId)
+
}
+
+
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)
}
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
-
if err != nil {
-
return nil, err
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
issue.Created = t
}
-
issue.Created = issueCreatedTime
-
issues = append(issues, issue)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
+
if editedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
+
issue.Edited = &t
+
}
+
}
-
return issues, nil
-
}
+
if deletedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
+
issue.Deleted = &t
+
}
+
}
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
-
return GetIssuesWithLimit(e, 0, filters...)
-
}
+
atUri := issue.AtUri().String()
+
issueMap[atUri] = &issue
+
}
-
// 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
+
// collect reverse repos
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
+
for _, issue := range issueMap {
+
repoAts = append(repoAts, string(issue.RepoAt))
+
}
-
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)
+
var issues []Issue
+
for _, i := range issueMap {
+
issues = append(issues, *i)
}
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
+
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) {
···
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
-
comments, err := GetComments(e, repoAt, issueId)
-
if err != nil {
-
return nil, nil, err
+
return id, nil
+
}
+
+
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()...)
+
}
-
rows, err := e.Query(`
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
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 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
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
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
}
+23 -5
appview/db/profile.go
···
ByMonth []ByMonth
}
+
func (p *ProfileTimeline) IsEmpty() bool {
+
if p == nil {
+
return true
+
}
+
+
for _, m := range p.ByMonth {
+
if !m.IsEmpty() {
+
return false
+
}
+
}
+
+
return true
+
}
+
type ByMonth struct {
RepoEvents []RepoEvent
IssueEvents IssueEvents
···
*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)
}
+4 -4
appview/db/punchcard.go
···
Punches []Punch
}
-
func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) {
-
punchcard := Punchcard{}
+
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
+
punchcard := &Punchcard{}
now := time.Now()
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
···
rows, err := e.Query(query, args...)
if err != nil {
-
return punchcard, err
+
return nil, err
}
defer rows.Close()
···
var date string
var count sql.NullInt64
if err := rows.Scan(&date, &count); err != nil {
-
return punchcard, err
+
return nil, err
}
punch.Date, err = time.Parse(time.DateOnly, date)
+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 ")
}
+19 -125
appview/db/repos.go
···
import (
"database/sql"
+
"errors"
"fmt"
"log"
"slices"
···
func (r Repo) DidSlashRepo() string {
p, _ := securejoin.SecureJoin(r.Did, r.Name)
return p
-
}
-
-
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
-
var repos []Repo
-
-
rows, err := e.Query(
-
`select did, name, knot, rkey, description, created, source
-
from repos
-
order by created desc
-
limit ?
-
`,
-
limit,
-
)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var repo Repo
-
err := scanRepo(
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
-
)
-
if err != nil {
-
return nil, err
-
}
-
repos = append(repos, repo)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return repos, nil
}
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
···
slices.SortFunc(repos, func(a, b Repo) int {
if a.Created.After(b.Created) {
-
return 1
+
return -1
}
-
return -1
+
return 1
})
return repos, 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
+
func CountRepos(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
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
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
repo.RepoStats = &repoStats
+
repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause)
+
var count int64
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
-
repos = append(repos, repo)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
}
-
return repos, nil
+
return count, nil
}
func GetRepo(e Execer, did, name string) (*Repo, error) {
···
IssueCount IssueCount
PullCount PullCount
}
-
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
-
var createdAt string
-
var nullableDescription sql.NullString
-
var nullableSource sql.NullString
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
-
return err
-
}
-
-
if nullableDescription.Valid {
-
*description = nullableDescription.String
-
} else {
-
*description = ""
-
}
-
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
*created = time.Now()
-
} else {
-
*created = createdAtTime
-
}
-
-
if nullableSource.Valid {
-
*source = nullableSource.String
-
} else {
-
*source = ""
-
}
-
-
return nil
-
}
+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 {
+26
appview/db/star.go
···
package db
import (
+
"database/sql"
+
"errors"
"fmt"
"log"
"strings"
···
}
return stars, nil
+
}
+
+
func CountStars(e Execer, filters ...filter) (int64, error) {
+
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 ")
+
}
+
+
repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause)
+
var count int64
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
+
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
+
}
+
+
return count, nil
}
func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
···
return all, nil
}
+
func CountStrings(e Execer, filters ...filter) (int64, error) {
+
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 ")
+
}
+
+
repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause)
+
var count int64
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
+
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
+
}
+
+
return count, nil
+
}
+
func DeleteString(e Execer, filters ...filter) error {
var conditions []string
var args []any
+12 -14
appview/db/timeline.go
···
*FollowStats
}
-
const Limit = 50
-
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
-
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
+
func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) {
var events []TimelineEvent
-
repos, err := getTimelineRepos(e)
+
repos, err := getTimelineRepos(e, limit)
if err != nil {
return nil, err
}
-
stars, err := getTimelineStars(e)
+
stars, err := getTimelineStars(e, limit)
if err != nil {
return nil, err
}
-
follows, err := getTimelineFollows(e)
+
follows, err := getTimelineFollows(e, limit)
if err != nil {
return nil, err
}
···
})
// Limit the slice to 100 events
-
if len(events) > Limit {
-
events = events[:Limit]
+
if len(events) > limit {
+
events = events[:limit]
}
return events, nil
}
-
func getTimelineRepos(e Execer) ([]TimelineEvent, error) {
-
repos, err := GetRepos(e, Limit)
+
func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) {
+
repos, err := GetRepos(e, limit)
if err != nil {
return nil, err
}
···
return events, nil
}
-
func getTimelineStars(e Execer) ([]TimelineEvent, error) {
-
stars, err := GetStars(e, Limit)
+
func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) {
+
stars, err := GetStars(e, limit)
if err != nil {
return nil, err
}
···
return events, nil
}
-
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
-
follows, err := GetFollows(e, Limit)
+
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
+
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
}
+133 -6
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
+
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
"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
···
case tangled.ActorProfileNSID:
err = i.ingestProfile(e)
case tangled.SpindleMemberNSID:
-
err = i.ingestSpindleMember(e)
+
err = i.ingestSpindleMember(ctx, e)
case tangled.SpindleNSID:
-
err = i.ingestSpindle(e)
+
err = i.ingestSpindle(ctx, e)
case tangled.KnotMemberNSID:
err = i.ingestKnotMember(e)
case tangled.KnotNSID:
err = i.ingestKnot(e)
case tangled.StringNSID:
err = i.ingestString(e)
+
case tangled.RepoIssueNSID:
+
err = i.ingestIssue(ctx, e)
+
case tangled.RepoIssueCommentNSID:
+
err = i.ingestIssueComment(e)
}
l = i.Logger.With("nsid", e.Commit.Collection)
}
···
return nil
}
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
did := e.Did
var err error
···
return fmt.Errorf("failed to enforce permissions: %w", err)
}
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
if err != nil {
return err
}
···
return nil
}
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
did := e.Did
var err error
···
return err
}
-
err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
+
err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
if err != nil {
l.Error("failed to add spindle to db", "err", err, "instance", instance)
return err
···
return nil
}
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
+
did := e.Did
+
rkey := e.Commit.RKey
+
+
var err error
+
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
+
l.Info("ingesting record")
+
+
ddb, ok := i.Db.Execer.(*db.DB)
+
if !ok {
+
return fmt.Errorf("failed to index issue record, invalid db cast")
+
}
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.RepoIssue{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
issue := db.IssueFromRecord(did, rkey, record)
+
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
+
return fmt.Errorf("failed to validate issue: %w", err)
+
}
+
+
tx, err := ddb.BeginTx(ctx, nil)
+
if err != nil {
+
l.Error("failed to begin transaction", "err", err)
+
return err
+
}
+
defer tx.Rollback()
+
+
err = db.PutIssue(tx, &issue)
+
if err != nil {
+
l.Error("failed to create issue", "err", err)
+
return err
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
l.Error("failed to commit txn", "err", err)
+
return err
+
}
+
+
return nil
+
+
case models.CommitOperationDelete:
+
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 nil
+
}
+
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
+
did := e.Did
+
rkey := e.Commit.RKey
+
+
var err error
+
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
+
l.Info("ingesting record")
+
+
ddb, ok := i.Db.Execer.(*db.DB)
+
if !ok {
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
+
}
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.RepoIssueComment{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
return fmt.Errorf("invalid record: %w", err)
+
}
+
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
+
if err != nil {
+
return fmt.Errorf("failed to parse comment from record: %w", err)
+
}
+
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
+
return fmt.Errorf("failed to validate comment: %w", err)
+
}
+
+
_, err = db.AddIssueComment(ddb, *comment)
+
if err != nil {
+
return fmt.Errorf("failed to create issue comment: %w", err)
+
}
+
+
return nil
+
+
case models.CommitOperationDelete:
+
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 nil
+
}
+477 -286
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
+
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
+
}
-
// TODO: make this more granular
-
if isIssueOwner || isCollaborator {
-
-
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.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
+
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
+
}
+
+
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)
-
commentIdInt64 := int64(commentId)
-
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,
-
CommentId: &commentIdInt64,
-
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)
-
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)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
-
if err != nil {
-
http.Error(w, "bad comment 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
}
-
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
+
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.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)
-
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)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
-
if err != nil {
-
http.Error(w, "bad comment 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
}
-
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
+
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 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
+
+
now := time.Now()
+
newComment := comment
+
newComment.Body = newBody
+
newComment.Edited = &now
+
record := newComment.AsRecord()
-
// optimistic update
-
edited := time.Now()
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
_, 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)
-
commentIdInt64 := int64(commentIdInt)
_, 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,
-
CommentId: &commentIdInt64,
-
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
+
}
+
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
http.Error(w, "bad issue 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
}
+
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]
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
+
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 {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
-
if err != nil {
-
http.Error(w, "bad comment 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
}
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
+
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,
-
Owner: user.Did,
-
IssueId: int64(issue.IssueId),
-
},
-
},
-
})
-
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 {
+5
appview/oauth/handler/handler.go
···
return
}
+
if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil {
+
log.Printf("failed to set up enforcer rules: %s", err)
+
return
+
}
+
log.Printf("successfully added %s to default Knot", did)
}
+35
appview/pages/cache.go
···
+
package pages
+
+
import (
+
"sync"
+
)
+
+
type TmplCache[K comparable, V any] struct {
+
data map[K]V
+
mutex sync.RWMutex
+
}
+
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
+
return &TmplCache[K, V]{
+
data: make(map[K]V),
+
}
+
}
+
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
+
c.mutex.RLock()
+
defer c.mutex.RUnlock()
+
val, exists := c.data[key]
+
return val, exists
+
}
+
+
func (c *TmplCache[K, V]) Set(key K, value V) {
+
c.mutex.Lock()
+
defer c.mutex.Unlock()
+
c.data[key] = value
+
}
+
+
func (c *TmplCache[K, V]) Size() int {
+
c.mutex.RLock()
+
defer c.mutex.RUnlock()
+
return len(c.data)
+
}
+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
+270 -190
appview/pages/pages.go
···
"html/template"
"io"
"io/fs"
-
"log"
+
"log/slog"
"net/http"
"os"
"path/filepath"
···
var Files embed.FS
type Pages struct {
-
mu sync.RWMutex
-
t map[string]*template.Template
+
mu sync.RWMutex
+
cache *TmplCache[string, *template.Template]
avatar config.AvatarConfig
resolver *idresolver.Resolver
dev bool
-
embedFS embed.FS
+
embedFS fs.FS
templateDir string // Path to templates on disk for dev mode
rctx *markup.RenderContext
+
logger *slog.Logger
}
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
···
p := &Pages{
mu: sync.RWMutex{},
-
t: make(map[string]*template.Template),
+
cache: NewTmplCache[string, *template.Template](),
dev: config.Core.Dev,
avatar: config.Avatar,
-
embedFS: Files,
rctx: rctx,
resolver: res,
templateDir: "appview/pages",
+
logger: slog.Default().With("component", "pages"),
}
-
// Initial load of all templates
-
p.loadAllTemplates()
+
if p.dev {
+
p.embedFS = os.DirFS(p.templateDir)
+
} else {
+
p.embedFS = Files
+
}
return p
}
-
func (p *Pages) loadAllTemplates() {
-
templates := make(map[string]*template.Template)
-
var fragmentPaths []string
+
func (p *Pages) pathToName(s string) string {
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
+
}
-
// Use embedded FS for initial loading
-
// First, collect all fragment paths
+
// reverse of pathToName
+
func (p *Pages) nameToPath(s string) string {
+
return "templates/" + s + ".html"
+
}
+
+
func (p *Pages) fragmentPaths() ([]string, error) {
+
var fragmentPaths []string
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
···
if !strings.Contains(path, "fragments/") {
return nil
}
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
-
tmpl, err := template.New(name).
-
Funcs(p.funcMap()).
-
ParseFS(p.embedFS, path)
-
if err != nil {
-
log.Fatalf("setting up fragment: %v", err)
-
}
-
templates[name] = tmpl
fragmentPaths = append(fragmentPaths, path)
-
log.Printf("loaded fragment: %s", name)
return nil
})
if err != nil {
-
log.Fatalf("walking template dir for fragments: %v", err)
+
return nil, err
}
-
// Then walk through and setup the rest of the templates
-
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
-
if err != nil {
-
return err
-
}
-
if d.IsDir() {
-
return nil
-
}
-
if !strings.HasSuffix(path, "html") {
-
return nil
-
}
-
// Skip fragments as they've already been loaded
-
if strings.Contains(path, "fragments/") {
-
return nil
-
}
-
// Skip layouts
-
if strings.Contains(path, "layouts/") {
-
return nil
-
}
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
-
// Add the page template on top of the base
-
allPaths := []string{}
-
allPaths = append(allPaths, "templates/layouts/*.html")
-
allPaths = append(allPaths, fragmentPaths...)
-
allPaths = append(allPaths, path)
-
tmpl, err := template.New(name).
-
Funcs(p.funcMap()).
-
ParseFS(p.embedFS, allPaths...)
-
if err != nil {
-
return fmt.Errorf("setting up template: %w", err)
-
}
-
templates[name] = tmpl
-
log.Printf("loaded template: %s", name)
-
return nil
-
})
+
return fragmentPaths, nil
+
}
+
+
// parse without memoization
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
+
paths, err := p.fragmentPaths()
if err != nil {
-
log.Fatalf("walking template dir: %v", err)
+
return nil, err
}
-
-
log.Printf("total templates loaded: %d", len(templates))
-
p.mu.Lock()
-
defer p.mu.Unlock()
-
p.t = templates
-
}
-
-
// loadTemplateFromDisk loads a template from the filesystem in dev mode
-
func (p *Pages) loadTemplateFromDisk(name string) error {
-
if !p.dev {
-
return nil
+
for _, s := range stack {
+
paths = append(paths, p.nameToPath(s))
}
-
log.Printf("reloading template from disk: %s", name)
-
-
// Find all fragments first
-
var fragmentPaths []string
-
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
-
if err != nil {
-
return err
-
}
-
if d.IsDir() {
-
return nil
-
}
-
if !strings.HasSuffix(path, ".html") {
-
return nil
-
}
-
if !strings.Contains(path, "fragments/") {
-
return nil
-
}
-
fragmentPaths = append(fragmentPaths, path)
-
return nil
-
})
+
funcs := p.funcMap()
+
top := stack[len(stack)-1]
+
parsed, err := template.New(top).
+
Funcs(funcs).
+
ParseFS(p.embedFS, paths...)
if err != nil {
-
return fmt.Errorf("walking disk template dir for fragments: %w", err)
+
return nil, err
}
-
// Find the template path on disk
-
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
-
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
-
return fmt.Errorf("template not found on disk: %s", name)
-
}
+
return parsed, nil
+
}
-
// Create a new template
-
tmpl := template.New(name).Funcs(p.funcMap())
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
+
key := strings.Join(stack, "|")
-
// Parse layouts
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
-
layouts, err := filepath.Glob(layoutGlob)
+
// never cache in dev mode
+
if cached, exists := p.cache.Get(key); !p.dev && exists {
+
return cached, nil
+
}
+
+
result, err := p.rawParse(stack...)
if err != nil {
-
return fmt.Errorf("finding layout templates: %w", err)
+
return nil, err
}
-
// Create paths for parsing
-
allFiles := append(layouts, fragmentPaths...)
-
allFiles = append(allFiles, templatePath)
+
p.cache.Set(key, result)
+
return result, nil
+
}
-
// Parse all templates
-
tmpl, err = tmpl.ParseFiles(allFiles...)
-
if err != nil {
-
return fmt.Errorf("parsing template files: %w", err)
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
top,
}
+
return p.parse(stack...)
+
}
-
// Update the template in the map
-
p.mu.Lock()
-
defer p.mu.Unlock()
-
p.t[name] = tmpl
-
log.Printf("template reloaded from disk: %s", name)
-
return nil
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/repobase",
+
top,
+
}
+
return p.parse(stack...)
}
-
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
-
// In dev mode, reload the template from disk before executing
-
if p.dev {
-
if err := p.loadTemplateFromDisk(templateName); err != nil {
-
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
-
// Continue with the existing template
-
}
+
func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/profilebase",
+
top,
}
+
return p.parse(stack...)
+
}
-
p.mu.RLock()
-
defer p.mu.RUnlock()
-
tmpl, exists := p.t[templateName]
-
if !exists {
-
return fmt.Errorf("template not found: %s", templateName)
+
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
+
tpl, err := p.parse(name)
+
if err != nil {
+
return err
}
-
if base == "" {
-
return tmpl.Execute(w, params)
-
} else {
-
return tmpl.ExecuteTemplate(w, base, params)
-
}
+
return tpl.Execute(w, params)
}
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "layouts/base", params)
-
}
+
tpl, err := p.parseBase(name)
+
if err != nil {
+
return err
+
}
-
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "", params)
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
}
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "layouts/repobase", params)
+
tpl, err := p.parseRepoBase(name)
+
if err != nil {
+
return err
+
}
+
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
+
}
+
+
func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
+
tpl, err := p.parseProfileBase(name)
+
if err != nil {
+
return err
+
}
+
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
}
func (p *Pages) Favicon(w io.Writer) error {
···
type TermsOfServiceParams struct {
LoggedInUser *oauth.User
+
Content template.HTML
}
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
+
filename := "terms.md"
+
filePath := filepath.Join("legal", filename)
+
markdownBytes, err := os.ReadFile(filePath)
+
if err != nil {
+
return fmt.Errorf("failed to read %s: %w", filename, err)
+
}
+
+
p.rctx.RendererType = markup.RendererTypeDefault
+
htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.Content = template.HTML(sanitized)
+
return p.execute("legal/terms", w, params)
}
type PrivacyPolicyParams struct {
LoggedInUser *oauth.User
+
Content template.HTML
}
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
+
filename := "privacy.md"
+
filePath := filepath.Join("legal", filename)
+
markdownBytes, err := os.ReadFile(filePath)
+
if err != nil {
+
return fmt.Errorf("failed to read %s: %w", filename, err)
+
}
+
+
p.rctx.RendererType = markup.RendererTypeDefault
+
htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.Content = template.HTML(sanitized)
+
return p.execute("legal/privacy", w, params)
}
···
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 {
···
return p.execute("repo/fork", w, params)
}
-
type ProfileHomePageParams struct {
+
type ProfileCard struct {
+
UserDid string
+
UserHandle string
+
FollowStatus db.FollowStatus
+
Punchcard *db.Punchcard
+
Profile *db.Profile
+
Stats ProfileStats
+
Active string
+
}
+
+
type ProfileStats struct {
+
RepoCount int64
+
StarredCount int64
+
StringCount int64
+
FollowersCount int64
+
FollowingCount int64
+
}
+
+
func (p *ProfileCard) GetTabs() [][]any {
+
tabs := [][]any{
+
{"overview", "overview", "square-chart-gantt", nil},
+
{"repos", "repos", "book-marked", p.Stats.RepoCount},
+
{"starred", "starred", "star", p.Stats.StarredCount},
+
{"strings", "strings", "line-squiggle", p.Stats.StringCount},
+
}
+
+
return tabs
+
}
+
+
type ProfileOverviewParams struct {
LoggedInUser *oauth.User
Repos []db.Repo
CollaboratingRepos []db.Repo
ProfileTimeline *db.ProfileTimeline
-
Card ProfileCard
-
Punchcard db.Punchcard
+
Card *ProfileCard
+
Active string
}
-
type ProfileCard struct {
-
UserDid string
-
UserHandle string
-
FollowStatus db.FollowStatus
-
FollowersCount int
-
FollowingCount int
+
func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/overview", w, params)
+
}
-
Profile *db.Profile
+
type ProfileReposParams struct {
+
LoggedInUser *oauth.User
+
Repos []db.Repo
+
Card *ProfileCard
+
Active string
}
-
func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error {
-
return p.execute("user/profile", w, params)
+
func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
+
params.Active = "repos"
+
return p.executeProfile("user/repos", w, params)
}
-
type ReposPageParams struct {
+
type ProfileStarredParams struct {
LoggedInUser *oauth.User
Repos []db.Repo
-
Card ProfileCard
+
Card *ProfileCard
+
Active string
}
-
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
-
return p.execute("user/repos", w, params)
+
func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
+
params.Active = "starred"
+
return p.executeProfile("user/starred", w, params)
+
}
+
+
type ProfileStringsParams struct {
+
LoggedInUser *oauth.User
+
Strings []db.String
+
Card *ProfileCard
+
Active string
+
}
+
+
func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
+
params.Active = "strings"
+
return p.executeProfile("user/strings", w, params)
}
type FollowCard struct {
UserDid string
FollowStatus db.FollowStatus
-
FollowersCount int
-
FollowingCount int
+
FollowersCount int64
+
FollowingCount int64
Profile *db.Profile
}
-
type FollowersPageParams struct {
+
type ProfileFollowersParams struct {
LoggedInUser *oauth.User
Followers []FollowCard
-
Card ProfileCard
+
Card *ProfileCard
+
Active string
}
-
func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error {
-
return p.execute("user/followers", w, params)
+
func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/followers", w, params)
}
-
type FollowingPageParams struct {
+
type ProfileFollowingParams struct {
LoggedInUser *oauth.User
Following []FollowCard
-
Card ProfileCard
+
Card *ProfileCard
+
Active string
}
-
func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error {
-
return p.execute("user/following", w, params)
+
func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/following", w, params)
}
type FollowFragmentParams struct {
···
VerifiedCommits commitverify.VerifiedCommits
Languages []types.RepoLanguageDetails
Pipelines map[string]db.Pipeline
+
NeedsKnotUpgrade bool
types.RepoIndexResponse
}
···
return p.executeRepo("repo/empty", w, params)
}
+
if params.NeedsKnotUpgrade {
+
return p.executeRepo("repo/needsUpgrade", w, params)
+
}
+
p.rctx.RepoInfo = params.RepoInfo
p.rctx.RepoInfo.Ref = params.Ref
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
···
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
params.Active = "overview"
-
return p.execute("repo/tree", w, params)
+
return p.executeRepo("repo/tree", w, params)
}
type RepoBranchesParams struct {
···
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.execute("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 {
···
return p.execute("strings/string", w, params)
+
func (p *Pages) Home(w io.Writer, params TimelineParams) error {
+
return p.execute("timeline/home", w, params)
+
}
+
func (p *Pages) Static() http.Handler {
if p.dev {
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
···
sub, err := fs.Sub(Files, "static")
if err != nil {
-
log.Fatalf("no static dir found? that's crazy: %v", err)
+
p.logger.Error("no static dir found? that's crazy", "err", err)
+
panic(err)
// Custom handler to apply Cache-Control headers for font files
return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
···
func CssContentHash() string {
cssFile, err := Files.Open("static/tw.css")
if err != nil {
-
log.Printf("Error opening CSS file: %v", err)
+
slog.Debug("Error opening CSS file", "err", err)
return ""
defer cssFile.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, cssFile); err != nil {
-
log.Printf("Error hashing CSS file: %v", err)
+
slog.Debug("Error hashing CSS file", "err", err)
return ""
+2 -7
appview/pages/repoinfo/repoinfo.go
···
func (r RepoInfo) TabMetadata() map[string]any {
meta := make(map[string]any)
-
if r.Stats.PullCount.Open > 0 {
-
meta["pulls"] = r.Stats.PullCount.Open
-
}
-
-
if r.Stats.IssueCount.Open > 0 {
-
meta["issues"] = r.Stats.IssueCount.Open
-
}
+
meta["pulls"] = r.Stats.PullCount.Open
+
meta["issues"] = r.Stats.IssueCount.Open
// more stuff?
+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 }}
+1 -1
appview/pages/templates/errors/404.html
···
The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
-
<a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
+
<a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2">
{{ i "arrow-left" "w-4 h-4" }}
go back
</a>
+4 -4
appview/pages/templates/errors/500.html
···
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
</div>
</div>
-
+
<div class="space-y-4">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
500 &mdash; internal server error
···
<p class="mt-1">Our team has been automatically notified about this error.</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
+
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
+
<a href="/" class="btn no-underline hover:no-underline gap-2">
{{ i "home" "w-4 h-4" }}
back to home
</a>
···
</div>
</div>
</div>
-
{{ end }}
+
{{ end }}
+2 -2
appview/pages/templates/errors/503.html
···
We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
+
<button onclick="location.reload()" class="btn-create gap-2">
{{ i "refresh-cw" "w-4 h-4" }}
try again
</button>
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
+
<a href="/" class="btn gap-2 no-underline hover:no-underline">
{{ i "arrow-left" "w-4 h-4" }}
back to timeline
</a>
+1 -1
appview/pages/templates/errors/knot404.html
···
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
+
<a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline">
{{ i "arrow-left" "w-4 h-4" }}
back to timeline
</a>
+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 }}
+12 -10
appview/pages/templates/knots/index.html
···
{{ define "title" }}knots{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4">
+
<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">
+
{{ i "book" "w-3 h-3" }}
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
+
</span>
</div>
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···
{{ end }}
{{ define "about" }}
-
<section class="rounded flex flex-col gap-2">
-
<p class="dark:text-gray-300">
-
Knots are lightweight headless servers that enable users to host Git repositories with ease.
-
Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โ€œcommunityโ€ servers.
-
When creating a repository, you can choose a knot to store it on.
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
-
Checkout the documentation if you're interested in self-hosting.
-
</a>
+
<section class="rounded">
+
<p class="text-gray-500 dark:text-gray-400">
+
Knots are lightweight headless servers that enable users to host Git repositories with ease.
+
When creating a repository, you can choose a knot to store it on.
</p>
-
</section>
+
+
+
</section>
{{ end }}
{{ define "list" }}
+14 -6
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;">
-
{{ template "layouts/topbar" . }}
+
<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">
-
{{ template "layouts/footer" . }}
+
<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 }}
</body>
-48
appview/pages/templates/layouts/footer.html
···
-
{{ define "layouts/footer" }}
-
<div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm">
-
<div class="container mx-auto max-w-7xl px-4">
-
<div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8">
-
<div class="mb-4 md:mb-0">
-
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
-
tangled<sub>alpha</sub>
-
</a>
-
</div>
-
-
{{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }}
-
{{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }}
-
{{ $iconStyle := "w-4 h-4 flex-shrink-0" }}
-
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1">
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">legal</div>
-
<a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a>
-
<a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a>
-
</div>
-
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">resources</div>
-
<a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
-
<a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
-
<a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
-
</div>
-
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">social</div>
-
<a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a>
-
<a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a>
-
<a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a>
-
</div>
-
-
<div class="flex flex-col gap-1">
-
<div class="{{ $headerStyle }}">contact</div>
-
<a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a>
-
<a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a>
-
</div>
-
</div>
-
-
<div class="text-center lg:text-right flex-shrink-0">
-
<div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div>
-
</div>
-
</div>
-
</div>
-
</div>
-
{{ end }}
+48
appview/pages/templates/layouts/fragments/footer.html
···
+
{{ define "layouts/fragments/footer" }}
+
<div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm">
+
<div class="container mx-auto max-w-7xl px-4">
+
<div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8">
+
<div class="mb-4 md:mb-0">
+
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
+
tangled<sub>alpha</sub>
+
</a>
+
</div>
+
+
{{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }}
+
{{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }}
+
{{ $iconStyle := "w-4 h-4 flex-shrink-0" }}
+
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1">
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">legal</div>
+
<a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a>
+
<a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a>
+
</div>
+
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">resources</div>
+
<a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a>
+
<a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a>
+
<a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a>
+
</div>
+
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">social</div>
+
<a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a>
+
<a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a>
+
<a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a>
+
</div>
+
+
<div class="flex flex-col gap-1">
+
<div class="{{ $headerStyle }}">contact</div>
+
<a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a>
+
<a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a>
+
</div>
+
</div>
+
+
<div class="text-center lg:text-right flex-shrink-0">
+
<div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
{{ end }}
+78
appview/pages/templates/layouts/fragments/topbar.html
···
+
{{ define "layouts/fragments/topbar" }}
+
<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="text-lg">{{ template "fragments/logotype" }}</a>
+
</div>
+
+
<div id="right-items" class="flex items-center gap-2">
+
{{ with .LoggedInUser }}
+
{{ block "newButton" . }} {{ end }}
+
{{ block "dropDown" . }} {{ end }}
+
{{ else }}
+
<a href="/login">login</a>
+
<span class="text-gray-500 dark:text-gray-400">or</span>
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
+
join now {{ i "arrow-right" "size-4" }}
+
</a>
+
{{ end }}
+
</div>
+
</div>
+
</nav>
+
{{ end }}
+
+
{{ define "newButton" }}
+
<details class="relative inline-block text-left nav-dropdown">
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
+
{{ i "plus" "w-4 h-4" }} new
+
</summary>
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
+
<a href="/repo/new" class="flex items-center gap-2">
+
{{ i "book-plus" "w-4 h-4" }}
+
new repository
+
</a>
+
<a href="/strings/new" class="flex items-center gap-2">
+
{{ i "line-squiggle" "w-4 h-4" }}
+
new string
+
</a>
+
</div>
+
</details>
+
{{ end }}
+
+
{{ define "dropDown" }}
+
<details class="relative inline-block text-left nav-dropdown">
+
<summary
+
class="cursor-pointer list-none flex items-center"
+
>
+
{{ $user := didOrHandle .Did .Handle }}
+
{{ template "user/fragments/picHandle" $user }}
+
</summary>
+
<div
+
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+
>
+
<a href="/{{ $user }}">profile</a>
+
<a href="/{{ $user }}?tab=repos">repositories</a>
+
<a href="/{{ $user }}?tab=strings">strings</a>
+
<a href="/knots">knots</a>
+
<a href="/spindles">spindles</a>
+
<a href="/settings">settings</a>
+
<a href="#"
+
hx-post="/logout"
+
hx-swap="none"
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
+
logout
+
</a>
+
</div>
+
</details>
+
+
<script>
+
document.addEventListener('click', function(event) {
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
+
dropdowns.forEach(function(dropdown) {
+
if (!dropdown.contains(event.target)) {
+
dropdown.removeAttribute('open');
+
}
+
});
+
});
+
</script>
+
{{ end }}
+104
appview/pages/templates/layouts/profilebase.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
+
+
{{ define "extrameta" }}
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
+
<meta property="og:type" content="profile" />
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
+
{{ end }}
+
+
{{ define "content" }}
+
{{ template "profileTabs" . }}
+
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
+
<div class="md:col-span-3 order-1 md:order-1">
+
<div class="flex flex-col gap-4">
+
{{ template "user/fragments/profileCard" .Card }}
+
{{ block "punchcard" .Card.Punchcard }} {{ end }}
+
</div>
+
</div>
+
{{ block "profileContent" . }} {{ end }}
+
</div>
+
</section>
+
{{ end }}
+
+
{{ define "profileTabs" }}
+
<nav class="w-full pl-4 overflow-x-auto overflow-y-hidden">
+
<div class="flex z-60">
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
+
{{ $tabs := .Card.GetTabs }}
+
{{ $tabmeta := dict "x" "y" }}
+
{{ range $item := $tabs }}
+
{{ $key := index $item 0 }}
+
{{ $value := index $item 1 }}
+
{{ $icon := index $item 2 }}
+
{{ $meta := index $item 3 }}
+
<a
+
href="?tab={{ $value }}"
+
class="relative -mr-px group no-underline hover:no-underline"
+
hx-boost="true">
+
<div
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
+
{{ if eq $.Active $key }}
+
{{ $activeTabStyles }}
+
{{ else }}
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
+
{{ end }}
+
">
+
<span class="flex items-center justify-center">
+
{{ i $icon "w-4 h-4 mr-2" }}
+
{{ $key }}
+
{{ if $meta }}
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
+
{{ end }}
+
</span>
+
</div>
+
</a>
+
{{ end }}
+
</div>
+
</nav>
+
{{ end }}
+
+
{{ define "punchcard" }}
+
{{ $now := now }}
+
<div>
+
<p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white">
+
PUNCHCARD
+
<span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 ">
+
{{ .Total | int64 | commaFmt }} commits
+
</span>
+
</p>
+
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full">
+
{{ range .Punches }}
+
{{ $count := .Count }}
+
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
+
{{ if lt $count 1 }}
+
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
+
{{ else if lt $count 2 }}
+
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
+
{{ else if lt $count 4 }}
+
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
+
{{ else if lt $count 8 }}
+
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
+
{{ else }}
+
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
+
{{ end }}
+
+
{{ if .Date.After $now }}
+
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
+
{{ end }}
+
<div class="w-full h-full flex justify-center items-center">
+
<div
+
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
+
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "layouts/profilebase" }}
+
{{ template "layouts/base" . }}
+
{{ end }}
+
+4 -8
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">
···
<span class="flex items-center justify-center">
{{ i $icon "w-4 h-4 mr-2" }}
{{ $key }}
-
{{ if not (isNil $meta) }}
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
+
{{ if $meta }}
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
{{ end }}
</span>
</div>
···
</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>
{{ block "repoAfter" . }}{{ end }}
</section>
{{ end }}
-
-
{{ define "layouts/repobase" }}
-
{{ template "layouts/base" . }}
-
{{ end }}
-87
appview/pages/templates/layouts/topbar.html
···
-
{{ define "layouts/topbar" }}
-
<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>
-
</div>
-
-
<div id="right-items" class="flex items-center gap-2">
-
{{ with .LoggedInUser }}
-
{{ block "newButton" . }} {{ end }}
-
{{ block "dropDown" . }} {{ end }}
-
{{ else }}
-
<a href="/login">login</a>
-
<span class="text-gray-500 dark:text-gray-400">or</span>
-
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
-
join now {{ i "arrow-right" "size-4" }}
-
</a>
-
{{ end }}
-
</div>
-
</div>
-
</nav>
-
{{ if .LoggedInUser }}
-
<div id="upgrade-banner"
-
hx-get="/knots/upgradeBanner"
-
hx-trigger="load"
-
hx-swap="innerHTML">
-
</div>
-
{{ end }}
-
{{ end }}
-
-
{{ define "newButton" }}
-
<details class="relative inline-block text-left nav-dropdown">
-
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
-
{{ i "plus" "w-4 h-4" }} new
-
</summary>
-
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
-
<a href="/repo/new" class="flex items-center gap-2">
-
{{ i "book-plus" "w-4 h-4" }}
-
new repository
-
</a>
-
<a href="/strings/new" class="flex items-center gap-2">
-
{{ i "line-squiggle" "w-4 h-4" }}
-
new string
-
</a>
-
</div>
-
</details>
-
{{ end }}
-
-
{{ define "dropDown" }}
-
<details class="relative inline-block text-left nav-dropdown">
-
<summary
-
class="cursor-pointer list-none flex items-center"
-
>
-
{{ $user := didOrHandle .Did .Handle }}
-
{{ template "user/fragments/picHandle" $user }}
-
</summary>
-
<div
-
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
-
>
-
<a href="/{{ $user }}">profile</a>
-
<a href="/{{ $user }}?tab=repos">repositories</a>
-
<a href="/strings/{{ $user }}">strings</a>
-
<a href="/knots">knots</a>
-
<a href="/spindles">spindles</a>
-
<a href="/settings">settings</a>
-
<a href="#"
-
hx-post="/logout"
-
hx-swap="none"
-
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
-
logout
-
</a>
-
</div>
-
</details>
-
-
<script>
-
document.addEventListener('click', function(event) {
-
const dropdowns = document.querySelectorAll('.nav-dropdown');
-
dropdowns.forEach(function(dropdown) {
-
if (!dropdown.contains(event.target)) {
-
dropdown.removeAttribute('open');
-
}
-
});
-
});
-
</script>
-
{{ end }}
+4 -126
appview/pages/templates/legal/privacy.html
···
-
{{ define "title" }} privacy policy {{ end }}
+
{{ define "title" }}privacy policy{{ end }}
+
{{ define "content" }}
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
<div class="prose prose-gray dark:prose-invert max-w-none">
-
<h1>Privacy Policy</h1>
-
-
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
-
-
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
-
-
<h2>1. Information We Collect</h2>
-
-
<h3>Account Information</h3>
-
<p>When you create an account, we collect:</p>
-
<ul>
-
<li>Your chosen username</li>
-
<li>Email address</li>
-
<li>Profile information you choose to provide</li>
-
<li>Authentication data</li>
-
</ul>
-
-
<h3>Content and Activity</h3>
-
<p>We store:</p>
-
<ul>
-
<li>Code repositories and associated metadata</li>
-
<li>Issues, pull requests, and comments</li>
-
<li>Activity logs and usage patterns</li>
-
<li>Public keys for authentication</li>
-
</ul>
-
-
<h2>2. Data Location and Hosting</h2>
-
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
-
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
-
<p class="text-blue-700 dark:text-blue-300">
-
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
-
</p>
-
<ul class="text-blue-700 dark:text-blue-300 mt-2">
-
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
-
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
-
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
-
</ul>
-
</div>
-
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
-
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
-
<p class="text-yellow-700 dark:text-yellow-300">
-
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
-
</p>
-
</div>
-
-
<h2>3. Third-Party Data Processors</h2>
-
<p>We only share your data with the following third-party processors:</p>
-
-
<h3>Resend (Email Services)</h3>
-
<ul>
-
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
-
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
-
<li><strong>Location:</strong> EU-compliant email delivery service</li>
-
</ul>
-
-
<h3>Cloudflare (Image Caching)</h3>
-
<ul>
-
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
-
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
-
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
-
</ul>
-
-
<h2>4. How We Use Your Information</h2>
-
<p>We use your information to:</p>
-
<ul>
-
<li>Provide and maintain the Service</li>
-
<li>Process your transactions and requests</li>
-
<li>Send you technical notices and support messages</li>
-
<li>Improve and develop new features</li>
-
<li>Ensure security and prevent fraud</li>
-
<li>Comply with legal obligations</li>
-
</ul>
-
-
<h2>5. Data Sharing and Disclosure</h2>
-
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
-
<ul>
-
<li>With the third-party processors listed above</li>
-
<li>When required by law or legal process</li>
-
<li>To protect our rights, property, or safety, or that of our users</li>
-
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
-
</ul>
-
-
<h2>6. Data Security</h2>
-
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
-
-
<h2>7. Data Retention</h2>
-
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
-
-
<h2>8. Your Rights</h2>
-
<p>Under applicable data protection laws, you have the right to:</p>
-
<ul>
-
<li>Access your personal information</li>
-
<li>Correct inaccurate information</li>
-
<li>Request deletion of your information</li>
-
<li>Object to processing of your information</li>
-
<li>Data portability</li>
-
<li>Withdraw consent (where applicable)</li>
-
</ul>
-
-
<h2>9. Cookies and Tracking</h2>
-
<p>We use cookies and similar technologies to:</p>
-
<ul>
-
<li>Maintain your login session</li>
-
<li>Remember your preferences</li>
-
<li>Analyze usage patterns to improve the Service</li>
-
</ul>
-
<p>You can control cookie settings through your browser preferences.</p>
-
-
<h2>10. Children's Privacy</h2>
-
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
-
-
<h2>11. International Data Transfers</h2>
-
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
-
-
<h2>12. Changes to This Privacy Policy</h2>
-
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
-
-
<h2>13. Contact Information</h2>
-
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
-
-
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
-
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
-
</div>
+
{{ .Content }}
</div>
</div>
</div>
-
{{ end }}
+
{{ end }}
+2 -62
appview/pages/templates/legal/terms.html
···
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
<div class="prose prose-gray dark:prose-invert max-w-none">
-
<h1>Terms of Service</h1>
-
-
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
-
-
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
-
-
<h2>1. Acceptance of Terms</h2>
-
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
-
-
<h2>2. Account Registration</h2>
-
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
-
-
<h2>3. Account Termination</h2>
-
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
-
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
-
<p class="text-red-700 dark:text-red-300">
-
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
-
</p>
-
<p class="text-red-700 dark:text-red-300 mt-2">
-
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
-
</p>
-
</div>
-
-
<h2>4. Acceptable Use</h2>
-
<p>You agree not to use the Service to:</p>
-
<ul>
-
<li>Violate any applicable laws or regulations</li>
-
<li>Infringe upon the rights of others</li>
-
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
-
<li>Engage in spam, phishing, or other deceptive practices</li>
-
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
-
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
-
</ul>
-
-
<h2>5. Content and Intellectual Property</h2>
-
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
-
-
<h2>6. Privacy</h2>
-
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
-
-
<h2>7. Disclaimers</h2>
-
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
-
-
<h2>8. Limitation of Liability</h2>
-
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
-
-
<h2>9. Indemnification</h2>
-
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
-
-
<h2>10. Governing Law</h2>
-
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
-
-
<h2>11. Changes to Terms</h2>
-
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
-
-
<h2>12. Contact Information</h2>
-
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
-
-
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
-
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
-
</div>
+
{{ .Content }}
</div>
</div>
</div>
-
{{ end }}
+
{{ end }}
+2 -2
appview/pages/templates/repo/commit.html
···
{{ define "topbarLayout" }}
<header class="px-1 col-span-full" style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
+
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
···
{{ define "footerLayout" }}
<footer class="px-1 col-span-full mt-12">
-
{{ template "layouts/footer" . }}
+
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+2 -2
appview/pages/templates/repo/compare/compare.html
···
{{ define "topbarLayout" }}
<header class="px-1 col-span-full" style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
+
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
···
{{ define "footerLayout" }}
<footer class="px-1 col-span-full mt-12">
-
{{ template "layouts/footer" . }}
+
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+1 -1
appview/pages/templates/repo/fork.html
···
class="mr-2"
id="domain-{{ . }}"
/>
-
<span class="dark:text-white">{{ . }}</span>
+
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
</div>
{{ else }}
<p class="dark:text-white">No knots available.</p>
+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 }}
+4
appview/pages/templates/repo/fragments/duration.html
···
+
{{ define "repo/fragments/duration" }}
+
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
+
{{ 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 }}
+4
appview/pages/templates/repo/fragments/shortTime.html
···
+
{{ define "repo/fragments/shortTime" }}
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
+
{{ end }}
+
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
+
{{ define "repo/fragments/shortTimeAgo" }}
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
+
{{ end }}
+
-16
appview/pages/templates/repo/fragments/time.html
···
-
{{ define "repo/fragments/timeWrapper" }}
-
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
-
{{ end }}
-
{{ define "repo/fragments/time" }}
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
{{ end }}
-
-
{{ define "repo/fragments/shortTime" }}
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
-
{{ end }}
-
-
{{ define "repo/fragments/shortTimeAgo" }}
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
-
{{ end }}
-
-
{{ define "repo/fragments/duration" }}
-
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
-
{{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
···
+
{{ define "repo/fragments/timeWrapper" }}
+
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
+
{{ end }}
+
+
+25 -8
appview/pages/templates/repo/index.html
···
{{ end }}
{{ define "repoLanguages" }}
-
<div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t">
+
<details class="group -m-6 mb-4">
+
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
+
{{ range $value := .Languages }}
+
<div
+
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
+
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
+
></div>
+
{{ end }}
+
</summary>
+
<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
-
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
-
class="h-[4px] rounded-full"
-
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
-
></div>
+
<div
+
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
+
>
+
{{ 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 }}
+
0.1%
+
{{ else }}
+
{{ printf "%.1f" $value.Percentage }}%
+
{{ end }}
+
</span></div>
+
</div>
{{ end }}
-
</div>
+
</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 }}
+1 -1
appview/pages/templates/repo/new.html
···
class="mr-2"
id="domain-{{ . }}"
/>
-
<span class="dark:text-white">{{ . }}</span>
+
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
</div>
{{ else }}
<p class="dark:text-white">No knots available.</p>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
</div>
{{ end }}
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
</div>
</div>
</a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
···
-
{{ define "repo/pulls/fragments/summarizedHeader" }}
+
{{ define "repo/pulls/fragments/summarizedPullHeader" }}
{{ $pull := index . 0 }}
{{ $pipeline := index . 1 }}
{{ with $pull }}
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
···
{{ define "topbarLayout" }}
<header class="px-1 col-span-full" style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
+
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
···
{{ define "footerLayout" }}
<footer class="px-1 col-span-full mt-12">
-
{{ template "layouts/footer" . }}
+
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+2 -2
appview/pages/templates/repo/pulls/patch.html
···
{{ define "topbarLayout" }}
<header class="px-1 col-span-full" style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
+
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
···
{{ define "footerLayout" }}
<footer class="px-1 col-span-full mt-12">
-
{{ template "layouts/footer" . }}
+
{{ template "layouts/fragments/footer" . }}
</footer>
{{ end }}
+1 -1
appview/pages/templates/repo/pulls/pulls.html
···
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
<div class="flex gap-2 items-center px-6">
<div class="flex-grow min-w-0 w-full py-2">
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
</div>
</div>
</a>
+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 }}
+10 -9
appview/pages/templates/spindles/index.html
···
{{ define "title" }}spindles{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4">
-
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
+
<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">
+
{{ i "book" "w-3 h-3" }}
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
+
</span>
</div>
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···
{{ end }}
{{ define "about" }}
-
<section class="rounded flex flex-col gap-2">
-
<p class="dark:text-gray-300">
-
Spindles are small CI runners.
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
-
Checkout the documentation if you're interested in self-hosting.
-
</a>
+
<section class="rounded flex items-center gap-2">
+
<p class="text-gray-500 dark:text-gray-400">
+
Spindles are small CI runners.
</p>
-
</section>
+
</section>
{{ end }}
{{ define "list" }}
-4
appview/pages/templates/strings/put.html
···
{{ define "title" }}publish a new string{{ end }}
-
{{ define "topbar" }}
-
{{ template "layouts/topbar" $ }}
-
{{ end }}
-
{{ define "content" }}
<div class="px-6 py-2 mb-4">
{{ if eq .Action "new" }}
-4
appview/pages/templates/strings/string.html
···
<meta property="og:description" content="{{ .String.Description }}" />
{{ end }}
-
{{ define "topbar" }}
-
{{ template "layouts/topbar" $ }}
-
{{ end }}
-
{{ define "content" }}
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
-4
appview/pages/templates/strings/timeline.html
···
{{ define "title" }} all strings {{ end }}
-
{{ define "topbar" }}
-
{{ template "layouts/topbar" $ }}
-
{{ end }}
-
{{ define "content" }}
{{ block "timeline" $ }}{{ end }}
{{ end }}
+34
appview/pages/templates/timeline/fragments/hero.html
···
+
{{ define "timeline/fragments/hero" }}
+
<div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row">
+
<div class="flex flex-col gap-6">
+
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
+
+
<p class="text-lg">
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
+
</p>
+
<p class="text-lg">
+
we envision a place where developers have complete ownership of their
+
code, open source communities can freely self-govern and most
+
importantly, coding can be social and fun again.
+
</p>
+
+
<div class="flex gap-6 items-center">
+
<a href="/signup" class="no-underline hover:no-underline ">
+
<button class="btn-create flex gap-2 px-4 items-center">
+
join now {{ i "arrow-right" "size-4" }}
+
</button>
+
</a>
+
</div>
+
</div>
+
+
<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" />
+
</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.
+
</figcaption>
+
</figure>
+
</div>
+
{{ end }}
+
+116
appview/pages/templates/timeline/fragments/timeline.html
···
+
{{ define "timeline/fragments/timeline" }}
+
<div class="py-4">
+
<div class="px-6 pb-4">
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
+
</div>
+
+
<div class="flex flex-col gap-4">
+
{{ range $i, $e := .Timeline }}
+
<div class="relative">
+
{{ if ne $i 0 }}
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
+
{{ end }}
+
{{ with $e }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ if .Repo }}
+
{{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }}
+
{{ else if .Star }}
+
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
+
{{ else if .Follow }}
+
{{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }}
+
{{ end }}
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "timeline/fragments/repoEvent" }}
+
{{ $root := index . 0 }}
+
{{ $repo := index . 1 }}
+
{{ $source := index . 2 }}
+
{{ $userHandle := resolve $repo.Did }}
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
+
{{ template "user/fragments/picHandleLink" $repo.Did }}
+
{{ with $source }}
+
{{ $sourceDid := resolve .Did }}
+
forked
+
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
+
{{ $sourceDid }}/{{ .Name }}
+
</a>
+
to
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
+
{{ else }}
+
created
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
+
{{ $repo.Name }}
+
</a>
+
{{ end }}
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
+
</div>
+
{{ with $repo }}
+
{{ template "user/fragments/repoCard" (list $root . true) }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "timeline/fragments/starEvent" }}
+
{{ $root := index . 0 }}
+
{{ $star := index . 1 }}
+
{{ with $star }}
+
{{ $starrerHandle := resolve .StarredByDid }}
+
{{ $repoOwnerHandle := resolve .Repo.Did }}
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
+
starred
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
+
</a>
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
+
</div>
+
{{ with .Repo }}
+
{{ template "user/fragments/repoCard" (list $root . true) }}
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "timeline/fragments/followEvent" }}
+
{{ $root := index . 0 }}
+
{{ $follow := index . 1 }}
+
{{ $profile := index . 2 }}
+
{{ $stat := index . 3 }}
+
+
{{ $userHandle := resolve $follow.UserDid }}
+
{{ $subjectHandle := resolve $follow.SubjectDid }}
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
+
{{ template "user/fragments/picHandleLink" $userHandle }}
+
followed
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
+
</div>
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
+
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
+
</div>
+
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
+
<a href="/{{ $subjectHandle }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
+
</a>
+
{{ with $profile }}
+
{{ with .Description }}
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
+
{{ end }}
+
{{ end }}
+
{{ with $stat }}
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
+
<span class="select-none after:content-['ยท']"></span>
+
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
···
+
{{ define "timeline/fragments/trending" }}
+
<div class="w-full md:mx-0 py-4">
+
<div class="px-6 pb-4">
+
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
+
Trending
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
+
</h3>
+
</div>
+
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
+
{{ range $index, $repo := .Repos }}
+
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
+
</div>
+
{{ else }}
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
+
No trending repositories this week
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
+90
appview/pages/templates/timeline/home.html
···
+
{{ define "title" }}tangled &middot; tightly-knit social coding{{ end }}
+
+
{{ define "extrameta" }}
+
<meta property="og:title" content="timeline ยท tangled" />
+
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.sh" />
+
<meta property="og:description" content="tightly-knit social coding" />
+
{{ end }}
+
+
+
{{ define "content" }}
+
<div class="flex flex-col gap-4">
+
{{ template "timeline/fragments/hero" . }}
+
{{ template "features" . }}
+
{{ template "timeline/fragments/trending" . }}
+
{{ template "timeline/fragments/timeline" . }}
+
<div class="flex justify-end">
+
<a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400">
+
view more
+
{{ i "arrow-right" "size-4" }}
+
</a>
+
</div>
+
</div>
+
{{ end }}
+
+
+
{{ define "feature" }}
+
{{ $info := index . 0 }}
+
{{ $bullets := index . 1 }}
+
<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">
+
{{ range $bullets }}
+
<li><p>{{ escapeHtml . }}</p></li>
+
{{ end }}
+
</ul>
+
</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 shadow-sm" />
+
</a>
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "features" }}
+
<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"
+
"image" "https://assets.tangled.network/what-is-tangled-repo.png"
+
"alt" "A repository hosted on Tangled"
+
)
+
(list
+
"Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations."
+
"Add friends to your knot or invite collaborators to your repository."
+
"Guarded by fine-grained role-based access control."
+
"Use SSH to push and pull."
+
)
+
) }}
+
+
{{ template "feature" (list
+
(dict
+
"title" "improved pull request model"
+
"image" "https://assets.tangled.network/pulls.png"
+
"alt" "Round-based pull requests."
+
)
+
(list
+
"An intuitive and effective round-based pull request flow, with inter-diffing between rounds."
+
"Stacked pull requests using Jujutsu's change IDs."
+
"Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes."
+
)
+
) }}
+
+
{{ template "feature" (list
+
(dict
+
"title" "run pipelines using spindles"
+
"image" "https://assets.tangled.network/pipelines.png"
+
"alt" "CI pipeline running on spindle"
+
)
+
(list
+
"Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners."
+
"Natively supports Nix for package management."
+
"Easily extended to support different execution backends."
+
)
+
) }}
+
</div>
+
{{ end }}
+
+6 -171
appview/pages/templates/timeline/timeline.html
···
{{ end }}
{{ define "content" }}
-
{{ if .LoggedInUser }}
-
{{ else }}
-
{{ block "hero" $ }}{{ end }}
-
{{ end }}
+
{{ if .LoggedInUser }}
+
{{ else }}
+
{{ template "timeline/fragments/hero" . }}
+
{{ end }}
-
{{ block "trending" $ }}{{ end }}
-
{{ block "timeline" $ }}{{ end }}
-
{{ end }}
-
-
{{ define "hero" }}
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
-
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
-
-
<p class="text-lg">
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
-
</p>
-
<p class="text-lg">
-
we envision a place where developers have complete ownership of their
-
code, open source communities can freely self-govern and most
-
importantly, coding can be social and fun again.
-
</p>
-
-
<div class="flex gap-6 items-center">
-
<a href="/signup" class="no-underline hover:no-underline ">
-
<button class="btn-create flex gap-2 px-4 items-center">
-
join now {{ i "arrow-right" "size-4" }}
-
</button>
-
</a>
-
</div>
-
</div>
-
{{ end }}
-
-
{{ define "trending" }}
-
<div class="w-full md:mx-0 py-4">
-
<div class="px-6 pb-4">
-
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
-
Trending
-
{{ i "trending-up" "size-4 flex-shrink-0" }}
-
</h3>
-
</div>
-
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
-
{{ range $index, $repo := .Repos }}
-
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
-
{{ template "user/fragments/repoCard" (list $ $repo true) }}
-
</div>
-
{{ else }}
-
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
-
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
-
No trending repositories this week
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
-
{{ define "timeline" }}
-
<div class="py-4">
-
<div class="px-6 pb-4">
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
-
</div>
-
-
<div class="flex flex-col gap-4">
-
{{ range $i, $e := .Timeline }}
-
<div class="relative">
-
{{ if ne $i 0 }}
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
-
{{ end }}
-
{{ with $e }}
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
-
{{ if .Repo }}
-
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
-
{{ else if .Star }}
-
{{ block "starEvent" (list $ .Star) }} {{ end }}
-
{{ else if .Follow }}
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
-
{{ end }}
-
</div>
-
{{ end }}
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
-
{{ define "repoEvent" }}
-
{{ $root := index . 0 }}
-
{{ $repo := index . 1 }}
-
{{ $source := index . 2 }}
-
{{ $userHandle := resolve $repo.Did }}
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
-
{{ template "user/fragments/picHandleLink" $repo.Did }}
-
{{ with $source }}
-
{{ $sourceDid := resolve .Did }}
-
forked
-
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
-
{{ $sourceDid }}/{{ .Name }}
-
</a>
-
to
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
-
{{ else }}
-
created
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
-
{{ $repo.Name }}
-
</a>
-
{{ end }}
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
-
</div>
-
{{ with $repo }}
-
{{ template "user/fragments/repoCard" (list $root . true) }}
-
{{ end }}
-
{{ end }}
-
-
{{ define "starEvent" }}
-
{{ $root := index . 0 }}
-
{{ $star := index . 1 }}
-
{{ with $star }}
-
{{ $starrerHandle := resolve .StarredByDid }}
-
{{ $repoOwnerHandle := resolve .Repo.Did }}
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
-
starred
-
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
-
</a>
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
-
</div>
-
{{ with .Repo }}
-
{{ template "user/fragments/repoCard" (list $root . true) }}
-
{{ end }}
-
{{ end }}
-
{{ end }}
-
-
-
{{ define "followEvent" }}
-
{{ $root := index . 0 }}
-
{{ $follow := index . 1 }}
-
{{ $profile := index . 2 }}
-
{{ $stat := index . 3 }}
-
-
{{ $userHandle := resolve $follow.UserDid }}
-
{{ $subjectHandle := resolve $follow.SubjectDid }}
-
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
-
{{ template "user/fragments/picHandleLink" $userHandle }}
-
followed
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
-
</div>
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
-
</div>
-
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
-
<a href="/{{ $subjectHandle }}">
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
-
</a>
-
{{ with $profile }}
-
{{ with .Description }}
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
-
{{ end }}
-
{{ end }}
-
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
-
</div>
-
{{ end }}
-
</div>
-
</div>
+
{{ template "timeline/fragments/trending" . }}
+
{{ template "timeline/fragments/timeline" . }}
{{ end }}
+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.
+4 -16
appview/pages/templates/user/followers.html
···
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
-
{{ define "extrameta" }}
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" />
-
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" />
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
-
{{ end }}
-
-
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
-
<div class="md:col-span-3 order-1 md:order-1">
-
{{ template "user/fragments/profileCard" .Card }}
-
</div>
-
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
-
{{ block "followers" . }}{{ end }}
-
</div>
-
</div>
+
{{ define "profileContent" }}
+
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
+
{{ block "followers" . }}{{ end }}
+
</div>
{{ end }}
{{ define "followers" }}
+4 -16
appview/pages/templates/user/following.html
···
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
-
{{ define "extrameta" }}
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" />
-
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" />
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
-
{{ end }}
-
-
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
-
<div class="md:col-span-3 order-1 md:order-1">
-
{{ template "user/fragments/profileCard" .Card }}
-
</div>
-
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
-
{{ block "following" . }}{{ end }}
-
</div>
-
</div>
+
{{ define "profileContent" }}
+
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
+
{{ block "following" . }}{{ end }}
+
</div>
{{ end }}
{{ define "following" }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
···
<label class="m-0 p-0" for="description">bio</label>
<textarea
type="text"
-
class="py-1 px-1 w-full"
+
class="p-2 w-full"
name="description"
rows="3"
placeholder="write a bio">{{ $description }}</textarea>
+2 -4
appview/pages/templates/user/fragments/profileCard.html
···
{{ define "user/fragments/profileCard" }}
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
<div id="avatar" class="col-span-1 flex justify-center items-center">
<div class="w-3/4 aspect-square relative">
···
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
</div>
</div>
-
</div>
{{ end }}
{{ define "followerFollowing" }}
···
{{ with $root }}
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span>
<span class="select-none after:content-['ยท']"></span>
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
</div>
{{ end }}
{{ end }}
+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.
+269
appview/pages/templates/user/overview.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
+
<div class="grid grid-cols-1 gap-4">
+
{{ block "ownRepos" . }}{{ end }}
+
{{ block "collaboratingRepos" . }}{{ end }}
+
</div>
+
</div>
+
<div class="md:col-span-4 order-3 md:order-3">
+
{{ block "profileTimeline" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "profileTimeline" }}
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
+
<div class="flex flex-col gap-4 relative">
+
{{ if .ProfileTimeline.IsEmpty }}
+
<p class="dark:text-white">This user does not have any activity yet.</p>
+
{{ end }}
+
+
{{ with .ProfileTimeline }}
+
{{ range $idx, $byMonth := .ByMonth }}
+
{{ with $byMonth }}
+
{{ if not .IsEmpty }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6">
+
<p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400">
+
{{ if eq $idx 0 }}
+
this month
+
{{ else }}
+
{{$idx}} month{{if ne $idx 1}}s{{end}} ago
+
{{ end }}
+
</p>
+
+
<div class="flex flex-col gap-1">
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
+
{{ block "pullEvents" .PullEvents }} {{ end }}
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "repoEvents" }}
+
{{ if gt (len .) 0 }}
+
<details>
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="flex flex-wrap items-center gap-2">
+
{{ i "book-plus" "w-4 h-4" }}
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
+
</div>
+
</summary>
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
+
{{ range . }}
+
<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 }}
+
{{ i "book-plus" "w-4 h-4" }}
+
{{ end }}
+
</span>
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
+
{{- .Repo.Name -}}
+
</a>
+
</span>
+
+
{{ 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>
+
</details>
+
{{ end }}
+
{{ end }}
+
+
{{ define "issueEvents" }}
+
{{ $items := .Items }}
+
{{ $stats := .Stats }}
+
+
{{ if gt (len $items) 0 }}
+
<details>
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="flex flex-wrap items-center gap-2">
+
{{ i "circle-dot" "w-4 h-4" }}
+
+
<div>
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
+
</div>
+
+
{{ if gt $stats.Open 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
+
{{$stats.Open}} open
+
</span>
+
{{ end }}
+
+
{{ if gt $stats.Closed 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
+
{{$stats.Closed}} closed
+
</span>
+
{{ end }}
+
+
</div>
+
</summary>
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
+
{{ range $items }}
+
{{ $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">
+
{{ if .Open }}
+
<span class="text-green-600 dark:text-green-500">
+
{{ i "circle-dot" "w-4 h-4" }}
+
</span>
+
{{ else }}
+
<span class="text-gray-500 dark:text-gray-400">
+
{{ i "ban" "w-4 h-4" }}
+
</span>
+
{{ end }}
+
<div class="flex-none min-w-8 text-right">
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
+
</div>
+
<div class="break-words max-w-full">
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
+
{{ .Title -}}
+
</a>
+
on
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
+
{{$repoUrl}}
+
</a>
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</details>
+
{{ end }}
+
{{ end }}
+
+
{{ define "pullEvents" }}
+
{{ $items := .Items }}
+
{{ $stats := .Stats }}
+
{{ if gt (len $items) 0 }}
+
<details>
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
<div class="flex flex-wrap items-center gap-2">
+
{{ i "git-pull-request" "w-4 h-4" }}
+
+
<div>
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
+
</div>
+
+
{{ if gt $stats.Open 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
+
{{$stats.Open}} open
+
</span>
+
{{ end }}
+
+
{{ if gt $stats.Merged 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
+
{{$stats.Merged}} merged
+
</span>
+
{{ end }}
+
+
+
{{ if gt $stats.Closed 0 }}
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
+
{{$stats.Closed}} closed
+
</span>
+
{{ end }}
+
+
</div>
+
</summary>
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
+
{{ range $items }}
+
{{ $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">
+
{{ if .State.IsOpen }}
+
<span class="text-green-600 dark:text-green-500">
+
{{ i "git-pull-request" "w-4 h-4" }}
+
</span>
+
{{ else if .State.IsMerged }}
+
<span class="text-purple-600 dark:text-purple-500">
+
{{ i "git-merge" "w-4 h-4" }}
+
</span>
+
{{ else }}
+
<span class="text-gray-600 dark:text-gray-300">
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
+
</span>
+
{{ end }}
+
<div class="flex-none min-w-8 text-right">
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
+
</div>
+
<div class="break-words max-w-full">
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
+
{{ .Title -}}
+
</a>
+
on
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
+
{{$repoUrl}}
+
</a>
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</details>
+
{{ end }}
+
{{ end }}
+
+
{{ define "ownRepos" }}
+
<div>
+
<div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2">
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
+
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
+
<span>PINNED REPOS</span>
+
</a>
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
+
<button
+
hx-get="profile/edit-pins"
+
hx-target="#all-repos"
+
class="py-0 font-normal text-sm flex gap-2 items-center group">
+
{{ i "pencil" "w-3 h-3" }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
</div>
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
+
{{ range .Repos }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ . false) }}
+
</div>
+
{{ else }}
+
<p class="dark:text-white">This user does not have any pinned repos.</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "collaboratingRepos" }}
+
{{ if gt (len .CollaboratingRepos) 0 }}
+
<div>
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p>
+
<div id="collaborating" class="grid grid-cols-1 gap-4">
+
{{ range .CollaboratingRepos }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ . true) }}
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
-318
appview/pages/templates/user/profile.html
···
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
-
-
{{ define "extrameta" }}
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
-
<meta property="og:type" content="profile" />
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
-
{{ end }}
-
-
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
-
<div class="md:col-span-3 order-1 md:order-1">
-
<div class="grid grid-cols-1 gap-4">
-
{{ template "user/fragments/profileCard" .Card }}
-
{{ block "punchcard" .Punchcard }} {{ end }}
-
</div>
-
</div>
-
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
-
<div class="grid grid-cols-1 gap-4">
-
{{ block "ownRepos" . }}{{ end }}
-
{{ block "collaboratingRepos" . }}{{ end }}
-
</div>
-
</div>
-
<div class="md:col-span-4 order-3 md:order-3">
-
{{ block "profileTimeline" . }}{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
-
{{ define "profileTimeline" }}
-
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
-
<div class="flex flex-col gap-4 relative">
-
{{ with .ProfileTimeline }}
-
{{ range $idx, $byMonth := .ByMonth }}
-
{{ with $byMonth }}
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
-
{{ if eq $idx 0 }}
-
-
{{ else }}
-
{{ $s := "s" }}
-
{{ if eq $idx 1 }}
-
{{ $s = "" }}
-
{{ end }}
-
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
-
{{ end }}
-
-
{{ if .IsEmpty }}
-
<div class="text-gray-500 dark:text-gray-400">
-
No activity for this month
-
</div>
-
{{ else }}
-
<div class="flex flex-col gap-1">
-
{{ block "repoEvents" .RepoEvents }} {{ end }}
-
{{ block "issueEvents" .IssueEvents }} {{ end }}
-
{{ block "pullEvents" .PullEvents }} {{ end }}
-
</div>
-
{{ end }}
-
</div>
-
-
{{ end }}
-
{{ else }}
-
<p class="dark:text-white">This user does not have any activity yet.</p>
-
{{ end }}
-
{{ end }}
-
</div>
-
{{ end }}
-
-
{{ define "repoEvents" }}
-
{{ if gt (len .) 0 }}
-
<details>
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
-
<div class="flex flex-wrap items-center gap-2">
-
{{ i "book-plus" "w-4 h-4" }}
-
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
-
</div>
-
</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 }}
-
{{ i "git-fork" "w-4 h-4" }}
-
{{ else }}
-
{{ i "book-plus" "w-4 h-4" }}
-
{{ end }}
-
</span>
-
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
-
{{- .Repo.Name -}}
-
</a>
-
</div>
-
{{ end }}
-
</div>
-
</details>
-
{{ end }}
-
{{ end }}
-
-
{{ define "issueEvents" }}
-
{{ $items := .Items }}
-
{{ $stats := .Stats }}
-
-
{{ if gt (len $items) 0 }}
-
<details>
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
-
<div class="flex flex-wrap items-center gap-2">
-
{{ i "circle-dot" "w-4 h-4" }}
-
-
<div>
-
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
-
</div>
-
-
{{ if gt $stats.Open 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
-
{{$stats.Open}} open
-
</span>
-
{{ end }}
-
-
{{ if gt $stats.Closed 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
-
{{$stats.Closed}} closed
-
</span>
-
{{ 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 }}
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
-
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
-
{{ if .Open }}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "circle-dot" "w-4 h-4" }}
-
</span>
-
{{ else }}
-
<span class="text-gray-500 dark:text-gray-400">
-
{{ i "ban" "w-4 h-4" }}
-
</span>
-
{{ end }}
-
<div class="flex-none min-w-8 text-right">
-
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
-
</div>
-
<div class="break-words max-w-full">
-
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
-
{{ .Title -}}
-
</a>
-
on
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
-
{{$repoUrl}}
-
</a>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
</details>
-
{{ end }}
-
{{ end }}
-
-
{{ define "pullEvents" }}
-
{{ $items := .Items }}
-
{{ $stats := .Stats }}
-
{{ if gt (len $items) 0 }}
-
<details>
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
-
<div class="flex flex-wrap items-center gap-2">
-
{{ i "git-pull-request" "w-4 h-4" }}
-
-
<div>
-
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
-
</div>
-
-
{{ if gt $stats.Open 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
-
{{$stats.Open}} open
-
</span>
-
{{ end }}
-
-
{{ if gt $stats.Merged 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
-
{{$stats.Merged}} merged
-
</span>
-
{{ end }}
-
-
-
{{ if gt $stats.Closed 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
-
{{$stats.Closed}} closed
-
</span>
-
{{ end }}
-
-
</div>
-
</summary>
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
-
{{ range $items }}
-
{{ $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">
-
{{ if .State.IsOpen }}
-
<span class="text-green-600 dark:text-green-500">
-
{{ i "git-pull-request" "w-4 h-4" }}
-
</span>
-
{{ else if .State.IsMerged }}
-
<span class="text-purple-600 dark:text-purple-500">
-
{{ i "git-merge" "w-4 h-4" }}
-
</span>
-
{{ else }}
-
<span class="text-gray-600 dark:text-gray-300">
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
-
</span>
-
{{ end }}
-
<div class="flex-none min-w-8 text-right">
-
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
-
</div>
-
<div class="break-words max-w-full">
-
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
-
{{ .Title -}}
-
</a>
-
on
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
-
{{$repoUrl}}
-
</a>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
</details>
-
{{ end }}
-
{{ end }}
-
-
{{ define "ownRepos" }}
-
<div>
-
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
-
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
-
<span>PINNED REPOS</span>
-
<span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
-
view all {{ i "chevron-right" "w-4 h-4" }}
-
</span>
-
</a>
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
-
<button
-
hx-get="profile/edit-pins"
-
hx-target="#all-repos"
-
class="btn py-0 font-normal text-sm flex gap-2 items-center group">
-
{{ i "pencil" "w-3 h-3" }}
-
edit
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ end }}
-
</div>
-
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
-
{{ range .Repos }}
-
{{ template "user/fragments/repoCard" (list $ . false) }}
-
{{ else }}
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
-
{{ define "collaboratingRepos" }}
-
{{ if gt (len .CollaboratingRepos) 0 }}
-
<div>
-
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
-
<div id="collaborating" class="grid grid-cols-1 gap-4">
-
{{ range .CollaboratingRepos }}
-
{{ template "user/fragments/repoCard" (list $ . true) }}
-
{{ else }}
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
{{ end }}
-
-
{{ define "punchcard" }}
-
{{ $now := now }}
-
<div>
-
<p class="p-2 flex gap-2 text-sm font-bold dark:text-white">
-
PUNCHCARD
-
<span class="font-normal text-sm text-gray-500 dark:text-gray-400 ">
-
{{ .Total | int64 | commaFmt }} commits
-
</span>
-
</p>
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
-
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full">
-
{{ range .Punches }}
-
{{ $count := .Count }}
-
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
-
{{ if lt $count 1 }}
-
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
-
{{ else if lt $count 2 }}
-
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
-
{{ else if lt $count 4 }}
-
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
-
{{ else if lt $count 8 }}
-
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
-
{{ else }}
-
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
-
{{ end }}
-
-
{{ if .Date.After $now }}
-
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
-
{{ end }}
-
<div class="w-full h-full flex justify-center items-center">
-
<div
-
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
-
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
</div>
-
{{ end }}
+7 -18
appview/pages/templates/user/repos.html
···
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
-
{{ define "extrameta" }}
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
-
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
-
{{ end }}
-
-
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
-
<div class="md:col-span-3 order-1 md:order-1">
-
{{ template "user/fragments/profileCard" .Card }}
-
</div>
-
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
-
{{ block "ownRepos" . }}{{ end }}
-
</div>
-
</div>
+
{{ define "profileContent" }}
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
+
{{ block "ownRepos" . }}{{ end }}
+
</div>
{{ end }}
{{ define "ownRepos" }}
-
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Repos }}
-
{{ template "user/fragments/repoCard" (list $ . false) }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ . false) }}
+
</div>
{{ else }}
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
{{ end }}
+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"
+19
appview/pages/templates/user/starred.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
+
{{ block "starredRepos" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "starredRepos" }}
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Repos }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ . true) }}
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+45
appview/pages/templates/user/strings.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
+
{{ block "allStrings" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "allStrings" }}
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Strings }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "singleString" (list $ .) }}
+
</div>
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "singleString" }}
+
{{ $root := index . 0 }}
+
{{ $s := index . 1 }}
+
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
+
<div class="font-medium dark:text-white flex gap-2 items-center">
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
+
</div>
+
{{ with $s.Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
{{ . }}
+
</div>
+
{{ end }}
+
+
{{ $stat := $s.Stats }}
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
+
<span class="select-none [&:before]:content-['ยท']"></span>
+
{{ with $s.Edited }}
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+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) {
+197 -137
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"
)
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
tabVal := r.URL.Query().Get("tab")
switch tabVal {
-
case "":
-
s.profileHomePage(w, r)
case "repos":
s.reposPage(w, r)
case "followers":
s.followersPage(w, r)
case "following":
s.followingPage(w, r)
+
case "starred":
+
s.starredPage(w, r)
+
case "strings":
+
s.stringsPage(w, r)
+
default:
+
s.profileOverview(w, r)
}
}
-
type ProfilePageParams struct {
-
Id identity.Identity
-
LoggedInUser *oauth.User
-
Card pages.ProfileCard
-
}
-
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams {
+
func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {
didOrHandle := chi.URLParam(r, "user")
if didOrHandle == "" {
-
http.Error(w, "bad request", http.StatusBadRequest)
-
return nil
+
return nil, fmt.Errorf("empty DID or handle")
}
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
if !ok {
-
log.Printf("malformed middleware")
-
w.WriteHeader(http.StatusInternalServerError)
-
return nil
+
return nil, fmt.Errorf("failed to resolve ID")
}
did := ident.DID.String()
profile, err := db.GetProfile(s.db, did)
if err != nil {
-
log.Printf("getting profile data for %s: %s", did, err)
-
s.pages.Error500(w)
-
return nil
+
return nil, fmt.Errorf("failed to get profile: %w", err)
+
}
+
+
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get repo count: %w", err)
+
}
+
+
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get string count: %w", err)
+
}
+
+
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
}
followStats, err := db.GetFollowerFollowingCount(s.db, did)
if err != nil {
-
log.Printf("getting follow stats for %s: %s", did, err)
+
return nil, fmt.Errorf("failed to get follower stats: %w", err)
}
loggedInUser := s.oauth.GetUser(r)
···
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
}
-
return &ProfilePageParams{
-
Id: ident,
-
LoggedInUser: loggedInUser,
-
Card: pages.ProfileCard{
-
UserDid: did,
-
UserHandle: ident.Handle.String(),
-
Profile: profile,
-
FollowStatus: followStatus,
+
now := time.Now()
+
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
+
punchcard, err := db.MakePunchcard(
+
s.db,
+
db.FilterEq("did", did),
+
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
+
db.FilterLte("date", now.Format(time.DateOnly)),
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
+
}
+
+
return &pages.ProfileCard{
+
UserDid: did,
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
Stats: pages.ProfileStats{
+
RepoCount: repoCount,
+
StringCount: stringCount,
+
StarredCount: starredCount,
FollowersCount: followStats.Followers,
FollowingCount: followStats.Following,
},
-
}
+
Punchcard: punchcard,
+
}, nil
}
-
func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
-
pageWithProfile := s.profilePage(w, r)
-
if pageWithProfile == nil {
+
func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "profileHomePage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
return
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
id := pageWithProfile.Id
repos, err := db.GetRepos(
s.db,
0,
-
db.FilterEq("did", id.DID),
+
db.FilterEq("did", profile.UserDid),
)
if err != nil {
-
log.Printf("getting repos for %s: %s", id.DID, err)
+
l.Error("failed to fetch repos", "err", err)
}
-
profile := pageWithProfile.Card.Profile
// filter out ones that are pinned
pinnedRepos := []db.Repo{}
for i, r := range repos {
// if this is a pinned repo, add it
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
pinnedRepos = append(pinnedRepos, r)
}
// if there are no saved pins, add the first 4 repos
-
if profile.IsPinnedReposEmpty() && i < 4 {
+
if profile.Profile.IsPinnedReposEmpty() && i < 4 {
pinnedRepos = append(pinnedRepos, r)
}
}
-
collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
if err != nil {
-
log.Printf("getting collaborating repos for %s: %s", id.DID, err)
+
l.Error("failed to fetch collaborating repos", "err", err)
}
pinnedCollaboratingRepos := []db.Repo{}
for _, r := range collaboratingRepos {
// if this is a pinned repo, add it
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
}
}
-
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
+
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
if err != nil {
-
log.Printf("failed to create profile timeline for %s: %s", id.DID, err)
+
l.Error("failed to create timeline", "err", err)
}
-
var didsToResolve []string
-
for _, r := range collaboratingRepos {
-
didsToResolve = append(didsToResolve, r.Did)
+
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Card: profile,
+
Repos: pinnedRepos,
+
CollaboratingRepos: pinnedCollaboratingRepos,
+
ProfileTimeline: timeline,
+
})
+
}
+
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "reposPage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
+
return
}
-
for _, byMonth := range timeline.ByMonth {
-
for _, pe := range byMonth.PullEvents.Items {
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
-
}
-
for _, ie := range byMonth.IssueEvents.Items {
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
-
}
-
for _, re := range byMonth.RepoEvents {
-
didsToResolve = append(didsToResolve, re.Repo.Did)
-
if re.Source != nil {
-
didsToResolve = append(didsToResolve, re.Source.Did)
-
}
-
}
-
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
now := time.Now()
-
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
-
punchcard, err := db.MakePunchcard(
+
repos, err := db.GetRepos(
s.db,
-
db.FilterEq("did", id.DID),
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
-
db.FilterLte("date", now.Format(time.DateOnly)),
+
0,
+
db.FilterEq("did", profile.UserDid),
)
if err != nil {
-
log.Println("failed to get punchcard for did", "did", id.DID, "err", err)
+
l.Error("failed to get repos", "err", err)
+
s.pages.Error500(w)
+
return
}
-
s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
-
LoggedInUser: pageWithProfile.LoggedInUser,
-
Repos: pinnedRepos,
-
CollaboratingRepos: pinnedCollaboratingRepos,
-
Card: pageWithProfile.Card,
-
Punchcard: punchcard,
-
ProfileTimeline: timeline,
+
err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Repos: repos,
+
Card: profile,
})
}
-
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
-
pageWithProfile := s.profilePage(w, r)
-
if pageWithProfile == nil {
+
func (s *State) starredPage(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "starredPage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
return
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
id := pageWithProfile.Id
+
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
+
if err != nil {
+
l.Error("failed to get stars", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
var repoAts []string
+
for _, s := range stars {
+
repoAts = append(repoAts, string(s.RepoAt))
+
}
+
repos, err := db.GetRepos(
s.db,
0,
-
db.FilterEq("did", id.DID),
+
db.FilterIn("at_uri", repoAts),
)
if err != nil {
-
log.Printf("getting repos for %s: %s", id.DID, err)
+
l.Error("failed to get repos", "err", err)
+
s.pages.Error500(w)
+
return
}
-
s.pages.ReposPage(w, pages.ReposPageParams{
-
LoggedInUser: pageWithProfile.LoggedInUser,
+
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
+
LoggedInUser: s.oauth.GetUser(r),
Repos: repos,
-
Card: pageWithProfile.Card,
+
Card: profile,
+
})
+
}
+
+
func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "stringsPage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
+
+
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
+
if err != nil {
+
l.Error("failed to get strings", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
+
err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Strings: strings,
+
Card: profile,
})
}
type FollowsPageParams struct {
-
LoggedInUser *oauth.User
-
Follows []pages.FollowCard
-
Card pages.ProfileCard
+
Follows []pages.FollowCard
+
Card *pages.ProfileCard
}
-
func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
-
pageWithProfile := s.profilePage(w, r)
-
if pageWithProfile == nil {
-
return FollowsPageParams{}, nil
+
func (s *State) followPage(
+
r *http.Request,
+
fetchFollows func(db.Execer, string) ([]db.Follow, error),
+
extractDid func(db.Follow) string,
+
) (*FollowsPageParams, error) {
+
l := s.logger.With("handler", "reposPage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
return nil, err
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
id := pageWithProfile.Id
-
loggedInUser := pageWithProfile.LoggedInUser
+
loggedInUser := s.oauth.GetUser(r)
+
params := FollowsPageParams{
+
Card: profile,
+
}
-
follows, err := fetchFollows(s.db, id.DID.String())
+
follows, err := fetchFollows(s.db, profile.UserDid)
if err != nil {
-
log.Printf("getting followers for %s: %s", id.DID, err)
-
return FollowsPageParams{}, err
+
l.Error("failed to fetch follows", "err", err)
+
return &params, err
}
if len(follows) == 0 {
-
return FollowsPageParams{
-
LoggedInUser: loggedInUser,
-
Follows: []pages.FollowCard{},
-
Card: pageWithProfile.Card,
-
}, nil
+
return &params, nil
}
followDids := make([]string, 0, len(follows))
···
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
if err != nil {
-
log.Printf("getting profile for %s: %s", followDids, err)
-
return FollowsPageParams{}, err
+
l.Error("failed to get profiles", "followDids", followDids, "err", err)
+
return &params, err
}
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
log.Printf("getting follow counts for %s: %s", followDids, err)
}
-
var loggedInUserFollowing map[string]struct{}
+
loggedInUserFollowing := make(map[string]struct{})
if loggedInUser != nil {
following, err := db.GetFollowing(s.db, loggedInUser.Did)
if err != nil {
-
return FollowsPageParams{}, err
+
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
+
return &params, err
}
-
if len(following) > 0 {
-
loggedInUserFollowing = make(map[string]struct{}, len(following))
-
for _, follow := range following {
-
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
-
}
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
+
for _, follow := range following {
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
}
}
-
followCards := make([]pages.FollowCard, 0, len(follows))
-
for _, did := range followDids {
-
followStats, exists := followStatsMap[did]
-
if !exists {
-
followStats = db.FollowStats{}
-
}
+
followCards := make([]pages.FollowCard, len(follows))
+
for i, did := range followDids {
+
followStats := followStatsMap[did]
followStatus := db.IsNotFollowing
-
if loggedInUserFollowing != nil {
-
if _, exists := loggedInUserFollowing[did]; exists {
-
followStatus = db.IsFollowing
-
} else if loggedInUser.Did == did {
-
followStatus = db.IsSelf
-
}
+
if _, exists := loggedInUserFollowing[did]; exists {
+
followStatus = db.IsFollowing
+
} else if loggedInUser != nil && loggedInUser.Did == did {
+
followStatus = db.IsSelf
}
+
var profile *db.Profile
if p, exists := profiles[did]; exists {
profile = p
···
profile = &db.Profile{}
profile.Did = did
}
-
followCards = append(followCards, pages.FollowCard{
+
followCards[i] = pages.FollowCard{
UserDid: did,
FollowStatus: followStatus,
FollowersCount: followStats.Followers,
FollowingCount: followStats.Following,
Profile: profile,
-
})
+
}
}
-
return FollowsPageParams{
-
LoggedInUser: loggedInUser,
-
Follows: followCards,
-
Card: pageWithProfile.Card,
-
}, nil
+
params.Follows = followCards
+
+
return &params, nil
}
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
-
followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
+
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
if err != nil {
s.pages.Notice(w, "all-followers", "Failed to load followers")
return
}
-
s.pages.FollowersPage(w, pages.FollowersPageParams{
-
LoggedInUser: followPage.LoggedInUser,
+
s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
+
LoggedInUser: s.oauth.GetUser(r),
Followers: followPage.Follows,
Card: followPage.Card,
})
}
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
-
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
+
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
if err != nil {
s.pages.Notice(w, "all-following", "Failed to load following")
return
}
-
s.pages.FollowingPage(w, pages.FollowingPageParams{
-
LoggedInUser: followPage.LoggedInUser,
+
s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
+
LoggedInUser: s.oauth.GetUser(r),
Following: followPage.Follows,
Card: followPage.Card,
})
···
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)
}
+4 -2
appview/state/router.go
···
r.Handle("/static/*", s.pages.Static())
-
r.Get("/", s.Timeline)
+
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)
}
+78 -4
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 {
···
tangled.SpindleMemberNSID,
tangled.SpindleNSID,
tangled.StringNSID,
+
tangled.RepoIssueNSID,
+
tangled.RepoIssueCommentNSID,
},
nil,
slog.Default(),
···
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) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
+
if s.oauth.GetUser(r) != nil {
+
s.Timeline(w, r)
+
return
+
}
+
s.Home(w, r)
+
}
+
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
-
timeline, err := db.MakeTimeline(s.db)
+
timeline, err := db.MakeTimeline(s.db, 50)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
})
}
+
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, 5)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
+
return
+
}
+
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
+
return
+
}
+
+
s.pages.Home(w, pages.TimelineParams{
+
LoggedInUser: nil,
+
Timeline: timeline,
+
Repos: repos,
+
})
+
}
+
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
user = strings.TrimPrefix(user, "@")
···
for _, k := range pubKeys {
key := strings.TrimRight(k.Key, "\n")
-
w.Write([]byte(fmt.Sprintln(key)))
+
fmt.Fprintln(w, key)
}
}
+1 -59
appview/strings/strings.go
···
"log/slog"
"net/http"
"path"
-
"slices"
"strconv"
"time"
···
}
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
-
l := s.Logger.With("handler", "dashboard")
-
-
id, ok := r.Context().Value("resolvedId").(identity.Identity)
-
if !ok {
-
l.Error("malformed middleware")
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
l = l.With("did", id.DID, "handle", id.Handle)
-
-
all, err := db.GetStrings(
-
s.Db,
-
0,
-
db.FilterEq("did", id.DID),
-
)
-
if err != nil {
-
l.Error("failed to fetch strings", "err", err)
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
-
slices.SortFunc(all, func(a, b db.String) int {
-
if a.Created.After(b.Created) {
-
return -1
-
} else {
-
return 1
-
}
-
})
-
-
profile, err := db.GetProfile(s.Db, id.DID.String())
-
if err != nil {
-
l.Error("failed to fetch user profile", "err", err)
-
w.WriteHeader(http.StatusInternalServerError)
-
return
-
}
-
loggedInUser := s.OAuth.GetUser(r)
-
followStatus := db.IsNotFollowing
-
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
-
}
-
-
followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
-
if err != nil {
-
l.Error("failed to get follow stats", "err", err)
-
}
-
-
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
-
LoggedInUser: s.OAuth.GetUser(r),
-
Card: pages.ProfileCard{
-
UserDid: id.DID.String(),
-
UserHandle: id.Handle.String(),
-
Profile: profile,
-
FollowStatus: followStatus,
-
FollowersCount: followStats.Followers,
-
FollowingCount: followStats.Following,
-
},
-
Strings: all,
-
})
+
http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
}
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+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)
+53 -12
docs/hacking.md
···
redis-server
```
-
## running a knot
+
## running knots and spindles
An end-to-end knot setup requires setting up a machine with
`sshd`, `AuthorizedKeysCommand`, and git user, which is
quite cumbersome. So the nix flake provides a
`nixosConfiguration` to do so.
-
To begin, grab your DID from http://localhost:3000/settings.
-
Then, set `TANGLED_VM_KNOT_OWNER` and
-
`TANGLED_VM_SPINDLE_OWNER` to your DID.
+
<details>
+
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
+
+
In order to build Tangled's dev VM on macOS, you will
+
first need to set up a Linux Nix builder. The recommended
+
way to do so is to run a [`darwin.linux-builder`
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
+
and to register it in `nix.conf` as a builder for Linux
+
with the same architecture as your Mac (`linux-aarch64` if
+
you are using Apple Silicon).
+
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
+
> the tangled repo so that it doesn't conflict with the other VM. For example,
+
> you can do
+
>
+
> ```shell
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
+
> ```
+
>
+
> to store the builder VM in a temporary dir.
+
>
+
> You should read and follow [all the other intructions][darwin builder vm] to
+
> avoid subtle problems.
+
+
Alternatively, you can use any other method to set up a
+
Linux machine with `nix` installed that you can `sudo ssh`
+
into (in other words, root user on your Mac has to be able
+
to ssh into the Linux machine without entering a password)
+
and that has the same architecture as your Mac. See
+
[remote builder
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
+
for how to register such a builder in `nix.conf`.
-
If you don't want to [set up a spindle](#running-a-spindle),
-
you can use any placeholder value.
+
> WARNING: If you'd like to use
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
+
> ssh` works can be tricky. It seems to be [possible with
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
-
You can now start a lightweight NixOS VM like so:
+
</details>
+
+
To begin, grab your DID from http://localhost:3000/settings.
+
Then, set `TANGLED_VM_KNOT_OWNER` and
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
+
lightweight NixOS VM like so:
```bash
nix run --impure .#vm
···
with `ssh` exposed on port 2222.
Once the services are running, head to
-
http://localhost:3000/knots and hit verify (and similarly,
-
http://localhost:3000/spindles to verify your spindle). It
-
should verify the ownership of the services instantly if
-
everything went smoothly.
+
http://localhost:3000/knots and hit verify. It should
+
verify the ownership of the services instantly if everything
+
went smoothly.
You can push repositories to this VM with this ssh config
block on your main machine:
···
git push local-dev main
```
-
## running a spindle
+
### running a spindle
The above VM should already be running a spindle on
`localhost:6555`. Head to http://localhost:3000/spindles and
···
# litecli has a nicer REPL interface:
litecli /var/lib/spindle/spindle.db
```
+
+
If for any reason you wish to disable either one of the
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
+
`services.tangled-spindle.enable` (or
+
`services.tangled-knot.enable`) to `false`.
-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
-
}
+7
knotserver/config/config.go
···
Dev bool `env:"DEV, default=false"`
}
+
type Git struct {
+
// user name & email used as committer
+
UserName string `env:"USER_NAME, default=Tangled"`
+
UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"`
+
}
+
func (s Server) Did() syntax.DID {
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
}
···
type Config struct {
Repo Repo `env:",prefix=KNOT_REPO_"`
Server Server `env:",prefix=KNOT_SERVER_"`
+
Git Git `env:",prefix=KNOT_GIT_"`
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
}
+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)
-
}
+58 -72
knotserver/git/merge.go
···
"github.com/dgraph-io/ristretto"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
-
"tangled.sh/tangled.sh/core/patchutil"
)
type MergeCheckCache struct {
···
// MergeOptions specifies the configuration for a merge operation
type MergeOptions struct {
-
CommitMessage string
-
CommitBody string
-
AuthorName string
-
AuthorEmail string
-
FormatPatch bool
+
CommitMessage string
+
CommitBody string
+
AuthorName string
+
AuthorEmail string
+
CommitterName string
+
CommitterEmail string
+
FormatPatch bool
}
func (e ErrMerge) Error() string {
···
return tmpDir, nil
}
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error {
+
func (g *GitRepo) checkPatch(tmpDir, patchFile string) error {
var stderr bytes.Buffer
-
var cmd *exec.Cmd
-
if checkOnly {
-
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
-
} else {
-
// if patch is a format-patch, apply using 'git am'
-
if opts.FormatPatch {
-
amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
-
amCmd.Stderr = &stderr
-
if err := amCmd.Run(); err != nil {
-
return fmt.Errorf("patch application failed: %s", stderr.String())
-
}
-
return nil
+
cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
+
cmd.Stderr = &stderr
+
+
if err := cmd.Run(); err != nil {
+
conflicts := parseGitApplyErrors(stderr.String())
+
return &ErrMerge{
+
Message: "patch cannot be applied cleanly",
+
Conflicts: conflicts,
+
HasConflict: len(conflicts) > 0,
+
OtherError: err,
}
-
-
// else, apply using 'git apply' and commit it manually
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
-
if opts != nil {
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
-
applyCmd.Stderr = &stderr
-
if err := applyCmd.Run(); err != nil {
-
return fmt.Errorf("patch application failed: %s", stderr.String())
-
}
+
}
+
return nil
+
}
-
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
-
if err := stageCmd.Run(); err != nil {
-
return fmt.Errorf("failed to stage changes: %w", err)
-
}
+
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
+
var stderr bytes.Buffer
+
var cmd *exec.Cmd
-
commitArgs := []string{"-C", tmpDir, "commit"}
+
// configure default git user before merge
+
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
+
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
-
// Set author if provided
-
authorName := opts.AuthorName
-
authorEmail := opts.AuthorEmail
+
// if patch is a format-patch, apply using 'git am'
+
if opts.FormatPatch {
+
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
+
} else {
+
// else, apply using 'git apply' and commit it manually
+
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
+
applyCmd.Stderr = &stderr
+
if err := applyCmd.Run(); err != nil {
+
return fmt.Errorf("patch application failed: %s", stderr.String())
+
}
-
if authorEmail == "" {
-
authorEmail = "noreply@tangled.sh"
-
}
+
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
+
if err := stageCmd.Run(); err != nil {
+
return fmt.Errorf("failed to stage changes: %w", err)
+
}
-
if authorName == "" {
-
authorName = "Tangled"
-
}
+
commitArgs := []string{"-C", tmpDir, "commit"}
-
if authorName != "" {
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
-
}
+
// Set author if provided
+
authorName := opts.AuthorName
+
authorEmail := opts.AuthorEmail
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
+
if authorName != "" && authorEmail != "" {
+
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
+
}
+
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
-
if opts.CommitBody != "" {
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
-
}
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
-
cmd = exec.Command("git", commitArgs...)
-
} else {
-
// If no commit message specified, use git-am which automatically creates a commit
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
+
if opts.CommitBody != "" {
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
}
+
+
cmd = exec.Command("git", commitArgs...)
}
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
-
if checkOnly {
-
conflicts := parseGitApplyErrors(stderr.String())
-
return &ErrMerge{
-
Message: "patch cannot be applied cleanly",
-
Conflicts: conflicts,
-
HasConflict: len(conflicts) > 0,
-
OtherError: err,
-
}
-
}
return fmt.Errorf("patch application failed: %s", stderr.String())
}
···
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
return val
}
-
-
var opts MergeOptions
-
opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
patchFile, err := g.createTempFileWithPatch(patchData)
if err != nil {
···
}
defer os.RemoveAll(tmpDir)
-
result := g.applyPatch(tmpDir, patchFile, true, &opts)
+
result := g.checkPatch(tmpDir, patchFile)
mergeCheckCache.Set(g, patchData, targetBranch, result)
return result
}
-
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
-
return g.MergeWithOptions(patchData, targetBranch, nil)
-
}
-
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error {
+
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
patchFile, err := g.createTempFileWithPatch(patchData)
if err != nil {
return &ErrMerge{
···
}
defer os.RemoveAll(tmpDir)
-
if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil {
+
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
return err
}
+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
+
}
-217
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
-
for _, mod := range info.Deps {
-
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
-
version = mod.Version
-
break
-
}
-
}
-
-
if modVer == "" {
-
version = "unknown"
-
}
-
}
-
-
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
+
}
+
}
+3 -1
knotserver/xrpc/merge.go
···
return
}
-
mo := &git.MergeOptions{}
+
mo := git.MergeOptions{}
if data.AuthorName != nil {
mo.AuthorName = *data.AuthorName
}
···
mo.CommitMessage = *data.CommitMessage
}
+
mo.CommitterName = x.Config.Git.UserName
+
mo.CommitterEmail = x.Config.Git.UserEmail
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+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) {
+158
legal/privacy.md
···
+
# Privacy Policy
+
+
**Last updated:** January 15, 2025
+
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
+
collects, uses, and shares your personal information when you use our
+
platform and services (the "Service").
+
+
## 1. Information We Collect
+
+
### Account Information
+
+
When you create an account, we collect:
+
+
- Your chosen username
+
- Email address
+
- Profile information you choose to provide
+
- Authentication data
+
+
### Content and Activity
+
+
We store:
+
+
- Code repositories and associated metadata
+
- Issues, pull requests, and comments
+
- Activity logs and usage patterns
+
- Public keys for authentication
+
+
## 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
+
+
### 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
+
+
We only share your data with the following third-party processors:
+
+
### Resend (Email Services)
+
+
- **Purpose:** Sending transactional emails (account verification,
+
notifications)
+
- **Data Shared:** Email address and necessary message content
+
+
### Cloudflare (Image Caching)
+
+
- **Purpose:** Caching and optimizing image delivery
+
- **Data Shared:** Public images and associated metadata for caching
+
purposes
+
+
### 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
+
+
We use your information to:
+
+
- Provide and maintain the Service
+
- Process your transactions and requests
+
- Send you technical notices and support messages
+
- Improve and develop new features
+
- Ensure security and prevent fraud
+
- Comply with legal obligations
+
+
## 5. Data Sharing and Disclosure
+
+
We do not sell, trade, or rent your personal information. We may share
+
your information only in the following circumstances:
+
+
- With the third-party processors listed above
+
- When required by law or legal process
+
- To protect our rights, property, or safety, or that of our users
+
- In connection with a merger, acquisition, or sale of assets (with
+
appropriate protections)
+
+
## 6. Data Security
+
+
We implement appropriate technical and organizational measures to
+
protect your personal information against unauthorized access,
+
alteration, disclosure, or destruction. However, no method of
+
transmission over the Internet is 100% secure.
+
+
## 7. Data Retention
+
+
We retain your personal information for as long as necessary to provide
+
the Service and fulfill the purposes outlined in this Privacy Policy,
+
unless a longer retention period is required by law.
+
+
## 8. Your Rights
+
+
Under applicable data protection laws, you have the right to:
+
+
- Access your personal information
+
- Correct inaccurate information
+
- Request deletion of your information
+
- Object to processing of your information
+
- Data portability
+
- Withdraw consent (where applicable)
+
+
## 9. Cookies and Tracking
+
+
We use cookies and similar technologies to:
+
+
- Maintain your login session
+
- Remember your preferences
+
- Analyze usage patterns to improve the Service
+
+
You can control cookie settings through your browser preferences.
+
+
## 10. Children's Privacy
+
+
The Service is not intended for children under 16 years of age. We do
+
not knowingly collect personal information from children under 16. If
+
we become aware that we have collected such information, we will take
+
steps to delete it.
+
+
## 11. International Data Transfers
+
+
While all our primary data processing occurs within the EU, some of our
+
third-party processors may process data outside the EU. When this
+
occurs, we ensure appropriate safeguards are in place, such as Standard
+
Contractual Clauses or adequacy decisions.
+
+
## 12. Changes to This Privacy Policy
+
+
We may update this Privacy Policy from time to time. We will notify you
+
of any changes by posting the new Privacy Policy on this page and
+
updating the "Last updated" date.
+
+
## 13. Contact Information
+
+
If you have any questions about this Privacy Policy or wish to exercise
+
your rights, please contact us through our platform or via email.
+
+
---
+
+
This Privacy Policy complies with the EU General Data Protection
+
Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
···
+
# Terms of Service
+
+
**Last updated:** January 15, 2025
+
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
+
to and use of the Tangled platform and services (the "Service")
+
operated by us ("Tangled," "we," "us," or "our").
+
+
## 1. Acceptance of Terms
+
+
By accessing or using our Service, you agree to be bound by these Terms.
+
If you disagree with any part of these terms, then you may not access
+
the Service.
+
+
## 2. Account Registration
+
+
To use certain features of the Service, you must register for an
+
account. You agree to provide accurate, current, and complete
+
information during the registration process and to update such
+
information to keep it accurate, current, and complete.
+
+
## 3. Account Termination
+
+
> **Important Notice**
+
>
+
> **We reserve the right to terminate, suspend, or restrict access to
+
> your account at any time, for any reason, or for no reason at all, at
+
> our sole discretion.** This includes, but is not limited to,
+
> termination for violation of these Terms, inappropriate conduct, spam,
+
> abuse, or any other behavior we deem harmful to the Service or other
+
> users.
+
>
+
> Account termination may result in the loss of access to your
+
> repositories, data, and other content associated with your account. We
+
> are not obligated to provide advance notice of termination, though we
+
> may do so in our discretion.
+
+
## 4. Acceptable Use
+
+
You agree not to use the Service to:
+
+
- Violate any applicable laws or regulations
+
- Infringe upon the rights of others
+
- Upload, store, or share content that is illegal, harmful, threatening,
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
+
objectionable
+
- Engage in spam, phishing, or other deceptive practices
+
- Attempt to gain unauthorized access to the Service or other users'
+
accounts
+
- Interfere with or disrupt the Service or servers connected to the
+
Service
+
+
## 5. Content and Intellectual Property
+
+
You retain ownership of the content you upload to the Service. By
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
+
license to use, reproduce, modify, and distribute your content as
+
necessary to provide the Service.
+
+
## 6. Privacy
+
+
Your privacy is important to us. Please review our [Privacy
+
Policy](/privacy), which also governs your use of the Service.
+
+
## 7. Disclaimers
+
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
+
no warranties, expressed or implied, and hereby disclaim and negate all
+
other warranties including without limitation, implied warranties or
+
conditions of merchantability, fitness for a particular purpose, or
+
non-infringement of intellectual property or other violation of rights.
+
+
## 8. Limitation of Liability
+
+
In no event shall Tangled, nor its directors, employees, partners,
+
agents, suppliers, or affiliates, be liable for any indirect,
+
incidental, special, consequential, or punitive damages, including
+
without limitation, loss of profits, data, use, goodwill, or other
+
intangible losses, resulting from your use of the Service.
+
+
## 9. Indemnification
+
+
You agree to defend, indemnify, and hold harmless Tangled and its
+
affiliates, officers, directors, employees, and agents from and against
+
any and all claims, damages, obligations, losses, liabilities, costs,
+
or debt, and expenses (including attorney's fees).
+
+
## 10. Governing Law
+
+
These Terms shall be interpreted and governed by the laws of Finland,
+
without regard to its conflict of law provisions.
+
+
## 11. Changes to Terms
+
+
We reserve the right to modify or replace these Terms at any time. If a
+
revision is material, we will try to provide at least 30 days notice
+
prior to any new terms taking effect.
+
+
## 12. Contact Information
+
+
If you have any questions about these Terms of Service, please contact
+
us through our platform or via email.
+
+
---
+
+
These terms are effective as of the last updated date shown above and
+
will remain in effect except with respect to any changes in their
+
provisions in the future, which will be in effect immediately after
+
being posted on this page.
+4 -11
lexicons/issue/comment.json
···
"type": "string",
"format": "at-uri"
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"commentId": {
-
"type": "integer"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"body": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "datetime"
+
},
+
"replyTo": {
+
"type": "string",
+
"format": "at-uri"
}
}
}
+1 -14
lexicons/issue/issue.json
···
"key": "tid",
"record": {
"type": "object",
-
"required": [
-
"repo",
-
"issueId",
-
"owner",
-
"title",
-
"createdAt"
-
],
+
"required": ["repo", "title", "createdAt"],
"properties": {
"repo": {
"type": "string",
"format": "at-uri"
-
},
-
"issueId": {
-
"type": "integer"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
},
"title": {
"type": "string"
+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="
+16
nix/modules/spindle.nix
···
description = "DID of owner (required)";
};
+
maxJobCount = mkOption {
+
type = types.int;
+
default = 2;
+
example = 5;
+
description = "Maximum number of concurrent jobs to run";
+
};
+
+
queueSize = mkOption {
+
type = types.int;
+
default = 100;
+
example = 100;
+
description = "Maximum number of jobs queue up";
+
};
+
secrets = {
provider = mkOption {
type = types.str;
···
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
+
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+
"SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}"
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+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;
+
}
+2
nix/vm.nix
···
hostname = "localhost:6555";
listenAddr = "0.0.0.0:6555";
dev = true;
+
queueSize = 100;
+
maxJobCount = 2;
secrets = {
provider = "sqlite";
};
+1 -1
patchutil/combinediff.go
···
// we have f1 and f2, combine them
combined, err := combineFiles(f1, f2)
if err != nil {
-
fmt.Println(err)
+
// fmt.Println(err)
}
// combined can be nil commit 2 reverted all changes from commit 1
+2
spindle/config/config.go
···
Owner string `env:"OWNER, required"`
Secrets Secrets `env:",prefix=SECRETS_"`
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
+
QueueSize int `env:"QUEUE_SIZE, default=100"`
+
MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
}
func (s Server) Did() syntax.DID {
+2 -4
spindle/server.go
···
return err
}
-
jq := queue.NewQueue(100, 5)
+
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
+
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
collections := []string{
tangled.SpindleMemberNSID,
···
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"),