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

Compare changes

Choose any two refs to compare.

Changed files
+14037 -8100
api
appview
config
db
issues
knots
middleware
oauth
pages
markup
repoinfo
templates
errors
fragments
knots
layouts
legal
repo
spindles
strings
timeline
user
posthog
pulls
repo
serververify
settings
spindles
spindleverify
state
strings
validator
xrpcclient
cmd
appview
docs
knotclient
knotserver
legal
lexicons
nix
patchutil
rbac
spindle
xrpc
errors
serviceauth
+518 -732
api/tangled/cbor_gen.go
···
return nil
-
func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error {
-
if t == nil {
-
_, err := w.Write(cbg.CborNull)
-
return err
-
}
-
-
cw := cbg.NewCborWriter(w)
-
fieldCount := 3
-
-
if t.LangBreakdown == nil {
-
fieldCount--
-
}
-
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
-
return err
-
}
-
-
// t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct)
-
if len("commitCount") > 1000000 {
-
return xerrors.Errorf("Value in field \"commitCount\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("commitCount")); err != nil {
-
return err
-
}
-
-
if err := t.CommitCount.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
-
// t.IsDefaultRef (bool) (bool)
-
if len("isDefaultRef") > 1000000 {
-
return xerrors.Errorf("Value in field \"isDefaultRef\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("isDefaultRef")); err != nil {
-
return err
-
}
-
-
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
-
return err
-
}
-
-
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
-
if t.LangBreakdown != nil {
-
-
if len("langBreakdown") > 1000000 {
-
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
-
return err
-
}
-
-
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
}
-
return nil
-
}
-
-
func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = GitRefUpdate_Meta{}
-
-
cr := cbg.NewCborReader(r)
-
-
maj, extra, err := cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
defer func() {
-
if err == io.EOF {
-
err = io.ErrUnexpectedEOF
-
}
-
}()
-
-
if maj != cbg.MajMap {
-
return fmt.Errorf("cbor input should be of type map")
-
}
-
-
if extra > cbg.MaxLength {
-
return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra)
-
}
-
-
n := extra
-
-
nameBuf := make([]byte, 13)
-
for i := uint64(0); i < n; i++ {
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
-
if err != nil {
-
return err
-
}
-
-
if !ok {
-
// Field doesn't exist on this type, so ignore it
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
-
return err
-
}
-
continue
-
}
-
-
switch string(nameBuf[:nameLen]) {
-
// t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct)
-
case "commitCount":
-
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
t.CommitCount = new(GitRefUpdate_Meta_CommitCount)
-
if err := t.CommitCount.UnmarshalCBOR(cr); err != nil {
-
return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err)
-
}
-
}
-
-
}
-
// t.IsDefaultRef (bool) (bool)
-
case "isDefaultRef":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
if maj != cbg.MajOther {
-
return fmt.Errorf("booleans must be major type 7")
-
}
-
switch extra {
-
case 20:
-
t.IsDefaultRef = false
-
case 21:
-
t.IsDefaultRef = true
-
default:
-
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
-
}
-
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
-
case "langBreakdown":
-
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown)
-
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
-
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
-
}
-
}
-
-
}
-
-
default:
-
// Field doesn't exist on this type, so ignore it
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
-
return err
-
}
-
}
-
}
-
-
return nil
-
}
-
func (t *GitRefUpdate_Meta_CommitCount) MarshalCBOR(w io.Writer) error {
+
func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
···
return err
-
// t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice)
+
// t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice)
if t.ByEmail != nil {
if len("byEmail") > 1000000 {
···
return nil
-
func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = GitRefUpdate_Meta_CommitCount{}
+
func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = GitRefUpdate_CommitCountBreakdown{}
cr := cbg.NewCborReader(r)
···
if extra > cbg.MaxLength {
-
return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra)
+
return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra)
n := extra
···
switch string(nameBuf[:nameLen]) {
-
// t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice)
+
// t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice)
case "byEmail":
maj, extra, err = cr.ReadHeader()
···
if extra > 0 {
-
t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra)
+
t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra)
for i := 0; i < int(extra); i++ {
···
if err := cr.UnreadByte(); err != nil {
return err
-
t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem)
+
t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount)
if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil {
return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err)
···
return nil
-
func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error {
+
func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
···
return nil
-
func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}
+
func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = GitRefUpdate_IndividualEmailCommitCount{}
cr := cbg.NewCborReader(r)
···
if extra > cbg.MaxLength {
-
return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra)
+
return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra)
n := extra
···
return nil
-
func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error {
+
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
···
return err
-
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
if t.Inputs != nil {
if len("inputs") > 1000000 {
···
return nil
-
func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = GitRefUpdate_Meta_LangBreakdown{}
+
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = GitRefUpdate_LangBreakdown{}
cr := cbg.NewCborReader(r)
···
if extra > cbg.MaxLength {
-
return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra)
+
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
n := extra
···
switch string(nameBuf[:nameLen]) {
-
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
case "inputs":
maj, extra, err = cr.ReadHeader()
···
if extra > 0 {
-
t.Inputs = make([]*GitRefUpdate_Pair, extra)
+
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
for i := 0; i < int(extra); i++ {
···
if err := cr.UnreadByte(); err != nil {
return err
-
t.Inputs[i] = new(GitRefUpdate_Pair)
+
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
···
return nil
-
func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error {
+
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
···
return nil
-
func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = GitRefUpdate_Pair{}
+
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = GitRefUpdate_IndividualLanguageSize{}
cr := cbg.NewCborReader(r)
···
if extra > cbg.MaxLength {
-
return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra)
+
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
n := extra
···
return nil
+
func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 3
+
+
if t.LangBreakdown == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
return err
+
}
+
+
// t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct)
+
if len("commitCount") > 1000000 {
+
return xerrors.Errorf("Value in field \"commitCount\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("commitCount")); err != nil {
+
return err
+
}
+
+
if err := t.CommitCount.MarshalCBOR(cw); err != nil {
+
return err
+
}
+
+
// t.IsDefaultRef (bool) (bool)
+
if len("isDefaultRef") > 1000000 {
+
return xerrors.Errorf("Value in field \"isDefaultRef\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("isDefaultRef")); err != nil {
+
return err
+
}
+
+
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
+
return err
+
}
+
+
// t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct)
+
if t.LangBreakdown != nil {
+
+
if len("langBreakdown") > 1000000 {
+
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
+
return err
+
}
+
+
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
+
return err
+
}
+
}
+
return nil
+
}
+
+
func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = GitRefUpdate_Meta{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 13)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct)
+
case "commitCount":
+
+
{
+
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.CommitCount = new(GitRefUpdate_CommitCountBreakdown)
+
if err := t.CommitCount.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err)
+
}
+
}
+
+
}
+
// t.IsDefaultRef (bool) (bool)
+
case "isDefaultRef":
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
if maj != cbg.MajOther {
+
return fmt.Errorf("booleans must be major type 7")
+
}
+
switch extra {
+
case 20:
+
t.IsDefaultRef = false
+
case 21:
+
t.IsDefaultRef = true
+
default:
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
+
}
+
// t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct)
+
case "langBreakdown":
+
+
{
+
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.LangBreakdown = new(GitRefUpdate_LangBreakdown)
+
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
+
}
+
}
+
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
func (t *GraphFollow) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
t.Subject = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+
func (t *Knot) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{162}); err != nil {
+
return err
+
}
+
+
// t.LexiconTypeID (string) (string)
+
if len("$type") > 1000000 {
+
return xerrors.Errorf("Value in field \"$type\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("$type")); err != nil {
+
return err
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil {
+
return err
+
}
+
+
// t.CreatedAt (string) (string)
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
return err
+
}
+
+
if len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = Knot{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("Knot: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 9)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
// t.CreatedAt (string) (string)
case "createdAt":
···
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
-
-
if t.CommentId == nil {
-
fieldCount--
-
}
+
fieldCount := 5
-
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)
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 9
+
fieldCount := 7
if t.Body == nil {
fieldCount--
···
return err
-
// t.PullId (int64) (int64)
-
if len("pullId") > 1000000 {
-
return xerrors.Errorf("Value in field \"pullId\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("pullId")); err != nil {
-
return err
-
}
-
-
if t.PullId >= 0 {
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil {
-
return err
-
}
-
} else {
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil {
-
return err
-
}
-
}
-
// t.Source (tangled.RepoPull_Source) (struct)
if t.Source != nil {
···
-
// t.CreatedAt (string) (string)
-
if len("createdAt") > 1000000 {
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
// t.Target (tangled.RepoPull_Target) (struct)
+
if len("target") > 1000000 {
+
return xerrors.Errorf("Value in field \"target\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil {
return err
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
if _, err := cw.WriteString(string("target")); err != nil {
return err
-
if len(t.CreatedAt) > 1000000 {
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+
if err := t.Target.MarshalCBOR(cw); err != nil {
return err
-
// t.TargetRepo (string) (string)
-
if len("targetRepo") > 1000000 {
-
return xerrors.Errorf("Value in field \"targetRepo\" was too long")
+
// t.CreatedAt (string) (string)
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
return err
-
if _, err := cw.WriteString(string("targetRepo")); err != nil {
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
return err
-
if len(t.TargetRepo) > 1000000 {
-
return xerrors.Errorf("Value in field t.TargetRepo was too long")
+
if len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
return err
-
if _, err := cw.WriteString(string(t.TargetRepo)); err != nil {
-
return err
-
}
-
-
// t.TargetBranch (string) (string)
-
if len("targetBranch") > 1000000 {
-
return xerrors.Errorf("Value in field \"targetBranch\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("targetBranch")); err != nil {
-
return err
-
}
-
-
if len(t.TargetBranch) > 1000000 {
-
return xerrors.Errorf("Value in field t.TargetBranch was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.TargetBranch)); err != nil {
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
return nil
···
n := extra
-
nameBuf := make([]byte, 12)
+
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
t.Title = string(sval)
-
// t.PullId (int64) (int64)
-
case "pullId":
-
{
-
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.PullId = int64(extraI)
-
}
// t.Source (tangled.RepoPull_Source) (struct)
case "source":
···
-
// t.CreatedAt (string) (string)
-
case "createdAt":
+
// t.Target (tangled.RepoPull_Target) (struct)
+
case "target":
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
+
b, err := cr.ReadByte()
if err != nil {
return err
-
-
t.CreatedAt = string(sval)
-
}
-
// t.TargetRepo (string) (string)
-
case "targetRepo":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.Target = new(RepoPull_Target)
+
if err := t.Target.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.Target pointer: %w", err)
+
}
-
t.TargetRepo = string(sval)
-
// t.TargetBranch (string) (string)
-
case "targetBranch":
+
// t.CreatedAt (string) (string)
+
case "createdAt":
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
return err
-
t.TargetBranch = string(sval)
+
t.CreatedAt = string(sval)
default:
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
-
if t.CommentId == nil {
-
fieldCount--
-
}
-
-
if t.Owner == nil {
-
fieldCount--
-
}
-
-
if t.Repo == nil {
-
fieldCount--
-
}
-
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
if _, err := cw.Write([]byte{164}); err != nil {
return err
···
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 {
-
-
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 t.Owner == 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 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.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.Pull = 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.LexiconTypeID = string(sval)
-
// t.Owner (string) (string)
-
case "owner":
-
-
{
-
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.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.CreatedAt (string) (string)
case "createdAt":
···
t.Status = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+
func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{162}); err != nil {
+
return err
+
}
+
+
// t.Repo (string) (string)
+
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 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.Branch (string) (string)
+
if len("branch") > 1000000 {
+
return xerrors.Errorf("Value in field \"branch\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("branch")); err != nil {
+
return err
+
}
+
+
if len(t.Branch) > 1000000 {
+
return xerrors.Errorf("Value in field t.Branch was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Branch)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = RepoPull_Target{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 6)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.Repo (string) (string)
+
case "repo":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Repo = string(sval)
+
}
+
// t.Branch (string) (string)
+
case "branch":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Branch = string(sval)
default:
+19 -15
api/tangled/gitrefUpdate.go
···
RepoName string `json:"repoName" cborgen:"repoName"`
}
-
type GitRefUpdate_Meta struct {
-
CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"`
-
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
-
LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
+
// GitRefUpdate_CommitCountBreakdown is a "commitCountBreakdown" in the sh.tangled.git.refUpdate schema.
+
type GitRefUpdate_CommitCountBreakdown struct {
+
ByEmail []*GitRefUpdate_IndividualEmailCommitCount `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"`
}
-
type GitRefUpdate_Meta_CommitCount struct {
-
ByEmail []*GitRefUpdate_Meta_CommitCount_ByEmail_Elem `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"`
-
}
-
-
type GitRefUpdate_Meta_CommitCount_ByEmail_Elem struct {
+
// GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema.
+
type GitRefUpdate_IndividualEmailCommitCount struct {
Count int64 `json:"count" cborgen:"count"`
Email string `json:"email" cborgen:"email"`
}
-
type GitRefUpdate_Meta_LangBreakdown struct {
-
Inputs []*GitRefUpdate_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
-
}
-
-
// GitRefUpdate_Pair is a "pair" in the sh.tangled.git.refUpdate schema.
-
type GitRefUpdate_Pair struct {
+
// GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema.
+
type GitRefUpdate_IndividualLanguageSize struct {
Lang string `json:"lang" cborgen:"lang"`
Size int64 `json:"size" cborgen:"size"`
}
+
+
// GitRefUpdate_LangBreakdown is a "langBreakdown" in the sh.tangled.git.refUpdate schema.
+
type GitRefUpdate_LangBreakdown struct {
+
Inputs []*GitRefUpdate_IndividualLanguageSize `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
+
}
+
+
// GitRefUpdate_Meta is a "meta" in the sh.tangled.git.refUpdate schema.
+
type GitRefUpdate_Meta struct {
+
CommitCount *GitRefUpdate_CommitCountBreakdown `json:"commitCount" cborgen:"commitCount"`
+
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
+
LangBreakdown *GitRefUpdate_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
+
}
+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
+
}
+4 -7
api/tangled/pullcomment.go
···
} //
// RECORDTYPE: RepoPullComment
type RepoPullComment struct {
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
-
Body string `json:"body" cborgen:"body"`
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-
Pull string `json:"pull" cborgen:"pull"`
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
+
Body string `json:"body" cborgen:"body"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Pull string `json:"pull" cborgen:"pull"`
}
+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
+
}
+34
api/tangled/repocreate.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.create
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoCreateNSID = "sh.tangled.repo.create"
+
)
+
+
// RepoCreate_Input is the input argument to a sh.tangled.repo.create call.
+
type RepoCreate_Input struct {
+
// defaultBranch: Default branch to push to
+
DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
+
// rkey: Rkey of the repository record
+
Rkey string `json:"rkey" cborgen:"rkey"`
+
// source: A source URL to clone from, populate this when forking or importing a repository.
+
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
+
}
+
+
// RepoCreate calls the XRPC method "sh.tangled.repo.create".
+
func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+34
api/tangled/repodelete.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.delete
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoDeleteNSID = "sh.tangled.repo.delete"
+
)
+
+
// RepoDelete_Input is the input argument to a sh.tangled.repo.delete call.
+
type RepoDelete_Input struct {
+
// did: DID of the repository owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the repository to delete
+
Name string `json:"name" cborgen:"name"`
+
// rkey: Rkey of the repository record
+
Rkey string `json:"rkey" cborgen:"rkey"`
+
}
+
+
// RepoDelete calls the XRPC method "sh.tangled.repo.delete".
+
func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil {
+
return err
+
}
+
+
return 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
+
}
+45
api/tangled/repoforkStatus.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.forkStatus
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoForkStatusNSID = "sh.tangled.repo.forkStatus"
+
)
+
+
// RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call.
+
type RepoForkStatus_Input struct {
+
// branch: Branch to check status for
+
Branch string `json:"branch" cborgen:"branch"`
+
// did: DID of the fork owner
+
Did string `json:"did" cborgen:"did"`
+
// hiddenRef: Hidden ref to use for comparison
+
HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"`
+
// name: Name of the forked repository
+
Name string `json:"name" cborgen:"name"`
+
// source: Source repository URL
+
Source string `json:"source" cborgen:"source"`
+
}
+
+
// RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call.
+
type RepoForkStatus_Output struct {
+
// status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch
+
Status int64 `json:"status" cborgen:"status"`
+
}
+
+
// RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus".
+
func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) {
+
var out RepoForkStatus_Output
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+36
api/tangled/repoforkSync.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.forkSync
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoForkSyncNSID = "sh.tangled.repo.forkSync"
+
)
+
+
// RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call.
+
type RepoForkSync_Input struct {
+
// branch: Branch to sync
+
Branch string `json:"branch" cborgen:"branch"`
+
// did: DID of the fork owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the forked repository
+
Name string `json:"name" cborgen:"name"`
+
// source: AT-URI of the source repository
+
Source string `json:"source" cborgen:"source"`
+
}
+
+
// RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync".
+
func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil {
+
return err
+
}
+
+
return 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
+
}
+45
api/tangled/repohiddenRef.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.hiddenRef
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef"
+
)
+
+
// RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call.
+
type RepoHiddenRef_Input struct {
+
// forkRef: Fork reference name
+
ForkRef string `json:"forkRef" cborgen:"forkRef"`
+
// remoteRef: Remote reference name
+
RemoteRef string `json:"remoteRef" cborgen:"remoteRef"`
+
// repo: AT-URI of the repository
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call.
+
type RepoHiddenRef_Output struct {
+
// error: Error message if creation failed
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
+
// ref: The created hidden ref name
+
Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"`
+
// success: Whether the hidden ref was created successfully
+
Success bool `json:"success" cborgen:"success"`
+
}
+
+
// RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef".
+
func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) {
+
var out RepoHiddenRef_Output
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &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
+
}
+44
api/tangled/repomerge.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.merge
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoMergeNSID = "sh.tangled.repo.merge"
+
)
+
+
// RepoMerge_Input is the input argument to a sh.tangled.repo.merge call.
+
type RepoMerge_Input struct {
+
// authorEmail: Author email for the merge commit
+
AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"`
+
// authorName: Author name for the merge commit
+
AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"`
+
// branch: Target branch to merge into
+
Branch string `json:"branch" cborgen:"branch"`
+
// commitBody: Additional commit message body
+
CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"`
+
// commitMessage: Merge commit message
+
CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"`
+
// did: DID of the repository owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the repository
+
Name string `json:"name" cborgen:"name"`
+
// patch: Patch content to merge
+
Patch string `json:"patch" cborgen:"patch"`
+
}
+
+
// RepoMerge calls the XRPC method "sh.tangled.repo.merge".
+
func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+57
api/tangled/repomergeCheck.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.mergeCheck
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck"
+
)
+
+
// RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema.
+
type RepoMergeCheck_ConflictInfo struct {
+
// filename: Name of the conflicted file
+
Filename string `json:"filename" cborgen:"filename"`
+
// reason: Reason for the conflict
+
Reason string `json:"reason" cborgen:"reason"`
+
}
+
+
// RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call.
+
type RepoMergeCheck_Input struct {
+
// branch: Target branch to merge into
+
Branch string `json:"branch" cborgen:"branch"`
+
// did: DID of the repository owner
+
Did string `json:"did" cborgen:"did"`
+
// name: Name of the repository
+
Name string `json:"name" cborgen:"name"`
+
// patch: Patch or pull request to check for merge conflicts
+
Patch string `json:"patch" cborgen:"patch"`
+
}
+
+
// RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call.
+
type RepoMergeCheck_Output struct {
+
// conflicts: List of files with merge conflicts
+
Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"`
+
// error: Error message if check failed
+
Error *string `json:"error,omitempty" cborgen:"error,omitempty"`
+
// is_conflicted: Whether the merge has conflicts
+
Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"`
+
// message: Additional message about the merge check
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
}
+
+
// RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck".
+
func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) {
+
var out RepoMergeCheck_Output
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+7 -3
api/tangled/repopull.go
···
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
Patch string `json:"patch" cborgen:"patch"`
-
PullId int64 `json:"pullId" cborgen:"pullId"`
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
Title string `json:"title" cborgen:"title"`
}
···
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
Sha string `json:"sha" cborgen:"sha"`
}
+
+
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
+
type RepoPull_Target struct {
+
Branch string `json:"branch" cborgen:"branch"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+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
+
}
+22
api/tangled/tangledknot.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.knot
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
KnotNSID = "sh.tangled.knot"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.knot", &Knot{})
+
} //
+
// RECORDTYPE: Knot
+
type Knot struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
}
+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
+
}
+4 -1
appview/config/config.go
···
Dev bool `env:"DEV, default=false"`
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
-
// temporarily, to add users to default spindle
+
// temporarily, to add users to default knot and spindle
AppPassword string `env:"APP_PASSWORD"`
+
+
// uhhhh this is because knot1 is under icy's did
+
TmpAltAppPassword string `env:"ALT_APP_PASSWORD"`
}
type OAuthConfig struct {
+198
appview/db/db.go
···
id integer primary key autoincrement,
name text unique
);
+
+
-- indexes for better star query performance
+
create index if not exists idx_stars_created on stars(created);
+
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
`)
if err != nil {
return nil, err
···
return nil
})
+
// drop all knot secrets, add unique constraint to knots
+
//
+
// knots will henceforth use service auth for signed requests
+
runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
create table registrations_new (
+
id integer primary key autoincrement,
+
domain text not null,
+
did text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
registered text,
+
read_only integer not null default 0,
+
unique(domain, did)
+
);
+
+
insert into registrations_new (id, domain, did, created, registered, read_only)
+
select id, domain, did, created, registered, 1 from registrations
+
where registered is not null;
+
+
drop table registrations;
+
alter table registrations_new rename to registrations;
+
`)
+
return err
+
})
+
// recreate and add rkey + created columns with default constraint
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
// create new table
···
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 {
+145 -42
appview/db/follow.go
···
package db
import (
+
"fmt"
"log"
+
"strings"
"time"
)
···
return err
}
-
func GetFollowerFollowingCount(e Execer, did string) (int, int, error) {
-
followers, following := 0, 0
+
type FollowStats struct {
+
Followers int64
+
Following int64
+
}
+
+
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
+
var followers, following int64
err := e.QueryRow(
-
`SELECT
+
`SELECT
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
FROM follows;`, did, did).Scan(&followers, &following)
if err != nil {
-
return 0, 0, err
+
return FollowStats{}, err
}
-
return followers, following, nil
+
return FollowStats{
+
Followers: followers,
+
Following: following,
+
}, nil
}
-
type FollowStatus int
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
+
if len(dids) == 0 {
+
return nil, nil
+
}
-
const (
-
IsNotFollowing FollowStatus = iota
-
IsFollowing
-
IsSelf
-
)
+
placeholders := make([]string, len(dids))
+
for i := range placeholders {
+
placeholders[i] = "?"
+
}
+
placeholderStr := strings.Join(placeholders, ",")
-
func (s FollowStatus) String() string {
-
switch s {
-
case IsNotFollowing:
-
return "IsNotFollowing"
-
case IsFollowing:
-
return "IsFollowing"
-
case IsSelf:
-
return "IsSelf"
-
default:
-
return "IsNotFollowing"
+
args := make([]any, len(dids)*2)
+
for i, did := range dids {
+
args[i] = did
+
args[i+len(dids)] = did
}
-
}
+
+
query := fmt.Sprintf(`
+
select
+
coalesce(f.did, g.did) as did,
+
coalesce(f.followers, 0) as followers,
+
coalesce(g.following, 0) as following
+
from (
+
select subject_did as did, count(*) as followers
+
from follows
+
where subject_did in (%s)
+
group by subject_did
+
) f
+
full outer join (
+
select user_did as did, count(*) as following
+
from follows
+
where user_did in (%s)
+
group by user_did
+
) g on f.did = g.did`,
+
placeholderStr, placeholderStr)
+
+
result := make(map[string]FollowStats)
+
+
rows, err := e.Query(query, args...)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var did string
+
var followers, following int64
+
if err := rows.Scan(&did, &followers, &following); err != nil {
+
return nil, err
+
}
+
result[did] = FollowStats{
+
Followers: followers,
+
Following: following,
+
}
+
}
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
-
if userDid == subjectDid {
-
return IsSelf
-
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
-
return IsNotFollowing
-
} else {
-
return IsFollowing
+
for _, did := range dids {
+
if _, exists := result[did]; !exists {
+
result[did] = FollowStats{
+
Followers: 0,
+
Following: 0,
+
}
+
}
}
+
+
return result, nil
}
-
func GetAllFollows(e Execer, limit int) ([]Follow, error) {
+
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
var follows []Follow
-
rows, err := e.Query(`
-
select user_did, subject_did, followed_at, rkey
+
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 ")
+
}
+
limitClause := ""
+
if limit > 0 {
+
limitClause = " limit ?"
+
args = append(args, limit)
+
}
+
+
query := fmt.Sprintf(
+
`select user_did, subject_did, followed_at, rkey
from follows
+
%s
order by followed_at desc
-
limit ?`, limit,
-
)
+
%s
+
`, whereClause, limitClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
-
defer rows.Close()
-
for rows.Next() {
var follow Follow
var followedAt string
-
if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil {
+
err := rows.Scan(
+
&follow.UserDid,
+
&follow.SubjectDid,
+
&followedAt,
+
&follow.Rkey,
+
)
+
if err != nil {
return nil, err
}
-
followedAtTime, err := time.Parse(time.RFC3339, followedAt)
if err != nil {
log.Println("unable to determine followed at time")
···
} else {
follow.FollowedAt = followedAtTime
}
-
follows = append(follows, follow)
}
+
return follows, nil
+
}
+
+
func GetFollowers(e Execer, did string) ([]Follow, error) {
+
return GetFollows(e, 0, FilterEq("subject_did", did))
+
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
func GetFollowing(e Execer, did string) ([]Follow, error) {
+
return GetFollows(e, 0, FilterEq("user_did", did))
+
}
+
+
type FollowStatus int
+
+
const (
+
IsNotFollowing FollowStatus = iota
+
IsFollowing
+
IsSelf
+
)
+
+
func (s FollowStatus) String() string {
+
switch s {
+
case IsNotFollowing:
+
return "IsNotFollowing"
+
case IsFollowing:
+
return "IsFollowing"
+
case IsSelf:
+
return "IsSelf"
+
default:
+
return "IsNotFollowing"
}
+
}
-
return follows, nil
+
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
+
if userDid == subjectDid {
+
return IsSelf
+
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
+
return IsNotFollowing
+
} else {
+
return IsFollowing
+
}
}
+454 -311
appview/db/issues.go
···
import (
"database/sql"
"fmt"
+
"maps"
+
"slices"
+
"sort"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
)
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"
}
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
-
defer tx.Rollback()
+
type CommentListItem struct {
+
Self *IssueComment
+
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
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
+
+
// collect top level comments into the map
+
for _, comment := range i.Comments {
+
if comment.IsTopLevel() {
+
toplevel[comment.AtUri().String()] = &CommentListItem{
+
Self: &comment,
+
}
+
} else {
+
replies = append(replies, &comment)
+
}
}
-
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 GetIssues(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,
+
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
return &comment, nil
+
}
+
+
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)
}
-
// 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
+
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 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()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
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 * 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
+
`,
+
whereClause,
+
pagination,
+
)
-
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)
+
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, repoCreatedAt string
-
var repo Repo
+
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,
-
&repo.Did,
-
&repo.Name,
-
&repo.Knot,
-
&repo.Rkey,
-
&repoCreatedAt,
+
&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
-
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
-
if err != nil {
-
return nil, err
+
if editedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
+
issue.Edited = &t
+
}
}
-
repo.Created = repoCreatedTime
-
issue.Metadata = &IssueMetadata{
-
Repo: &repo,
+
if deletedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
+
issue.Deleted = &t
+
}
}
-
issues = append(issues, issue)
+
atUri := issue.AtUri().String()
+
issueMap[atUri] = &issue
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
// collect reverse repos
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
+
for _, issue := range issueMap {
+
repoAts = append(repoAts, string(issue.RepoAt))
+
}
+
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
+
}
+
+
repoMap := make(map[string]*Repo)
+
for i := range repos {
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
+
}
+
+
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)
+
}
+
}
+
+
// 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)
+
}
+
+
for i := range comments {
+
issueAt := comments[i].IssueAt
+
if issue, ok := issueMap[issueAt]; ok {
+
issue.Comments = append(issue.Comments, comments[i])
+
}
+
}
+
+
var issues []Issue
+
for _, i := range issueMap {
+
issues = append(issues, *i)
}
+
sort.Slice(issues, func(i, j int) bool {
+
return issues[i].Created.After(issues[j].Created)
+
})
+
return issues, nil
+
}
+
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
+
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
}
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
···
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)
+
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 0, err
+
}
-
var issue Issue
-
var createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
id, err := result.LastInsertId()
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
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()...)
}
-
issue.Created = createdTime
-
comments, err := GetComments(e, repoAt, issueId)
-
if err != nil {
-
return nil, nil, err
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
return &issue, comments, nil
-
}
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
-
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,
-
)
+
_, 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
-
rows, err := e.Query(`
+
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 ")
+
}
+
+
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
}
+25 -12
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)
}
···
return tx.Commit()
}
-
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
+
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
idxs[did] = idx + 1
}
-
var profiles []Profile
-
for _, p := range profileMap {
-
profiles = append(profiles, *p)
-
}
-
-
return profiles, nil
+
return profileMap, nil
}
func GetProfile(e Execer, did string) (*Profile, error) {
···
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)
}
+31 -11
appview/db/pulls.go
···
}
record := tangled.RepoPull{
-
Title: p.Title,
-
Body: &p.Body,
-
CreatedAt: p.Created.Format(time.RFC3339),
-
PullId: int64(p.PullId),
-
TargetRepo: p.RepoAt.String(),
-
TargetBranch: p.TargetBranch,
-
Patch: p.LatestPatch(),
-
Source: source,
+
Title: p.Title,
+
Body: &p.Body,
+
CreatedAt: p.Created.Format(time.RFC3339),
+
Target: &tangled.RepoPull_Target{
+
Repo: p.RepoAt.String(),
+
Branch: p.TargetBranch,
+
},
+
Patch: p.LatestPatch(),
+
Source: source,
}
return record
}
···
return pullId - 1, err
}
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
pulls := make(map[int]*Pull)
var conditions []string
···
if conditions != nil {
whereClause = " where " + strings.Join(conditions, " and ")
}
+
limitClause := ""
+
if limit != 0 {
+
limitClause = fmt.Sprintf(" limit %d ", limit)
+
}
query := fmt.Sprintf(`
select
···
from
pulls
%s
-
`, whereClause)
+
order by
+
created desc
+
%s
+
`, whereClause, limitClause)
rows, err := e.Query(query, args...)
if err != nil {
···
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
submissionsQuery := fmt.Sprintf(`
select
-
id, pull_id, round_number, patch, source_rev
+
id, pull_id, round_number, patch, created, source_rev
from
pull_submissions
where
···
for submissionsRows.Next() {
var s PullSubmission
var sourceRev sql.NullString
+
var createdAt string
err := submissionsRows.Scan(
&s.ID,
&s.PullId,
&s.RoundNumber,
&s.Patch,
+
&createdAt,
&sourceRev,
)
if err != nil {
return nil, err
}
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
s.Created = createdTime
+
if sourceRev.Valid {
s.SourceRev = sourceRev.String
}
···
})
return orderedByPullId, nil
+
}
+
+
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
+
return GetPullsWithLimit(e, 0, filters...)
}
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+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)
+94 -130
appview/db/registration.go
···
package db
import (
-
"crypto/rand"
"database/sql"
-
"encoding/hex"
"fmt"
-
"log"
+
"strings"
"time"
)
+
// 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
+
Id int64
+
Domain string
+
ByDid string
+
Created *time.Time
+
Registered *time.Time
+
NeedsUpgrade bool
}
func (r *Registration) Status() Status {
-
if r.Registered != nil {
+
if r.NeedsUpgrade {
+
return NeedsUpgrade
+
} else if r.Registered != nil {
return Registered
} else {
return Pending
}
}
+
func (r *Registration) IsRegistered() bool {
+
return r.Status() == Registered
+
}
+
+
func (r *Registration) IsNeedsUpgrade() bool {
+
return r.Status() == NeedsUpgrade
+
}
+
+
func (r *Registration) IsPending() bool {
+
return r.Status() == Pending
+
}
+
type Status uint32
const (
Registered Status = iota
Pending
+
NeedsUpgrade
)
-
// returns registered status, did of owner, error
-
func RegistrationsByDid(e Execer, did string) ([]Registration, error) {
+
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
var registrations []Registration
-
rows, err := e.Query(`
-
select id, domain, did, created, registered from registrations
-
where did = ?
-
`, did)
+
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 ")
+
}
+
+
query := fmt.Sprintf(`
+
select id, domain, did, created, registered, needs_upgrade
+
from registrations
+
%s
+
order by created
+
`,
+
whereClause,
+
)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
for rows.Next() {
-
var createdAt *string
-
var registeredAt *string
-
var registration Registration
-
err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt)
+
var createdAt string
+
var registeredAt sql.Null[string]
+
var needsUpgrade int
+
var reg Registration
+
err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade)
if err != nil {
-
log.Println(err)
-
} else {
-
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
-
var registeredAtTime *time.Time
-
if registeredAt != nil {
-
x, _ := time.Parse(time.RFC3339, *registeredAt)
-
registeredAtTime = &x
-
}
-
-
registration.Created = &createdAtTime
-
registration.Registered = registeredAtTime
-
registrations = append(registrations, registration)
+
return nil, err
}
-
}
-
return registrations, nil
-
}
-
-
// returns registered status, did of owner, error
-
func RegistrationByDomain(e Execer, domain string) (*Registration, error) {
-
var createdAt *string
-
var registeredAt *string
-
var registration Registration
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
reg.Created = &t
+
}
-
err := e.QueryRow(`
-
select id, domain, did, created, registered from registrations
-
where domain = ?
-
`, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt)
+
if registeredAt.Valid {
+
if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil {
+
reg.Registered = &t
+
}
+
}
-
if err != nil {
-
if err == sql.ErrNoRows {
-
return nil, nil
-
} else {
-
return nil, err
+
if needsUpgrade != 0 {
+
reg.NeedsUpgrade = true
}
-
}
-
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
-
var registeredAtTime *time.Time
-
if registeredAt != nil {
-
x, _ := time.Parse(time.RFC3339, *registeredAt)
-
registeredAtTime = &x
+
registrations = append(registrations, reg)
}
-
registration.Created = &createdAtTime
-
registration.Registered = registeredAtTime
-
-
return &registration, nil
-
}
-
-
func genSecret() string {
-
key := make([]byte, 32)
-
rand.Read(key)
-
return hex.EncodeToString(key)
+
return registrations, nil
}
-
func GenerateRegistrationKey(e Execer, domain, did string) (string, error) {
-
// sanity check: does this domain already have a registration?
-
reg, err := RegistrationByDomain(e, domain)
-
if err != nil {
-
return "", err
-
}
-
-
// registration is open
-
if reg != nil {
-
switch reg.Status() {
-
case Registered:
-
// already registered by `owner`
-
return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid)
-
case Pending:
-
// TODO: be loud about this
-
log.Printf("%s registered by %s, status pending", domain, reg.ByDid)
-
}
+
func MarkRegistered(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()...)
}
-
secret := genSecret()
-
-
_, err = e.Exec(`
-
insert into registrations (domain, did, secret)
-
values (?, ?, ?)
-
on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created
-
`, domain, did, secret)
-
-
if err != nil {
-
return "", err
+
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 ")
}
-
return secret, nil
+
_, err := e.Exec(query, args...)
+
return err
}
-
func GetRegistrationKey(e Execer, domain string) (string, error) {
-
res := e.QueryRow(`select secret from registrations where domain = ?`, domain)
-
-
var secret string
-
err := res.Scan(&secret)
-
if err != nil || secret == "" {
-
return "", err
-
}
-
-
return secret, nil
+
func AddKnot(e Execer, domain, did string) error {
+
_, err := e.Exec(`
+
insert into registrations (domain, did)
+
values (?, ?)
+
`, domain, did)
+
return err
}
-
func GetCompletedRegistrations(e Execer) ([]string, error) {
-
rows, err := e.Query(`select domain from registrations where registered not null`)
-
if err != nil {
-
return nil, err
-
}
-
-
var domains []string
-
for rows.Next() {
-
var domain string
-
err = rows.Scan(&domain)
-
-
if err != nil {
-
log.Println(err)
-
} else {
-
domains = append(domains, domain)
-
}
+
func DeleteKnot(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 err = rows.Err(); err != nil {
-
return nil, err
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
return domains, nil
-
}
-
-
func Register(e Execer, domain string) error {
-
_, err := e.Exec(`
-
update registrations
-
set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where domain = ?;
-
`, domain)
+
query := fmt.Sprintf(`delete from registrations %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
+27 -130
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
+
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()...)
+
}
-
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
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
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
-
}
+
repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause)
+
var count int64
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
-
if nullableDescription.Valid {
-
repo.Description = nullableDescription.String
-
}
-
-
if nullableSource.Valid {
-
repo.Source = nullableSource.String
-
}
-
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
repo.Created = time.Now()
-
} else {
-
repo.Created = createdAtTime
-
}
-
-
repo.RepoStats = &repoStats
-
-
repos = append(repos, repo)
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
}
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return repos, nil
+
return count, nil
}
func GetRepo(e Execer, did, name string) (*Repo, error) {
···
var repos []Repo
rows, err := e.Query(
-
`select did, name, knot, rkey, description, created, source
-
from repos
-
where did = ? and source is not null and source != ''
-
order by created desc`,
-
did,
+
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
+
from repos r
+
left join collaborators c on r.at_uri = c.repo_at
+
where (r.did = ? or c.subject_did = ?)
+
and r.source is not null
+
and r.source != ''
+
order by r.created desc`,
+
did, did,
)
if err != nil {
return nil, err
···
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 {
+99 -3
appview/db/star.go
···
package db
import (
+
"database/sql"
+
"errors"
"fmt"
"log"
"strings"
···
// Get a star record
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
query := `
-
select starred_by_did, repo_at, created, rkey
+
select starred_by_did, repo_at, created, rkey
from stars
where starred_by_did = ? and repo_at = ?`
row := e.QueryRow(query, starredByDid, repoAt)
···
}
repoQuery := fmt.Sprintf(
-
`select starred_by_did, repo_at, created, rkey
+
`select starred_by_did, repo_at, created, rkey
from stars
%s
order by created desc
···
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) {
var stars []Star
rows, err := e.Query(`
-
select
+
select
s.starred_by_did,
s.repo_at,
s.rkey,
···
return stars, nil
}
+
+
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
+
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
+
// first, get the top repo URIs by star count from the last week
+
query := `
+
with recent_starred_repos as (
+
select distinct repo_at
+
from stars
+
where created >= datetime('now', '-7 days')
+
),
+
repo_star_counts as (
+
select
+
s.repo_at,
+
count(*) as stars_gained_last_week
+
from stars s
+
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
+
where s.created >= datetime('now', '-7 days')
+
group by s.repo_at
+
)
+
select rsc.repo_at
+
from repo_star_counts rsc
+
order by rsc.stars_gained_last_week desc
+
limit 8
+
`
+
+
rows, err := e.Query(query)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
var repoUris []string
+
for rows.Next() {
+
var repoUri string
+
err := rows.Scan(&repoUri)
+
if err != nil {
+
return nil, err
+
}
+
repoUris = append(repoUris, repoUri)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
if len(repoUris) == 0 {
+
return []Repo{}, nil
+
}
+
+
// get full repo data
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
+
if err != nil {
+
return nil, err
+
}
+
+
// sort repos by the original trending order
+
repoMap := make(map[string]Repo)
+
for _, repo := range repos {
+
repoMap[repo.RepoAt().String()] = repo
+
}
+
+
orderedRepos := make([]Repo, 0, len(repoUris))
+
for _, uri := range repoUris {
+
if repo, exists := repoMap[uri]; exists {
+
orderedRepos = append(orderedRepos, repo)
+
}
+
}
+
+
return orderedRepos, nil
+
}
+25 -9
appview/db/strings.go
···
func (s String) Validate() error {
var err error
-
if !strings.Contains(s.Filename, ".") {
-
err = errors.Join(err, fmt.Errorf("missing filename extension"))
-
}
-
-
if strings.HasSuffix(s.Filename, ".") {
-
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
-
}
-
if utf8.RuneCountInString(s.Filename) > 140 {
err = errors.Join(err, fmt.Errorf("filename too long"))
}
···
filename = excluded.filename,
description = excluded.description,
content = excluded.content,
-
edited = case
+
edited = case
when
strings.content != excluded.content
or strings.filename != excluded.filename
···
}
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 {
+17 -35
appview/db/timeline.go
···
*FollowStats
}
-
type FollowStats struct {
-
Followers int
-
Following int
-
}
-
-
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 := GetAllFollows(e, Limit)
+
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
+
follows, err := GetFollows(e, limit)
if err != nil {
return nil, err
}
···
return nil, nil
}
-
profileMap := make(map[string]Profile)
profiles, err := GetProfiles(e, FilterIn("did", subjects))
if err != nil {
return nil, err
}
-
for _, p := range profiles {
-
profileMap[p.Did] = p
-
}
-
followStatMap := make(map[string]FollowStats)
-
for _, s := range subjects {
-
followers, following, err := GetFollowerFollowingCount(e, s)
-
if err != nil {
-
return nil, err
-
}
-
followStatMap[s] = FollowStats{
-
Followers: followers,
-
Following: following,
-
}
+
followStatMap, err := GetFollowerFollowingCounts(e, subjects)
+
if err != nil {
+
return nil, err
}
var events []TimelineEvent
for _, f := range follows {
-
profile, _ := profileMap[f.SubjectDid]
+
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
events = append(events, TimelineEvent{
Follow: &f,
-
Profile: &profile,
+
Profile: profile,
FollowStats: &followStatMap,
EventAt: f.FollowedAt,
})
+295 -8
appview/ingester.go
···
"encoding/json"
"fmt"
"log/slog"
+
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/spindleverify"
+
"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 = spindleverify.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
}
-
_, err = spindleverify.MarkVerified(ddb, i.Enforcer, instance, did)
+
_, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did)
if err != nil {
return fmt.Errorf("failed to mark verified: %w", err)
}
···
return nil
}
+
+
func (i *Ingester) ingestKnotMember(e *models.Event) error {
+
did := e.Did
+
var err error
+
+
l := i.Logger.With("handler", "ingestKnotMember")
+
l = l.With("nsid", e.Commit.Collection)
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.KnotMember{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
// only knot owner can invite to knots
+
ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain)
+
if err != nil || !ok {
+
return fmt.Errorf("failed to enforce permissions: %w", err)
+
}
+
+
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
+
if err != nil {
+
return err
+
}
+
+
if memberId.Handle.IsInvalidHandle() {
+
return err
+
}
+
+
err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String())
+
if err != nil {
+
return fmt.Errorf("failed to update ACLs: %w", err)
+
}
+
+
l.Info("added knot member")
+
case models.CommitOperationDelete:
+
// we don't store knot members in a table (like we do for spindle)
+
// and we can't remove this just yet. possibly fixed if we switch
+
// to either:
+
// 1. a knot_members table like with spindle and store the rkey
+
// 2. use the knot host as the rkey
+
//
+
// TODO: implement member deletion
+
l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey)
+
}
+
+
return nil
+
}
+
+
func (i *Ingester) ingestKnot(e *models.Event) error {
+
did := e.Did
+
var err error
+
+
l := i.Logger.With("handler", "ingestKnot")
+
l = l.With("nsid", e.Commit.Collection)
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.Knot{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
domain := e.Commit.RKey
+
+
ddb, ok := i.Db.Execer.(*db.DB)
+
if !ok {
+
return fmt.Errorf("failed to index profile record, invalid db cast")
+
}
+
+
err := db.AddKnot(ddb, domain, did)
+
if err != nil {
+
l.Error("failed to add knot to db", "err", err, "domain", domain)
+
return err
+
}
+
+
err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev)
+
if err != nil {
+
l.Error("failed to verify knot", "err", err, "domain", domain)
+
return err
+
}
+
+
err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did)
+
if err != nil {
+
return fmt.Errorf("failed to mark verified: %w", err)
+
}
+
+
return nil
+
+
case models.CommitOperationDelete:
+
domain := e.Commit.RKey
+
+
ddb, ok := i.Db.Execer.(*db.DB)
+
if !ok {
+
return fmt.Errorf("failed to index knot record, invalid db cast")
+
}
+
+
// get record from db first
+
registrations, err := db.GetRegistrations(
+
ddb,
+
db.FilterEq("domain", domain),
+
db.FilterEq("did", did),
+
)
+
if err != nil {
+
return fmt.Errorf("failed to get registration: %w", err)
+
}
+
if len(registrations) != 1 {
+
return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations))
+
}
+
registration := registrations[0]
+
+
tx, err := ddb.Begin()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
tx.Rollback()
+
i.Enforcer.E.LoadPolicy()
+
}()
+
+
err = db.DeleteKnot(
+
tx,
+
db.FilterEq("did", did),
+
db.FilterEq("domain", domain),
+
)
+
if err != nil {
+
return err
+
}
+
+
if registration.Registered != nil {
+
err = i.Enforcer.RemoveKnot(domain)
+
if err != nil {
+
return err
+
}
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return err
+
}
+
+
err = i.Enforcer.E.SavePolicy()
+
if err != nil {
+
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.GetIssues(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)
})
})
+416 -218
appview/knots/knots.go
···
package knots
import (
-
"context"
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
+
"errors"
"fmt"
"log/slog"
"net/http"
-
"strings"
+
"slices"
"time"
"github.com/go-chi/chi/v5"
···
"tangled.sh/tangled.sh/core/appview/middleware"
"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/knotclient"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
···
Knotstream *eventconsumer.Consumer
}
-
func (k *Knots) Router(mw *middleware.Middleware) http.Handler {
+
func (k *Knots) Router() http.Handler {
r := chi.NewRouter()
-
r.Use(middleware.AuthMiddleware(k.OAuth))
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register)
-
r.Get("/", k.index)
-
r.Post("/key", k.generateKey)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete)
-
r.Route("/{domain}", func(r chi.Router) {
-
r.Post("/init", k.init)
-
r.Get("/", k.dashboard)
-
r.Route("/member", func(r chi.Router) {
-
r.Use(mw.KnotOwner())
-
r.Get("/", k.members)
-
r.Put("/", k.addMember)
-
r.Delete("/", k.removeMember)
-
})
-
})
+
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)
return r
}
-
// get knots registered by this user
-
func (k *Knots) index(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "index")
-
+
func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
user := k.OAuth.GetUser(r)
-
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
)
if err != nil {
-
l.Error("failed to get registrations by did", "err", err)
+
k.Logger.Error("failed to fetch knot registrations", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
}
k.Pages.Knots(w, pages.KnotsParams{
···
})
}
-
// requires auth
-
func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "generateKey")
+
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
+
l := k.Logger.With("handler", "dashboard")
user := k.OAuth.GetUser(r)
-
did := user.Did
-
l = l.With("did", did)
+
l = l.With("user", user.Did)
-
// check if domain is valid url, and strip extra bits down to just host
-
domain := r.FormValue("domain")
+
domain := chi.URLParam(r, "domain")
if domain == "" {
-
l.Error("empty domain")
-
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
l = l.With("domain", domain)
-
noticeId := "registration-error"
-
fail := func() {
-
k.Pages.Notice(w, noticeId, "Failed to generate registration key.")
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
+
if err != nil {
+
l.Error("failed to get registrations", "err", err)
+
http.Error(w, "Not found", http.StatusNotFound)
+
return
+
}
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
return
}
+
registration := registrations[0]
-
key, err := db.GenerateRegistrationKey(k.Db, domain, did)
+
members, err := k.Enforcer.GetUserByRole("server:member", domain)
if err != nil {
-
l.Error("failed to generate registration key", "err", err)
-
fail()
+
l.Error("failed to get knot members", "err", err)
+
http.Error(w, "Not found", http.StatusInternalServerError)
return
}
+
slices.Sort(members)
-
allRegs, err := db.RegistrationsByDid(k.Db, did)
+
repos, err := db.GetRepos(
+
k.Db,
+
0,
+
db.FilterEq("knot", domain),
+
)
if err != nil {
-
l.Error("failed to generate registration key", "err", err)
-
fail()
+
l.Error("failed to get knot repos", "err", err)
+
http.Error(w, "Not found", http.StatusInternalServerError)
return
}
-
k.Pages.KnotListingFull(w, pages.KnotListingFullParams{
-
Registrations: allRegs,
-
})
-
k.Pages.KnotSecret(w, pages.KnotSecretParams{
-
Secret: key,
+
// organize repos by did
+
repoMap := make(map[string][]db.Repo)
+
for _, r := range repos {
+
repoMap[r.Did] = append(repoMap[r.Did], r)
+
}
+
+
k.Pages.Knot(w, pages.KnotParams{
+
LoggedInUser: user,
+
Registration: &registration,
+
Members: members,
+
Repos: repoMap,
+
IsOwner: true,
})
}
-
// create a signed request and check if a node responds to that
-
func (k *Knots) init(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "init")
+
func (k *Knots) register(w http.ResponseWriter, r *http.Request) {
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "register")
-
noticeId := "operation-error"
-
defaultErr := "Failed to initialize knot. Try again later."
+
noticeId := "register-error"
+
defaultErr := "Failed to register knot. Try again later."
fail := func() {
k.Pages.Notice(w, noticeId, defaultErr)
}
-
domain := chi.URLParam(r, "domain")
+
domain := r.FormValue("domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
k.Pages.Notice(w, noticeId, "Incomplete form.")
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
-
l.Info("checking domain")
+
tx, err := k.Db.Begin()
+
if err != nil {
+
l.Error("failed to start transaction", "err", err)
+
fail()
+
return
+
}
+
defer func() {
+
tx.Rollback()
+
k.Enforcer.E.LoadPolicy()
+
}()
-
registration, err := db.RegistrationByDomain(k.Db, domain)
+
err = db.AddKnot(tx, domain, user.Did)
if err != nil {
-
l.Error("failed to get registration for domain", "err", err)
+
l.Error("failed to insert", "err", err)
fail()
return
}
-
if registration.ByDid != user.Did {
-
l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did)
-
w.WriteHeader(http.StatusUnauthorized)
+
+
err = k.Enforcer.AddKnot(domain)
+
if err != nil {
+
l.Error("failed to create knot", "err", err)
+
fail()
return
}
-
secret, err := db.GetRegistrationKey(k.Db, domain)
+
// create record on pds
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to get registration key for domain", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
var exCid *string
+
if ex != nil {
+
exCid = ex.Cid
+
}
+
+
// re-announce by registering under same rkey
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.KnotNSID,
+
Repo: user.Did,
+
Rkey: domain,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.Knot{
+
CreatedAt: time.Now().Format(time.RFC3339),
+
},
+
},
+
SwapRecord: exCid,
+
})
+
if err != nil {
-
l.Error("failed to create knotclient", "err", err)
+
l.Error("failed to put record", "err", err)
fail()
return
}
-
resp, err := client.Init(user.Did)
+
err = tx.Commit()
if err != nil {
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error()))
-
l.Error("failed to make init request", "err", err)
+
l.Error("failed to commit transaction", "err", err)
+
fail()
return
}
-
if resp.StatusCode == http.StatusConflict {
-
k.Pages.Notice(w, noticeId, "This knot is already registered")
-
l.Error("knot already registered", "statuscode", resp.StatusCode)
+
err = k.Enforcer.E.SavePolicy()
+
if err != nil {
+
l.Error("failed to update ACL", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
if resp.StatusCode != http.StatusNoContent {
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent))
-
l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent)
+
// begin verification
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
+
if err != nil {
+
l.Error("verification failed", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
// verify response mac
-
signature := resp.Header.Get("X-Signature")
-
signatureBytes, err := hex.DecodeString(signature)
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
if err != nil {
+
l.Error("failed to mark verified", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
expectedMac := hmac.New(sha256.New, []byte(secret))
-
expectedMac.Write([]byte("ok"))
+
// add this knot to knotstream
+
go k.Knotstream.AddSource(
+
r.Context(),
+
eventconsumer.NewKnotSource(domain),
+
)
-
if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) {
-
k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.")
-
l.Error("signature mismatch", "bytes", signatureBytes)
-
return
+
// ok
+
k.Pages.HxRefresh(w)
+
}
+
+
func (k *Knots) delete(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "delete")
+
+
noticeId := "operation-error"
+
defaultErr := "Failed to delete knot. Try again later."
+
fail := func() {
+
k.Pages.Notice(w, noticeId, defaultErr)
}
-
tx, err := k.Db.BeginTx(r.Context(), nil)
-
if err != nil {
-
l.Error("failed to start tx", "err", err)
+
domain := chi.URLParam(r, "domain")
+
if domain == "" {
+
l.Error("empty domain")
fail()
return
}
-
defer func() {
-
tx.Rollback()
-
err = k.Enforcer.E.LoadPolicy()
-
if err != nil {
-
l.Error("rollback failed", "err", err)
-
}
-
}()
-
// mark as registered
-
err = db.Register(tx, domain)
+
// get record from db first
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
-
l.Error("failed to register domain", "err", err)
+
l.Error("failed to get registration", "err", err)
fail()
return
}
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
fail()
+
return
+
}
+
registration := registrations[0]
-
// set permissions for this did as owner
-
reg, err := db.RegistrationByDomain(tx, domain)
+
tx, err := k.Db.Begin()
if err != nil {
-
l.Error("failed get registration by domain", "err", err)
+
l.Error("failed to start txn", "err", err)
fail()
return
}
+
defer func() {
+
tx.Rollback()
+
k.Enforcer.E.LoadPolicy()
+
}()
-
// add basic acls for this domain
-
err = k.Enforcer.AddKnot(domain)
+
err = db.DeleteKnot(
+
tx,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
-
l.Error("failed to add knot to enforcer", "err", err)
+
l.Error("failed to delete registration", "err", err)
fail()
return
}
-
// add this did as owner of this domain
-
err = k.Enforcer.AddKnotOwner(domain, reg.ByDid)
+
// delete from enforcer if it was registered
+
if registration.Registered != nil {
+
err = k.Enforcer.RemoveKnot(domain)
+
if err != nil {
+
l.Error("failed to update ACL", "err", err)
+
fail()
+
return
+
}
+
}
+
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to add knot owner to enforcer", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.KnotNSID,
+
Repo: user.Did,
+
Rkey: domain,
+
})
+
if err != nil {
+
// non-fatal
+
l.Error("failed to delete record", "err", err)
+
}
+
err = tx.Commit()
if err != nil {
-
l.Error("failed to commit changes", "err", err)
+
l.Error("failed to delete knot", "err", err)
fail()
return
}
err = k.Enforcer.E.SavePolicy()
if err != nil {
-
l.Error("failed to update ACLs", "err", err)
-
fail()
+
l.Error("failed to update ACL", "err", err)
+
k.Pages.HxRefresh(w)
return
}
-
// add this knot to knotstream
-
go k.Knotstream.AddSource(
-
context.Background(),
-
eventconsumer.NewKnotSource(domain),
-
)
+
shouldRedirect := r.Header.Get("shouldRedirect")
+
if shouldRedirect == "true" {
+
k.Pages.HxRedirect(w, "/knots")
+
return
+
}
-
k.Pages.KnotListing(w, pages.KnotListingParams{
-
Registration: *reg,
-
})
+
w.Write([]byte{})
}
-
func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "dashboard")
+
func (k *Knots) retry(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "retry")
+
+
noticeId := "operation-error"
+
defaultErr := "Failed to verify knot. Try again later."
fail := func() {
-
w.WriteHeader(http.StatusInternalServerError)
+
k.Pages.Notice(w, noticeId, defaultErr)
}
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
l.Error("empty domain")
+
fail()
return
}
l = l.With("domain", domain)
-
-
user := k.OAuth.GetUser(r)
-
l = l.With("did", user.Did)
+
l = l.With("user", user.Did)
-
// dashboard is only available to owners
-
ok, err := k.Enforcer.IsKnotOwner(user.Did, domain)
+
// get record from db first
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
-
l.Error("failed to query enforcer", "err", err)
+
l.Error("failed to get registration", "err", err)
fail()
+
return
}
-
if !ok {
-
http.Error(w, "only owners can view dashboards", http.StatusUnauthorized)
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
fail()
return
}
+
registration := registrations[0]
-
reg, err := db.RegistrationByDomain(k.Db, domain)
+
// begin verification
+
err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev)
if err != nil {
-
l.Error("failed to get registration by domain", "err", err)
+
l.Error("verification failed", "err", err)
+
+
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
+
}
+
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
+
k.Pages.Notice(w, noticeId, e.Error())
+
return
+
}
+
fail()
return
}
-
var members []string
-
if reg.Registered != nil {
-
members, err = k.Enforcer.GetUserByRole("server:member", domain)
+
err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did)
+
if err != nil {
+
l.Error("failed to mark verified", "err", err)
+
k.Pages.Notice(w, noticeId, err.Error())
+
return
+
}
+
+
// 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.NeedsUpgrade {
+
// re-announce by registering under same rkey
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to get members list", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
+
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
var exCid *string
+
if ex != nil {
+
exCid = ex.Cid
+
}
+
+
// ignore the error here
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.KnotNSID,
+
Repo: user.Did,
+
Rkey: domain,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.Knot{
+
CreatedAt: time.Now().Format(time.RFC3339),
+
},
+
},
+
SwapRecord: exCid,
+
})
+
if err != nil {
+
l.Error("non-fatal: failed to reannouce knot", "err", err)
+
}
}
-
repos, err := db.GetRepos(
+
// add this knot to knotstream
+
go k.Knotstream.AddSource(
+
r.Context(),
+
eventconsumer.NewKnotSource(domain),
+
)
+
+
shouldRefresh := r.Header.Get("shouldRefresh")
+
if shouldRefresh == "true" {
+
k.Pages.HxRefresh(w)
+
return
+
}
+
+
// Get updated registration to show
+
registrations, err = db.GetRegistrations(
k.Db,
-
0,
-
db.FilterEq("knot", domain),
-
db.FilterIn("did", members),
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
)
if err != nil {
-
l.Error("failed to get repos list", "err", err)
+
l.Error("failed to get registration", "err", err)
fail()
return
}
-
// convert to map
-
repoByMember := make(map[string][]db.Repo)
-
for _, r := range repos {
-
repoByMember[r.Did] = append(repoByMember[r.Did], r)
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
fail()
+
return
}
+
updatedRegistration := registrations[0]
-
k.Pages.Knot(w, pages.KnotParams{
-
LoggedInUser: user,
-
Registration: reg,
-
Members: members,
-
Repos: repoByMember,
-
IsOwner: true,
+
w.Header().Set("HX-Reswap", "outerHTML")
+
k.Pages.KnotListing(w, pages.KnotListingParams{
+
Registration: &updatedRegistration,
})
}
-
// list members of domain, requires auth and requires owner status
-
func (k *Knots) members(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "members")
+
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "addMember")
domain := chi.URLParam(r, "domain")
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
l.Error("empty domain")
+
http.Error(w, "Not found", http.StatusNotFound)
return
}
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
-
// list all members for this domain
-
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
db.FilterIsNot("registered", "null"),
+
)
if err != nil {
-
w.Write([]byte("failed to fetch member list"))
+
l.Error("failed to get registration", "err", err)
return
}
-
-
w.Write([]byte(strings.Join(memberDids, "\n")))
-
}
-
-
// add member to domain, requires auth and requires invite access
-
func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) {
-
l := k.Logger.With("handler", "members")
-
-
domain := chi.URLParam(r, "domain")
-
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
return
}
-
l = l.With("domain", domain)
-
-
reg, err := db.RegistrationByDomain(k.Db, domain)
-
if err != nil {
-
l.Error("failed to get registration by domain", "err", err)
-
http.Error(w, "malformed url", http.StatusBadRequest)
-
return
-
}
+
registration := registrations[0]
-
noticeId := fmt.Sprintf("add-member-error-%d", reg.Id)
-
l = l.With("notice-id", noticeId)
+
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
defaultErr := "Failed to add member. Try again later."
fail := func() {
k.Pages.Notice(w, noticeId, defaultErr)
}
-
subjectIdentifier := r.FormValue("subject")
-
if subjectIdentifier == "" {
-
http.Error(w, "malformed form", http.StatusBadRequest)
+
member := r.FormValue("member")
+
if member == "" {
+
l.Error("empty member")
+
k.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
return
}
-
l = l.With("subjectIdentifier", subjectIdentifier)
+
l = l.With("member", member)
-
subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier)
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
-
l.Error("failed to resolve identity", "err", err)
+
l.Error("failed to resolve member identity to handle", "err", err)
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
return
}
-
l = l.With("subjectDid", subjectIdentity.DID)
-
-
l.Info("adding member to knot")
+
if memberId.Handle.IsInvalidHandle() {
+
l.Error("failed to resolve member identity to handle")
+
k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
+
return
+
}
-
// announce this relation into the firehose, store into owners' pds
+
// write to pds
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to create client", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
currentUser := k.OAuth.GetUser(r)
-
createdAt := time.Now().Format(time.RFC3339)
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
rkey := tid.TID()
+
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
-
Repo: currentUser.Did,
-
Rkey: tid.TID(),
+
Repo: user.Did,
+
Rkey: rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
-
Subject: subjectIdentity.DID.String(),
+
CreatedAt: time.Now().Format(time.RFC3339),
Domain: domain,
-
CreatedAt: createdAt,
-
}},
+
Subject: memberId.DID.String(),
+
},
+
},
})
-
// invalid record
if err != nil {
-
l.Error("failed to write to PDS", "err", err)
-
fail()
+
l.Error("failed to add record to PDS", "err", err)
+
k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.")
return
}
-
l = l.With("at-uri", resp.Uri)
-
l.Info("wrote record to PDS")
-
secret, err := db.GetRegistrationKey(k.Db, domain)
+
err = k.Enforcer.AddKnotMember(domain, memberId.DID.String())
if err != nil {
-
l.Error("failed to get registration key", "err", err)
+
l.Error("failed to add member to ACLs", "err", err)
fail()
return
}
-
ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev)
+
err = k.Enforcer.E.SavePolicy()
if err != nil {
-
l.Error("failed to create client", "err", err)
+
l.Error("failed to save ACL policy", "err", err)
fail()
return
}
-
ksResp, err := ksClient.AddMember(subjectIdentity.DID.String())
+
// success
+
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
+
}
+
+
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "removeMember")
+
+
noticeId := "operation-error"
+
defaultErr := "Failed to remove member. Try again later."
+
fail := func() {
+
k.Pages.Notice(w, noticeId, defaultErr)
+
}
+
+
domain := chi.URLParam(r, "domain")
+
if domain == "" {
+
l.Error("empty domain")
+
fail()
+
return
+
}
+
l = l.With("domain", domain)
+
l = l.With("user", user.Did)
+
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
db.FilterIsNot("registered", "null"),
+
)
if err != nil {
-
l.Error("failed to reach knotserver", "err", err)
-
k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.")
+
l.Error("failed to get registration", "err", err)
+
return
+
}
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
return
+
}
+
+
member := r.FormValue("member")
+
if member == "" {
+
l.Error("empty member")
+
k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+
return
+
}
+
l = l.With("member", member)
+
+
memberId, err := k.IdResolver.ResolveIdent(r.Context(), member)
+
if err != nil {
+
l.Error("failed to resolve member identity to handle", "err", err)
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
+
return
+
}
+
if memberId.Handle.IsInvalidHandle() {
+
l.Error("failed to resolve member identity to handle")
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
return
}
-
if ksResp.StatusCode != http.StatusNoContent {
-
l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent)
-
k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent))
+
// remove from enforcer
+
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+
if err != nil {
+
l.Error("failed to update ACLs", "err", err)
+
fail()
return
}
-
err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String())
+
client, err := k.OAuth.AuthorizedClient(r)
if err != nil {
-
l.Error("failed to add member to enforcer", "err", err)
+
l.Error("failed to authorize client", "err", err)
fail()
return
}
-
// success
-
k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain))
-
}
+
// TODO: We need to track the rkey for knot members to delete the record
+
// For now, just remove from ACLs
+
_ = client
+
+
// commit everything
+
err = k.Enforcer.E.SavePolicy()
+
if err != nil {
+
l.Error("failed to save ACLs", "err", err)
+
fail()
+
return
+
}
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+
// ok
+
k.Pages.HxRefresh(w)
}
+43 -3
appview/middleware/middleware.go
···
if err != nil {
// invalid did or handle
log.Println("failed to resolve repo")
-
mw.pages.Error404(w)
+
mw.pages.ErrorKnot404(w)
return
}
···
f, err := mw.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to fully resolve repo", err)
-
http.Error(w, "invalid repo url", http.StatusNotFound)
+
mw.pages.ErrorKnot404(w)
return
}
···
}
}
+
// 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 {
···
f, err := mw.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to fully resolve repo", err)
-
http.Error(w, "invalid repo url", http.StatusNotFound)
+
mw.pages.ErrorKnot404(w)
return
}
+107 -84
appview/oauth/handler/handler.go
···
"log"
"net/http"
"net/url"
+
"slices"
"strings"
"time"
···
"tangled.sh/tangled.sh/core/appview/oauth/client"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
)
···
return pubKey, nil
}
+
var (
+
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
+
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
+
+
defaultSpindle = "spindle.tangled.sh"
+
defaultKnot = "knot1.tangled.sh"
+
)
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
// use the tangled.sh app password to get an accessJwt
// and create an sh.tangled.spindle.member record with that
-
-
defaultSpindle := "spindle.tangled.sh"
-
appPassword := o.config.Core.AppPassword
-
spindleMembers, err := db.GetSpindleMembers(
o.db,
db.FilterEq("instance", "spindle.tangled.sh"),
···
return
}
-
// TODO: hardcoded tangled handle and did for now
-
tangledHandle := "tangled.sh"
-
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
+
log.Printf("adding %s to default spindle", did)
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid)
+
if err != nil {
+
log.Printf("failed to create session: %s", err)
+
return
+
}
-
if appPassword == "" {
-
log.Println("no app password configured, skipping spindle member addition")
+
record := tangled.SpindleMember{
+
LexiconTypeID: "sh.tangled.spindle.member",
+
Subject: did,
+
Instance: defaultSpindle,
+
CreatedAt: time.Now().Format(time.RFC3339),
+
}
+
+
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
+
log.Printf("failed to add member to default spindle: %s", err)
return
}
-
log.Printf("adding %s to default spindle", did)
+
log.Printf("successfully added %s to default spindle", did)
+
}
+
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
+
// use the tangled.sh app password to get an accessJwt
+
// and create an sh.tangled.spindle.member record with that
+
+
allKnots, err := o.enforcer.GetKnotsForUser(did)
+
if err != nil {
+
log.Printf("failed to get knot members for did %s: %v", did, err)
+
return
+
}
+
+
if slices.Contains(allKnots, defaultKnot) {
+
log.Printf("did %s is already a member of the default knot", did)
+
return
+
}
-
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
+
log.Printf("adding %s to default knot", did)
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid)
if err != nil {
-
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
+
log.Printf("failed to create session: %s", err)
+
return
+
}
+
+
record := tangled.KnotMember{
+
LexiconTypeID: "sh.tangled.knot.member",
+
Subject: did,
+
Domain: defaultKnot,
+
CreatedAt: time.Now().Format(time.RFC3339),
+
}
+
+
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
+
log.Printf("failed to add member to default knot: %s", err)
+
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)
+
}
+
+
// create a session using apppasswords
+
type session struct {
+
AccessJwt string `json:"accessJwt"`
+
PdsEndpoint string
+
Did string
+
}
+
+
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
+
if appPassword == "" {
+
return nil, fmt.Errorf("no app password configured, skipping member addition")
+
}
+
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
+
}
+
pdsEndpoint := resolved.PDSEndpoint()
if pdsEndpoint == "" {
-
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
-
return
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
}
sessionPayload := map[string]string{
-
"identifier": tangledHandle,
+
"identifier": did,
"password": appPassword,
}
sessionBytes, err := json.Marshal(sessionPayload)
if err != nil {
-
log.Printf("failed to marshal session payload: %v", err)
-
return
+
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
}
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
if err != nil {
-
log.Printf("failed to create session request: %v", err)
-
return
+
return nil, fmt.Errorf("failed to create session request: %v", err)
}
sessionReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
sessionResp, err := client.Do(sessionReq)
if err != nil {
-
log.Printf("failed to create session: %v", err)
-
return
+
return nil, fmt.Errorf("failed to create session: %v", err)
}
defer sessionResp.Body.Close()
if sessionResp.StatusCode != http.StatusOK {
-
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
-
return
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
}
-
var session struct {
-
AccessJwt string `json:"accessJwt"`
-
}
+
var session session
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
-
log.Printf("failed to decode session response: %v", err)
-
return
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
}
-
record := tangled.SpindleMember{
-
LexiconTypeID: "sh.tangled.spindle.member",
-
Subject: did,
-
Instance: defaultSpindle,
-
CreatedAt: time.Now().Format(time.RFC3339),
-
}
+
session.PdsEndpoint = pdsEndpoint
+
session.Did = did
+
+
return &session, nil
+
}
+
func (s *session) putRecord(record any, collection string) error {
recordBytes, err := json.Marshal(record)
if err != nil {
-
log.Printf("failed to marshal spindle member record: %v", err)
-
return
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
}
-
payload := map[string]interface{}{
-
"repo": tangledDid,
-
"collection": tangled.SpindleMemberNSID,
+
payload := map[string]any{
+
"repo": s.Did,
+
"collection": collection,
"rkey": tid.TID(),
"record": json.RawMessage(recordBytes),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
-
log.Printf("failed to marshal request payload: %v", err)
-
return
+
return fmt.Errorf("failed to marshal request payload: %w", err)
}
-
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
if err != nil {
-
log.Printf("failed to create HTTP request: %v", err)
-
return
+
return fmt.Errorf("failed to create HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
+
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
-
log.Printf("failed to add user to default spindle: %v", err)
-
return
+
return fmt.Errorf("failed to add user to default service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
-
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
-
return
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
}
-
log.Printf("successfully added %s to default spindle", did)
-
}
-
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
-
defaultKnot := "knot1.tangled.sh"
-
-
log.Printf("adding %s to default knot", did)
-
err := o.enforcer.AddKnotMember(defaultKnot, did)
-
if err != nil {
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
-
return
-
}
-
err = o.enforcer.E.SavePolicy()
-
if err != nil {
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
-
return
-
}
-
-
secret, err := db.GetRegistrationKey(o.db, defaultKnot)
-
if err != nil {
-
log.Println("failed to get registration key for knot1.tangled.sh")
-
return
-
}
-
signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev)
-
resp, err := signedClient.AddMember(did)
-
if err != nil {
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
-
return
-
}
-
-
if resp.StatusCode != http.StatusNoContent {
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
-
return
-
}
+
return nil
}
+3
appview/oauth/oauth.go
···
AccessJwt: resp.Token,
},
Host: opts.Host(),
+
Client: &http.Client{
+
Timeout: time.Second * 5,
+
},
}, nil
}
+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)
+
}
+16
appview/pages/funcmap.go
···
"github.com/go-enry/go-enry/v2"
"tangled.sh/tangled.sh/core/appview/filetree"
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/crypto"
)
func (p *Pages) funcMap() template.FuncMap {
return template.FuncMap{
"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)
···
},
"layoutCenter": func() string {
return "col-span-1 md:col-span-8 lg:col-span-6"
+
},
+
+
"normalizeForHtmlId": func(s string) string {
+
// TODO: extend this to handle other cases?
+
return strings.ReplaceAll(s, ":", "_")
+
},
+
"sshFingerprint": func(pubKey string) string {
+
fp, err := crypto.SSHFingerprint(pubKey)
+
if err != nil {
+
return "error"
+
}
+
return fp
},
}
}
+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 {
+12 -8
appview/pages/markup/markdown.go
···
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/styles"
+
treeblood "github.com/wyatt915/goldmark-treeblood"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
···
"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"
)
···
extension.NewFootnote(
extension.WithFootnoteIDPrefix([]byte("footnote")),
),
+
treeblood.MathML(),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
···
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
+17
appview/pages/markup/sanitizer.go
···
"margin-bottom",
)
+
// math
+
mathAttrs := []string{
+
"accent", "columnalign", "columnlines", "columnspan", "dir", "display",
+
"displaystyle", "encoding", "fence", "form", "largeop", "linebreak",
+
"linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize",
+
"movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan",
+
"scriptlevel", "stretchy", "symmetric", "title", "voffset", "width",
+
}
+
mathElements := []string{
+
"annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts",
+
"mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace",
+
"msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext",
+
"mtr", "munder", "munderover", "semantics",
+
}
+
policy.AllowNoAttrs().OnElements(mathElements...)
+
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
+
return policy
}
+333 -206
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")
+
}
+
+
// reverse of pathToName
+
func (p *Pages) nameToPath(s string) string {
+
return "templates/" + s + ".html"
+
}
-
// Use embedded FS for initial loading
-
// First, collect all fragment paths
+
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
+
}
+
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
+
key := strings.Join(stack, "|")
+
+
// never cache in dev mode
+
if cached, exists := p.cache.Get(key); !p.dev && exists {
+
return cached, nil
}
-
// Create a new template
-
tmpl := template.New(name).Funcs(p.funcMap())
-
-
// Parse layouts
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
-
layouts, err := filepath.Glob(layoutGlob)
+
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)
}
type TimelineParams struct {
LoggedInUser *oauth.User
Timeline []db.TimelineEvent
+
Repos []db.Repo
}
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
-
return p.execute("timeline", w, params)
+
return p.execute("timeline/timeline", w, params)
+
}
+
+
type UserProfileSettingsParams struct {
+
LoggedInUser *oauth.User
+
Tabs []map[string]any
+
Tab string
+
}
+
+
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
+
return p.execute("user/settings/profile", w, params)
}
-
type SettingsParams struct {
+
type UserKeysSettingsParams struct {
LoggedInUser *oauth.User
PubKeys []db.PublicKey
+
Tabs []map[string]any
+
Tab string
+
}
+
+
func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
+
return p.execute("user/settings/keys", w, params)
+
}
+
+
type UserEmailsSettingsParams struct {
+
LoggedInUser *oauth.User
Emails []db.Email
+
Tabs []map[string]any
+
Tab string
}
-
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
-
return p.execute("settings", w, params)
+
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
+
return p.execute("user/settings/emails", w, params)
+
}
+
+
type UpgradeBannerParams struct {
+
Registrations []db.Registration
+
Spindles []db.Spindle
+
}
+
+
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
+
return p.executePlain("banner", w, params)
}
type KnotsParams struct {
···
}
type KnotListingParams struct {
-
db.Registration
+
*db.Registration
}
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
return p.executePlain("knots/fragments/knotListing", w, params)
}
-
type KnotListingFullParams struct {
-
Registrations []db.Registration
-
}
-
-
func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error {
-
return p.executePlain("knots/fragments/knotListingFull", w, params)
-
}
-
-
type KnotSecretParams struct {
-
Secret string
-
}
-
-
func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error {
-
return p.executePlain("knots/fragments/secret", w, params)
-
}
-
type SpindlesParams struct {
LoggedInUser *oauth.User
Spindles []db.Spindle
···
return p.execute("repo/fork", w, params)
}
-
type ProfilePageParams 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
-
Followers int
-
Following 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) ProfilePage(w io.Writer, params ProfilePageParams) 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) 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 int64
+
FollowingCount int64
+
Profile *db.Profile
+
}
+
+
type ProfileFollowersParams struct {
+
LoggedInUser *oauth.User
+
Followers []FollowCard
+
Card *ProfileCard
+
Active string
+
}
+
+
func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/followers", w, params)
+
}
+
+
type ProfileFollowingParams struct {
+
LoggedInUser *oauth.User
+
Following []FollowCard
+
Card *ProfileCard
+
Active string
}
-
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
-
return p.execute("user/repos", 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 {
···
}
type RepoIndexParams struct {
-
LoggedInUser *oauth.User
-
RepoInfo repoinfo.RepoInfo
-
Active string
-
TagMap map[string][]string
-
CommitsTrunc []*object.Commit
-
TagsTrunc []*types.TagReference
-
BranchesTrunc []types.Branch
-
ForkInfo *types.ForkInfo
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
TagMap map[string][]string
+
CommitsTrunc []*object.Commit
+
TagsTrunc []*types.TagReference
+
BranchesTrunc []types.Branch
+
// ForkInfo *types.ForkInfo
HTMLReadme template.HTML
Raw bool
EmailToDidOrHandle map[string]string
VerifiedCommits commitverify.VerifiedCommits
Languages []types.RepoLanguageDetails
Pipelines map[string]db.Pipeline
+
NeedsKnotUpgrade bool
types.RepoIndexResponse
}
···
params.Active = "overview"
if params.IsEmpty {
return p.executeRepo("repo/empty", w, params)
+
}
+
+
if params.NeedsKnotUpgrade {
+
return p.executeRepo("repo/needsUpgrade", w, params)
}
p.rctx.RepoInfo = params.RepoInfo
···
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 ""
···
func (p *Pages) Error404(w io.Writer) error {
return p.execute("errors/404", w, nil)
+
}
+
+
func (p *Pages) ErrorKnot404(w io.Writer) error {
+
return p.execute("errors/knot404", w, nil)
func (p *Pages) Error503(w io.Writer) error {
+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 }}
+24 -4
appview/pages/templates/errors/404.html
···
{{ define "title" }}404 &middot; tangled{{ end }}
{{ define "content" }}
-
<h1>404 &mdash; nothing like that here!</h1>
-
<p>
-
It seems we couldn't find what you were looking for. Sorry about that!
-
</p>
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
+
<div class="mb-6">
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
+
{{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }}
+
</div>
+
</div>
+
+
<div class="space-y-4">
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
+
404 &mdash; page not found
+
</h1>
+
<p class="text-gray-600 dark:text-gray-300">
+
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 no-underline hover:no-underline gap-2">
+
{{ i "arrow-left" "w-4 h-4" }}
+
go back
+
</a>
+
</div>
+
</div>
+
</div>
+
</div>
{{ end }}
+35 -2
appview/pages/templates/errors/500.html
···
{{ define "title" }}500 &middot; tangled{{ end }}
{{ define "content" }}
-
<h1>500 &mdash; something broke!</h1>
-
<p>We're working on getting service back up. Hang tight!</p>
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
+
<div class="mb-6">
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
+
{{ 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
+
</h1>
+
<p class="text-gray-600 dark:text-gray-300">
+
Something went wrong on our end. We've been notified and are working to fix the issue.
+
</p>
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
+
<div class="flex items-center gap-2">
+
{{ i "info" "w-4 h-4" }}
+
<span class="font-medium">we're on it!</span>
+
</div>
+
<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 gap-2">
+
{{ i "refresh-cw" "w-4 h-4" }}
+
try again
+
</button>
+
<a href="/" class="btn no-underline hover:no-underline gap-2">
+
{{ i "home" "w-4 h-4" }}
+
back to home
+
</a>
+
</div>
+
</div>
+
</div>
+
</div>
{{ end }}
+28 -5
appview/pages/templates/errors/503.html
···
{{ define "title" }}503 &middot; tangled{{ end }}
{{ define "content" }}
-
<h1>503 &mdash; unable to reach knot</h1>
-
<p>
-
We were unable to reach the knot hosting this repository. Try again
-
later.
-
</p>
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
+
<div class="mb-6">
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
+
{{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }}
+
</div>
+
</div>
+
+
<div class="space-y-4">
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
+
503 &mdash; service unavailable
+
</h1>
+
<p class="text-gray-600 dark:text-gray-300">
+
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 gap-2">
+
{{ i "refresh-cw" "w-4 h-4" }}
+
try again
+
</button>
+
<a href="/" class="btn gap-2 no-underline hover:no-underline">
+
{{ i "arrow-left" "w-4 h-4" }}
+
back to timeline
+
</a>
+
</div>
+
</div>
+
</div>
+
</div>
{{ end }}
+28
appview/pages/templates/errors/knot404.html
···
+
{{ define "title" }}404 &middot; tangled{{ end }}
+
+
{{ define "content" }}
+
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
+
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
+
<div class="mb-6">
+
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
+
{{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }}
+
</div>
+
</div>
+
+
<div class="space-y-4">
+
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
+
404 &mdash; repository not found
+
</h1>
+
<p class="text-gray-600 dark:text-gray-300">
+
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 flex items-center gap-2 no-underline hover:no-underline">
+
{{ i "arrow-left" "w-4 h-4" }}
+
back to timeline
+
</a>
+
</div>
+
</div>
+
</div>
+
</div>
+
{{ end }}
+8
appview/pages/templates/fragments/logotype.html
···
+
{{ define "fragments/logotype" }}
+
<span class="flex items-center gap-2">
+
<span class="font-bold italic">tangled</span>
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
+
alpha
+
</span>
+
<span>
+
{{ end }}
+93 -28
appview/pages/templates/knots/dashboard.html
···
-
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
+
{{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }}
{{ define "content" }}
-
<div class="px-6 py-4">
-
<div class="flex justify-between items-center">
-
<div id="left-side" class="flex gap-2 items-center">
-
<h1 class="text-xl font-bold dark:text-white">
-
{{ .Registration.Domain }}
-
</h1>
-
<span class="text-gray-500 text-base">
-
{{ template "repo/fragments/shortTimeAgo" .Registration.Created }}
+
<div class="px-6 py-4">
+
<div class="flex justify-between items-center">
+
<h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1>
+
<div id="right-side" class="flex gap-2">
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
+
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }}
+
{{ if .Registration.IsRegistered }}
+
<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>
+
{{ if $isOwner }}
+
{{ template "knots/fragments/addMemberModal" .Registration }}
+
{{ end }}
+
{{ else if .Registration.IsReadOnly }}
+
<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
</span>
-
</div>
-
<div id="right-side" class="flex gap-2">
-
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
-
{{ if .Registration.Registered }}
-
<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 "knots/fragments/addMemberModal" .Registration }}
-
{{ 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" }} pending</span>
+
{{ if $isOwner }}
+
{{ block "retryButton" .Registration }} {{ end }}
+
{{ end }}
+
{{ 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>
+
{{ if $isOwner }}
+
{{ block "retryButton" .Registration }} {{ end }}
{{ end }}
-
</div>
+
{{ end }}
+
+
{{ if $isOwner }}
+
{{ block "deleteButton" .Registration }} {{ end }}
+
{{ end }}
</div>
-
<div id="operation-error" class="dark:text-red-400"></div>
</div>
+
<div id="operation-error" class="dark:text-red-400"></div>
+
</div>
-
{{ if .Members }}
-
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
-
<div class="flex flex-col gap-2">
-
{{ block "knotMember" . }} {{ end }}
-
</div>
-
</section>
-
{{ end }}
+
{{ if .Members }}
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
+
<div class="flex flex-col gap-2">
+
{{ block "member" . }} {{ end }}
+
</div>
+
</section>
+
{{ end }}
{{ end }}
-
{{ define "knotMember" }}
+
+
{{ define "member" }}
{{ range .Members }}
<div>
<div class="flex justify-between items-center">
···
{{ template "user/fragments/picHandleLink" . }}
<span class="ml-2 font-mono text-gray-500">{{.}}</span>
</div>
+
{{ if ne $.LoggedInUser.Did . }}
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
+
{{ end }}
</div>
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
{{ $repos := index $.Repos . }}
···
</div>
{{ else }}
<div class="text-gray-500 dark:text-gray-400">
-
No repositories created yet.
+
No repositories configured yet.
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
+
+
{{ define "deleteButton" }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete knot"
+
hx-delete="/knots/{{ .Domain }}"
+
hx-swap="outerHTML"
+
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
+
hx-headers='{"shouldRedirect": "true"}'
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "retryButton" }}
+
<button
+
class="btn gap-2 group"
+
title="Retry knot verification"
+
hx-post="/knots/{{ .Domain }}/retry"
+
hx-swap="none"
+
hx-headers='{"shouldRefresh": "true"}'
+
>
+
{{ i "rotate-ccw" "w-5 h-5" }}
+
<span class="hidden md:inline">retry</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "removeMemberButton" }}
+
{{ $root := index . 0 }}
+
{{ $member := index . 1 }}
+
{{ $memberHandle := resolve $member }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Remove member"
+
hx-post="/knots/{{ $root.Registration.Domain }}/remove"
+
hx-swap="none"
+
hx-vals='{"member": "{{$member}}" }'
+
hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+
>
+
{{ i "user-minus" "w-4 h-4" }}
+
remove
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+6 -7
appview/pages/templates/knots/fragments/addMemberModal.html
···
{{ define "knots/fragments/addMemberModal" }}
<button
class="btn gap-2 group"
-
title="Add member to this spindle"
+
title="Add member to this knot"
popovertarget="add-member-{{ .Id }}"
popovertargetaction="toggle"
>
···
{{ define "addKnotMemberPopover" }}
<form
-
hx-put="/knots/{{ .Domain }}/member"
+
hx-post="/knots/{{ .Domain }}/add"
hx-indicator="#spinner"
hx-swap="none"
class="flex flex-col gap-2"
···
<label for="member-did-{{ .Id }}" class="uppercase p-0">
ADD MEMBER
</label>
-
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p>
<input
type="text"
id="member-did-{{ .Id }}"
-
name="subject"
+
name="member"
required
placeholder="@foo.bsky.social"
/>
<div class="flex gap-2 pt-2">
-
<button
+
<button
type="button"
popovertarget="add-member-{{ .Id }}"
popovertargetaction="hide"
···
</div>
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
</form>
-
{{ end }}
-
+
{{ end }}
+57 -25
appview/pages/templates/knots/fragments/knotListing.html
···
{{ define "knots/fragments/knotListing" }}
-
<div
-
id="knot-{{.Id}}"
-
hx-swap-oob="true"
-
class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
-
{{ block "listLeftSide" . }} {{ end }}
-
{{ block "listRightSide" . }} {{ end }}
+
<div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
+
{{ block "knotLeftSide" . }} {{ end }}
+
{{ block "knotRightSide" . }} {{ end }}
</div>
{{ end }}
-
{{ define "listLeftSide" }}
+
{{ define "knotLeftSide" }}
+
{{ if .Registered }}
+
<a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
+
{{ i "hard-drive" "w-4 h-4" }}
+
<span class="hover:underline">
+
{{ .Domain }}
+
</span>
+
<span class="text-gray-500">
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
+
</span>
+
</a>
+
{{ else }}
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
{{ i "hard-drive" "w-4 h-4" }}
-
{{ if .Registered }}
-
<a href="/knots/{{ .Domain }}">
-
{{ .Domain }}
-
</a>
-
{{ else }}
-
{{ .Domain }}
-
{{ end }}
+
{{ .Domain }}
<span class="text-gray-500">
{{ template "repo/fragments/shortTimeAgo" .Created }}
</span>
</div>
+
{{ end }}
{{ end }}
-
{{ define "listRightSide" }}
+
{{ define "knotRightSide" }}
<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 .Registered }}
-
<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>
+
{{ if .IsRegistered }}
+
<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 "knots/fragments/addMemberModal" . }}
+
{{ block "knotDeleteButton" . }} {{ end }}
+
{{ 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" }} needs upgrade
+
</span>
+
{{ block "knotRetryButton" . }} {{ end }}
+
{{ block "knotDeleteButton" . }} {{ end }}
{{ 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" }} pending</span>
-
{{ block "initializeButton" . }} {{ end }}
+
<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 "knotRetryButton" . }} {{ end }}
+
{{ block "knotDeleteButton" . }} {{ end }}
{{ end }}
</div>
{{ end }}
-
{{ define "initializeButton" }}
+
{{ define "knotDeleteButton" }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete knot"
+
hx-delete="/knots/{{ .Domain }}"
+
hx-swap="outerHTML"
+
hx-target="#knot-{{.Id}}"
+
hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?"
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
+
+
{{ define "knotRetryButton" }}
<button
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group"
-
hx-post="/knots/{{ .Domain }}/init"
+
class="btn gap-2 group"
+
title="Retry knot verification"
+
hx-post="/knots/{{ .Domain }}/retry"
hx-swap="none"
+
hx-target="#knot-{{.Id}}"
>
-
{{ i "square-play" "w-5 h-5" }}
-
<span class="hidden md:inline">initialize</span>
+
{{ i "rotate-ccw" "w-5 h-5" }}
+
<span class="hidden md:inline">retry</span>
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</button>
{{ end }}
-
-18
appview/pages/templates/knots/fragments/knotListingFull.html
···
-
{{ define "knots/fragments/knotListingFull" }}
-
<section
-
id="knot-listing-full"
-
hx-swap-oob="true"
-
class="rounded w-full flex flex-col gap-2">
-
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2>
-
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
-
{{ range $knot := .Registrations }}
-
{{ template "knots/fragments/knotListing" . }}
-
{{ else }}
-
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
-
no knots registered yet
-
</div>
-
{{ end }}
-
</div>
-
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
-
</section>
-
{{ end }}
-10
appview/pages/templates/knots/fragments/secret.html
···
-
{{ define "knots/fragments/secret" }}
-
<div
-
id="secret"
-
hx-swap-oob="true"
-
class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl">
-
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2>
-
<p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p>
-
<span class="font-mono overflow-x">{{ .Secret }}</span>
-
</div>
-
{{ end }}
+34 -17
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">
<div class="flex flex-col gap-6">
{{ block "about" . }} {{ end }}
-
{{ template "knots/fragments/knotListingFull" . }}
+
{{ block "list" . }} {{ end }}
{{ block "register" . }} {{ end }}
</div>
</section>
{{ 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>
+
{{ end }}
+
+
{{ define "list" }}
+
<section class="rounded w-full flex flex-col gap-2">
+
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2>
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
+
{{ range $registration := .Registrations }}
+
{{ template "knots/fragments/knotListing" . }}
+
{{ else }}
+
<div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500">
+
no knots registered yet
+
</div>
+
{{ end }}
+
</div>
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
</section>
{{ end }}
{{ define "register" }}
-
<section class="rounded max-w-2xl flex flex-col gap-2">
+
<section class="rounded w-full lg:w-fit flex flex-col gap-2">
<h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
-
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p>
+
<p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p>
<form
-
hx-post="/knots/key"
-
class="space-y-4"
+
hx-post="/knots/register"
+
class="max-w-2xl mb-2 space-y-4"
hx-indicator="#register-button"
hx-swap="none"
>
···
>
<span class="inline-flex items-center gap-2">
{{ i "plus" "w-4 h-4" }}
-
generate
+
register
</span>
<span class="pl-2 hidden group-[.htmx-request]:inline">
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
···
</button>
</div>
-
<div id="registration-error" class="error dark:text-red-400"></div>
+
<div id="register-error" class="error dark:text-red-400"></div>
</form>
-
<div id="secret"></div>
</section>
{{ end }}
+27 -12
appview/pages/templates/layouts/base.html
···
<html lang="en" class="dark:bg-gray-900">
<head>
<meta charset="UTF-8" />
-
<meta
-
name="viewport"
-
content="width=device-width, initial-scale=1.0"
-
/>
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+
<meta name="description" content="Social coding, but for real this time!"/>
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
-
<script src="/static/htmx.min.js"></script>
-
<script src="/static/htmx-ext-ws.min.js"></script>
+
+
<script defer src="/static/htmx.min.js"></script>
+
<script defer src="/static/htmx-ext-ws.min.js"></script>
+
+
<!-- preconnect to image cdn -->
+
<link rel="preconnect" href="https://avatar.tangled.sh" />
+
<link rel="preconnect" href="https://camo.tangled.sh" />
+
+
<!-- preload main font -->
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<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 }}
+
+19 -17
appview/pages/templates/layouts/repobase.html
···
</div>
<div class="flex items-center gap-2 z-auto">
+
<a
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
+
>
+
{{ i "rss" "size-4" }}
+
</a>
{{ template "repo/fragments/repoStar" .RepoInfo }}
-
<a
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
-
hx-boost="true"
-
href="/{{ .RepoInfo.FullName }}/fork"
-
>
-
{{ i "git-fork" "w-4 h-4" }}
-
fork
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</a>
+
<a
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
+
hx-boost="true"
+
href="/{{ .RepoInfo.FullName }}/fork"
+
>
+
{{ i "git-fork" "w-4 h-4" }}
+
fork
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
</div>
</div>
{{ template "repo/fragments/repoDescription" . }}
</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 }}
-80
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>
-
{{ 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 }}
+9 -3
appview/pages/templates/repo/fork.html
···
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
</div>
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
-
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
<fieldset class="space-y-3">
<legend class="dark:text-white">Select a knot to fork into</legend>
<div class="space-y-2">
···
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>
···
</fieldset>
<div class="space-y-2">
-
<button type="submit" class="btn">fork repo</button>
+
<button type="submit" class="btn-create flex items-center gap-2">
+
{{ i "git-fork" "w-4 h-4" }}
+
fork repo
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
<div id="repo" class="error"></div>
</div>
</form>
+35 -83
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 }}
-
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
-
<div id="file-{{ .Name.New }}">
-
<div id="diff-file">
-
<details open>
-
<summary class="list-none cursor-pointer sticky top-0">
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
-
<div class="flex gap-1 items-center">
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
-
{{ if .IsNew }}
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
-
{{ else if .IsDelete }}
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
-
{{ else if .IsCopy }}
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
-
{{ else if .IsRename }}
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
-
{{ else }}
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
-
{{ end }}
-
-
{{ template "repo/fragments/diffStatPill" .Stats }}
-
</div>
-
-
<div class="flex gap-2 items-center overflow-x-auto">
-
{{ if .IsDelete }}
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
-
{{ .Name.Old }}
-
</a>
-
{{ else if (or .IsCopy .IsRename) }}
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
-
{{ .Name.Old }}
-
</a>
-
{{ i "arrow-right" "w-4 h-4" }}
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
-
{{ .Name.New }}
-
</a>
-
{{ else }}
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
-
{{ .Name.New }}
-
</a>
-
{{ end }}
-
</div>
-
</div>
-
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
-
<div id="right-side-items" class="p-2 flex items-center">
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
-
{{ if gt $idx 0 }}
-
{{ $prev := index $diff (sub $idx 1) }}
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
-
{{ end }}
-
-
{{ if lt $idx $last }}
-
{{ $next := index $diff (add $idx 1) }}
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
-
{{ end }}
-
</div>
+
<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 }}">
+
<summary class="list-none cursor-pointer sticky top-0">
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+
<span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span>
+
<span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span>
+
{{ template "repo/fragments/diffStatPill" .Stats }}
-
</div>
-
</summary>
-
-
<div class="transition-all duration-700 ease-in-out">
-
{{ if .IsDelete }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This file has been deleted.
-
</p>
-
{{ else if .IsCopy }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This file has been copied.
-
</p>
-
{{ else if .IsBinary }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This is a binary file and will not be displayed.
-
</p>
-
{{ else }}
-
{{ if $isSplit }}
-
{{- template "repo/fragments/splitDiff" .Split -}}
+
<div class="flex gap-2 items-center overflow-x-auto">
+
{{ if .IsDelete }}
+
{{ .Name.Old }}
+
{{ else if (or .IsCopy .IsRename) }}
+
{{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }}
{{ else }}
-
{{- template "repo/fragments/unifiedDiff" . -}}
+
{{ .Name.New }}
{{ end }}
-
{{- end -}}
+
</div>
</div>
+
</div>
+
</summary>
-
</details>
-
+
<div class="transition-all duration-700 ease-in-out">
+
{{ if .IsBinary }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This is a binary file and will not be displayed.
+
</p>
+
{{ else }}
+
{{ if $isSplit }}
+
{{- template "repo/fragments/splitDiff" .Split -}}
+
{{ else }}
+
{{- template "repo/fragments/unifiedDiff" . -}}
+
{{ end }}
+
{{- end -}}
</div>
-
</div>
-
</section>
+
</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 }}
+
+44 -69
appview/pages/templates/repo/fragments/interdiff.html
···
<div class="flex flex-col gap-4">
{{ range $idx, $hunk := $diff }}
{{ with $hunk }}
-
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
-
<div id="file-{{ .Name }}">
-
<div id="diff-file">
-
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
-
<summary class="list-none cursor-pointer sticky top-0">
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
-
<div class="flex gap-1 items-center" style="direction: ltr;">
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
-
{{ if .Status.IsOk }}
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
-
{{ else if .Status.IsUnchanged }}
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
-
{{ else if .Status.IsOnlyInOne }}
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
-
{{ else if .Status.IsOnlyInTwo }}
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
-
{{ else if .Status.IsRebased }}
-
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
-
{{ else }}
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
-
{{ end }}
-
</div>
-
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
-
{{ .Name }}
-
</a>
-
</div>
-
</div>
-
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
-
<div id="right-side-items" class="p-2 flex items-center">
-
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
-
{{ if gt $idx 0 }}
-
{{ $prev := index $diff (sub $idx 1) }}
-
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
-
{{ end }}
-
-
{{ if lt $idx $last }}
-
{{ $next := index $diff (add $idx 1) }}
-
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
-
{{ end }}
-
</div>
-
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<summary class="list-none cursor-pointer sticky top-0">
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+
<div class="flex gap-1 items-center" style="direction: ltr;">
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
+
{{ if .Status.IsOk }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
+
{{ else if .Status.IsUnchanged }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
+
{{ else if .Status.IsOnlyInOne }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
+
{{ else if .Status.IsOnlyInTwo }}
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
+
{{ else if .Status.IsRebased }}
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
+
{{ else }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
+
{{ end }}
</div>
-
</summary>
-
<div class="transition-all duration-700 ease-in-out">
-
{{ if .Status.IsUnchanged }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This file has not been changed.
-
</p>
-
{{ else if .Status.IsRebased }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This patch was likely rebased, as context lines do not match.
-
</p>
-
{{ else if .Status.IsError }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
Failed to calculate interdiff for this file.
-
</p>
-
{{ else }}
-
{{ if $isSplit }}
-
{{- template "repo/fragments/splitDiff" .Split -}}
-
{{ else }}
-
{{- template "repo/fragments/unifiedDiff" . -}}
-
{{ end }}
-
{{- end -}}
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div>
</div>
-
</details>
+
</div>
+
</summary>
+
<div class="transition-all duration-700 ease-in-out">
+
{{ if .Status.IsUnchanged }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has not been changed.
+
</p>
+
{{ else if .Status.IsRebased }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This patch was likely rebased, as context lines do not match.
+
</p>
+
{{ else if .Status.IsError }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
Failed to calculate interdiff for this file.
+
</p>
+
{{ else }}
+
{{ if $isSplit }}
+
{{- template "repo/fragments/splitDiff" .Split -}}
+
{{ else }}
+
{{- template "repo/fragments/unifiedDiff" . -}}
+
{{ end }}
+
{{- end -}}
</div>
-
</div>
-
</section>
+
+
</details>
{{ end }}
{{ end }}
</div>
+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 }}
+
+
+47 -55
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">
···
</optgroup>
</select>
<div class="flex items-center gap-2">
-
{{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }}
-
{{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }}
-
{{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }}
-
{{ $disabled := "" }}
-
{{ $title := "" }}
-
{{ if eq .ForkInfo.Status 0 }}
-
{{ $disabled = "disabled" }}
-
{{ $title = "This branch is not behind the upstream" }}
-
{{ else if eq .ForkInfo.Status 2 }}
-
{{ $disabled = "disabled" }}
-
{{ $title = "This branch has conflicts that must be resolved" }}
-
{{ else if eq .ForkInfo.Status 3 }}
-
{{ $disabled = "disabled" }}
-
{{ $title = "This branch does not exist on the upstream" }}
-
{{ end }}
-
-
<button
-
id="syncBtn"
-
{{ $disabled }}
-
{{ if $title }}title="{{ $title }}"{{ end }}
-
class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed"
-
hx-post="/{{ .RepoInfo.FullName }}/fork/sync"
-
hx-trigger="click"
-
hx-swap="none"
-
>
-
{{ if $disabled }}
-
{{ i "refresh-cw-off" "w-4 h-4" }}
-
{{ else }}
-
{{ i "refresh-cw" "w-4 h-4" }}
-
{{ end }}
-
<span>sync</span>
-
</button>
-
{{ end }}
<a
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
class="btn flex items-center gap-2 no-underline hover:no-underline"
···
{{ define "repoAfter" }}
{{- if or .HTMLReadme .Readme -}}
-
<section
-
class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }}
-
prose dark:prose-invert dark:[&_pre]:bg-gray-900
-
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
-
dark:[&_pre]:border dark:[&_pre]:border-gray-700
-
{{ end }}"
-
>
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
-
{{- .Readme -}}
-
</pre>
-
{{- else -}}
-
{{ .HTMLReadme }}
-
{{- end -}}</article>
-
</section>
+
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
+
{{- if .ReadmeFileName -}}
+
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
+
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
+
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
+
</div>
+
{{- end -}}
+
<section
+
class="p-6 overflow-auto {{ if not .Raw }}
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+
{{ end }}"
+
>
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
+
{{- .Readme -}}
+
</pre>
+
{{- else -}}
+
{{ .HTMLReadme }}
+
{{- end -}}</article>
+
</section>
+
</div>
{{- end -}}
{{ end }}
+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 }}
+2 -2
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>
···
<button type="submit" class="btn-create flex items-center gap-2">
{{ i "book-plus" "w-4 h-4" }}
create repo
-
<span id="create-pull-spinner" class="group">
+
<span id="spinner" class="group">
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
</span>
</button>
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
>
<option disabled selected>select a fork</option>
{{ range .Forks }}
-
<option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
-
{{ .Name }}
+
<option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
+
{{ .Did | resolve }}/{{ .Name }}
</option>
{{ end }}
</select>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
{{ $icon = "git-merge" }}
{{ end }}
-
{{ $owner := resolve .Pull.OwnerDid }}
<section class="mt-2">
<div class="flex items-center gap-2">
<div
···
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
{{ if .Pull.IsForkBased }}
{{ if .Pull.PullSource.Repo }}
+
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
{{- else -}}
<span class="italic">[deleted fork]</span>
+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>
+3 -1
appview/pages/templates/repo/settings/general.html
···
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
{{ template "branchSettings" . }}
{{ template "deleteRepo" . }}
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
</div>
</section>
{{ end }}
···
unless you specify a different branch.
</p>
</div>
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
<select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
<option value="" disabled selected >
Choose a default branch
···
<button
class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
type="button"
+
hx-swap="none"
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?">
{{ i "trash-2" "size-4" }}
-192
appview/pages/templates/settings.html
···
-
{{ define "title" }}settings{{ end }}
-
-
{{ define "content" }}
-
<div class="p-6">
-
<p class="text-xl font-bold dark:text-white">Settings</p>
-
</div>
-
<div class="flex flex-col">
-
{{ block "profile" . }} {{ end }}
-
{{ block "keys" . }} {{ end }}
-
{{ block "emails" . }} {{ end }}
-
</div>
-
{{ end }}
-
-
{{ define "profile" }}
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
-
{{ if .LoggedInUser.Handle }}
-
<dt class="font-bold">handle</dt>
-
<dd>@{{ .LoggedInUser.Handle }}</dd>
-
{{ end }}
-
<dt class="font-bold">did</dt>
-
<dd>{{ .LoggedInUser.Did }}</dd>
-
<dt class="font-bold">pds</dt>
-
<dd>{{ .LoggedInUser.Pds }}</dd>
-
</dl>
-
</section>
-
{{ end }}
-
-
{{ define "keys" }}
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
-
<div id="key-list" class="flex flex-col gap-6 mb-8">
-
{{ range $index, $key := .PubKeys }}
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
-
<div class="flex flex-col gap-1">
-
<div class="inline-flex items-center gap-4">
-
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
-
<p class="font-bold dark:text-white">{{ .Name }}</p>
-
</div>
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p>
-
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
-
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
-
</div>
-
</div>
-
<button
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
-
title="Delete key"
-
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
-
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"
-
>
-
{{ i "trash-2" "w-5 h-5" }}
-
<span class="hidden md:inline">delete</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
{{ end }}
-
</div>
-
<form
-
hx-put="/settings/keys"
-
hx-indicator="#add-sshkey-spinner"
-
hx-swap="none"
-
class="max-w-2xl mb-8 space-y-4"
-
>
-
<input
-
type="text"
-
id="name"
-
name="name"
-
placeholder="key name"
-
required
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
-
-
<input
-
id="key"
-
name="key"
-
placeholder="ssh-rsa AAAAAA..."
-
required
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
-
-
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit">
-
<span>add key</span>
-
<span id="add-sshkey-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
-
<div id="settings-keys" class="error dark:text-red-400"></div>
-
</form>
-
</section>
-
{{ end }}
-
-
{{ define "emails" }}
-
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
-
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
-
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
-
<div id="email-list" class="flex flex-col gap-6 mb-8">
-
{{ range $index, $email := .Emails }}
-
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
-
<div class="flex flex-col gap-2">
-
<div class="inline-flex items-center gap-4">
-
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
-
<p class="font-bold dark:text-white">{{ .Address }}</p>
-
<div class="inline-flex items-center gap-1">
-
{{ if .Verified }}
-
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
-
{{ else }}
-
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
-
{{ end }}
-
{{ if .Primary }}
-
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
-
{{ end }}
-
</div>
-
</div>
-
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p>
-
</div>
-
<div class="flex gap-2 items-center">
-
{{ if not .Verified }}
-
<button
-
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
-
hx-post="/settings/emails/verify/resend"
-
hx-swap="none"
-
href="#"
-
hx-vals='{"email": "{{ .Address }}"}'>
-
{{ i "rotate-cw" "w-5 h-5" }}
-
<span class="hidden md:inline">resend</span>
-
</button>
-
{{ end }}
-
{{ if and (not .Primary) .Verified }}
-
<a
-
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
-
hx-post="/settings/emails/primary"
-
hx-swap="none"
-
href="#"
-
hx-vals='{"email": "{{ .Address }}"}'>
-
set as primary
-
</a>
-
{{ end }}
-
{{ if not .Primary }}
-
<form
-
hx-delete="/settings/emails"
-
hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"
-
hx-indicator="#delete-email-{{ $index }}-spinner"
-
>
-
<input type="hidden" name="email" value="{{ .Address }}">
-
<button
-
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center"
-
title="Delete email"
-
type="submit"
-
>
-
{{ i "trash-2" "w-5 h-5" }}
-
<span class="hidden md:inline">delete</span>
-
<span id="delete-email-{{ $index }}-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</form>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
<form
-
hx-put="/settings/emails"
-
hx-swap="none"
-
class="max-w-2xl mb-8 space-y-4"
-
hx-indicator="#add-email-spinner"
-
>
-
<input
-
type="email"
-
id="email"
-
name="email"
-
placeholder="your@email.com"
-
required
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
-
>
-
-
<button
-
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center"
-
type="submit"
-
>
-
<span>add email</span>
-
<span id="add-email-spinner" class="group">
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
-
<div id="settings-emails-error" class="error dark:text-red-400"></div>
-
<div id="settings-emails-success" class="success dark:text-green-400"></div>
-
</form>
-
</section>
-
{{ end }}
+2 -2
appview/pages/templates/spindles/fragments/addMemberModal.html
···
id="add-member-{{ .Instance }}"
popover
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
-
{{ block "addMemberPopover" . }} {{ end }}
+
{{ block "addSpindleMemberPopover" . }} {{ end }}
</div>
{{ end }}
-
{{ define "addMemberPopover" }}
+
{{ define "addSpindleMemberPopover" }}
<form
hx-post="/spindles/{{ .Instance }}/add"
hx-indicator="#spinner"
+17 -10
appview/pages/templates/spindles/fragments/spindleListing.html
···
{{ define "spindles/fragments/spindleListing" }}
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
-
{{ block "leftSide" . }} {{ end }}
-
{{ block "rightSide" . }} {{ end }}
+
{{ block "spindleLeftSide" . }} {{ end }}
+
{{ block "spindleRightSide" . }} {{ end }}
</div>
{{ end }}
-
{{ define "leftSide" }}
+
{{ define "spindleLeftSide" }}
{{ if .Verified }}
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
{{ i "hard-drive" "w-4 h-4" }}
-
{{ .Instance }}
+
<span class="hover:underline">
+
{{ .Instance }}
+
</span>
<span class="text-gray-500">
{{ template "repo/fragments/shortTimeAgo" .Created }}
</span>
···
{{ end }}
{{ end }}
-
{{ define "rightSide" }}
+
{{ 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 "retryButton" . }} {{ end }}
+
{{ block "spindleRetryButton" . }} {{ end }}
{{ end }}
-
{{ block "deleteButton" . }} {{ end }}
+
+
{{ block "spindleDeleteButton" . }} {{ end }}
</div>
{{ end }}
-
{{ define "deleteButton" }}
+
{{ define "spindleDeleteButton" }}
<button
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
title="Delete spindle"
···
{{ end }}
-
{{ define "retryButton" }}
+
{{ define "spindleRetryButton" }}
<button
class="btn gap-2 group"
title="Retry spindle verification"
+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" }}
+1 -1
appview/pages/templates/strings/fragments/form.html
···
type="text"
id="filename"
name="filename"
-
placeholder="Filename with extension"
+
placeholder="Filename"
required
value="{{ .String.Filename }}"
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
-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" }}
+1 -5
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">
···
title="Delete string"
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
hx-swap="none"
-
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
+
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
>
{{ i "trash-2" "size-4" }}
<span class="hidden md:inline">delete</span>
-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 }}
+
+18
appview/pages/templates/timeline/timeline.html
···
+
{{ define "title" }}timeline{{ 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" }}
+
{{ if .LoggedInUser }}
+
{{ else }}
+
{{ template "timeline/fragments/hero" . }}
+
{{ end }}
+
+
{{ template "timeline/fragments/trending" . }}
+
{{ template "timeline/fragments/timeline" . }}
+
{{ end }}
-162
appview/pages/templates/timeline.html
···
-
{{ define "title" }}timeline{{ 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="see what's tangling" />
-
{{ end }}
-
-
{{ define "topbar" }}
-
{{ template "layouts/topbar" $ }}
-
{{ end }}
-
-
{{ define "content" }}
-
{{ with .LoggedInUser }}
-
{{ block "timeline" $ }}{{ end }}
-
{{ else }}
-
{{ block "hero" $ }}{{ end }}
-
{{ block "timeline" $ }}{{ end }}
-
{{ 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 "timeline" }}
-
<div>
-
<div class="p-6">
-
<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 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 text-sm">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers">{{ .Followers }} followers</span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following">{{ .Following }} following</span>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
{{ 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.
+18
appview/pages/templates/user/followers.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
+
{{ block "followers" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "followers" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
+
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Followers }}
+
{{ template "user/fragments/followCard" . }}
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+18
appview/pages/templates/user/following.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
+
+
{{ define "profileContent" }}
+
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
+
{{ block "following" . }}{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "following" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
+
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Following }}
+
{{ template "user/fragments/followCard" . }}
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+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 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
-
<button id="followBtn"
+
<button id="{{ normalizeForHtmlId .UserDid }}"
class="btn mt-2 w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
···
{{ end }}
hx-trigger="click"
-
hx-target="#followBtn"
+
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
···
+
{{ define "user/fragments/followCard" }}
+
{{ $userIdent := resolve .UserDid }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
+
<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 class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
+
</div>
+
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
+
<a href="/{{ $userIdent }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
+
</a>
+
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
+
<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="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
+
<span class="select-none after:content-['ยท']"></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
+
</div>
+
</div>
+
+
{{ if ne .FollowStatus.String "IsSelf" }}
+
<div class="max-w-24">
+
{{ template "user/fragments/follow" . }}
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+1 -1
appview/pages/templates/user/fragments/picHandle.html
···
{{ define "user/fragments/picHandle" }}
<img
src="{{ tinyAvatar . }}"
-
alt="{{ . }}"
+
alt=""
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
/>
{{ . | truncateAt30 }}
+17 -16
appview/pages/templates/user/fragments/profileCard.html
···
{{ define "user/fragments/profileCard" }}
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
+
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
<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>
<div class="col-span-2">
<div class="flex items-center flex-row flex-nowrap gap-2">
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+
<p title="{{ $userIdent }}"
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
-
{{ didOrHandle .UserDid .UserHandle }}
+
{{ $userIdent }}
</p>
-
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
+
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
</div>
<div class="md:hidden">
-
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+
{{ block "followerFollowing" (list . $userIdent) }} {{ end }}
</div>
</div>
<div class="col-span-3 md:col-span-full">
···
{{ end }}
<div class="hidden md:block">
-
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
+
{{ block "followerFollowing" (list $ $userIdent) }} {{ end }}
</div>
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
···
{{ if .IncludeBluesky }}
<div class="flex items-center gap-2">
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
-
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a>
</div>
{{ end }}
{{ range $link := .Links }}
···
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
</div>
</div>
-
</div>
{{ end }}
{{ define "followerFollowing" }}
-
{{ $followers := index . 0 }}
-
{{ $following := index . 1 }}
-
<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">{{ $followers }} followers</span>
-
<span class="select-none after:content-['ยท']"></span>
-
<span id="following">{{ $following }} following</span>
-
</div>
+
{{ $root := index . 0 }}
+
{{ $userIdent := index . 1 }}
+
{{ 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">{{ .Stats.FollowersCount }} followers</a></span>
+
<span class="select-none after:content-['ยท']"></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
+
</div>
+
{{ end }}
{{ end }}
+5 -6
appview/pages/templates/user/fragments/repoCard.html
···
{{ $fullName := index . 2 }}
{{ with $repo }}
-
<div class="py-4 px-6 gap-2 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
+
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
<div class="font-medium dark:text-white flex items-center">
{{ if .Source }}
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
···
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}">{{ $repoOwner }}/{{ .Name }}</a>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}">{{ .Name }}</a>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
{{- end -}}
</div>
{{ with .Description }}
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
{{ . | description }}
</div>
{{ end }}
···
<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 }}/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 }}
+94
appview/pages/templates/user/settings/emails.html
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<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>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "emailSettings" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "emailSettings" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Commits authored using emails listed here will be associated with your Tangled profile.
+
</p>
+
</div>
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
+
{{ template "addEmailButton" . }}
+
</div>
+
</div>
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
{{ range .Emails }}
+
{{ template "user/settings/fragments/emailListing" (list $ .) }}
+
{{ else }}
+
<div class="flex items-center justify-center p-2 text-gray-500">
+
no emails added yet
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "addEmailButton" }}
+
<button
+
class="btn flex items-center gap-2"
+
popovertarget="add-email-modal"
+
popovertargetaction="toggle">
+
{{ i "plus" "size-4" }}
+
add email
+
</button>
+
<div
+
id="add-email-modal"
+
popover
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
{{ template "addEmailModal" . }}
+
</div>
+
{{ end}}
+
+
{{ define "addEmailModal" }}
+
<form
+
hx-put="/settings/emails"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
class="flex flex-col gap-2"
+
>
+
<p class="uppercase p-0">ADD EMAIL</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p>
+
<input
+
type="email"
+
id="email-address"
+
name="email"
+
required
+
placeholder="your@email.com"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
+
/>
+
<div class="flex gap-2 pt-2">
+
<button
+
type="button"
+
popovertarget="add-email-modal"
+
popovertargetaction="hide"
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+
>
+
{{ i "x" "size-4" }} cancel
+
</button>
+
<button type="submit" class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
<div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div>
+
<div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div>
+
</form>
+
{{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
···
+
{{ define "user/settings/fragments/emailListing" }}
+
{{ $root := index . 0 }}
+
{{ $email := index . 1 }}
+
<div id="email-{{$email.Address}}" class="flex items-center justify-between p-2">
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
+
<div class="flex items-center gap-2">
+
{{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }}
+
<span class="font-bold">
+
{{ $email.Address }}
+
</span>
+
<div class="inline-flex items-center gap-1">
+
{{ if $email.Verified }}
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
+
{{ else }}
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
+
{{ end }}
+
{{ if $email.Primary }}
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
+
{{ end }}
+
</div>
+
</div>
+
<div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span>
+
</div>
+
</div>
+
<div class="flex gap-2 items-center">
+
{{ if not $email.Verified }}
+
<button
+
class="btn flex gap-2 text-sm px-2 py-1"
+
hx-post="/settings/emails/verify/resend"
+
hx-swap="none"
+
hx-vals='{"email": "{{ $email.Address }}"}'>
+
{{ i "rotate-cw" "w-4 h-4" }}
+
<span class="hidden md:inline">resend</span>
+
</button>
+
{{ end }}
+
{{ if and (not $email.Primary) $email.Verified }}
+
<button
+
class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+
hx-post="/settings/emails/primary"
+
hx-swap="none"
+
hx-vals='{"email": "{{ $email.Address }}"}'>
+
set as primary
+
</button>
+
{{ end }}
+
{{ if not $email.Primary }}
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete email"
+
hx-delete="/settings/emails"
+
hx-swap="none"
+
hx-vals='{"email": "{{ $email.Address }}"}'
+
hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?"
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
···
+
{{ define "user/settings/fragments/keyListing" }}
+
{{ $root := index . 0 }}
+
{{ $key := index . 1 }}
+
<div id="key-{{$key.Name}}" class="flex items-center justify-between p-2">
+
<div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]">
+
<div class="flex items-center gap-2">
+
<span>{{ i "key" "w-4" "h-4" }}</span>
+
<span class="font-bold">
+
{{ $key.Name }}
+
</span>
+
</div>
+
<span class="font-mono text-sm text-gray-500 dark:text-gray-400">
+
{{ sshFingerprint $key.Key }}
+
</span>
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>added {{ template "repo/fragments/time" $key.Created }}</span>
+
</div>
+
</div>
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
+
title="Delete key"
+
hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?"
+
>
+
{{ i "trash-2" "w-5 h-5" }}
+
<span class="hidden md:inline">delete</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
{{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
···
+
{{ define "user/settings/fragments/sidebar" }}
+
{{ $active := .Tab }}
+
{{ $tabs := .Tabs }}
+
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner">
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
+
{{ range $tabs }}
+
<a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
+
<div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
+
{{ i .Icon "size-4" }}
+
{{ .Name }}
+
</div>
+
</a>
+
{{ end }}
+
</div>
+
{{ end }}
+101
appview/pages/templates/user/settings/keys.html
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<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>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "sshKeysSettings" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "sshKeysSettings" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
SSH public keys added here will be broadcasted to knots that you are a member of,
+
allowing you to push to repositories there.
+
</p>
+
</div>
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
+
{{ template "addKeyButton" . }}
+
</div>
+
</div>
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
{{ range .PubKeys }}
+
{{ template "user/settings/fragments/keyListing" (list $ .) }}
+
{{ else }}
+
<div class="flex items-center justify-center p-2 text-gray-500">
+
no keys added yet
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "addKeyButton" }}
+
<button
+
class="btn flex items-center gap-2"
+
popovertarget="add-key-modal"
+
popovertargetaction="toggle">
+
{{ i "plus" "size-4" }}
+
add key
+
</button>
+
<div
+
id="add-key-modal"
+
popover
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50">
+
{{ template "addKeyModal" . }}
+
</div>
+
{{ end}}
+
+
{{ define "addKeyModal" }}
+
<form
+
hx-put="/settings/keys"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
class="flex flex-col gap-2"
+
>
+
<p class="uppercase p-0">ADD SSH KEY</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
+
<input
+
type="text"
+
id="key-name"
+
name="name"
+
required
+
placeholder="key name"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
+
/>
+
<textarea
+
type="text"
+
id="key-value"
+
name="key"
+
required
+
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea>
+
<div class="flex gap-2 pt-2">
+
<button
+
type="button"
+
popovertarget="add-key-modal"
+
popovertargetaction="hide"
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
+
>
+
{{ i "x" "size-4" }} cancel
+
</button>
+
<button type="submit" class="btn w-1/2 flex items-center">
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
+
<span id="spinner" class="group">
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</span>
+
</button>
+
</div>
+
<div id="settings-keys" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ end }}
+64
appview/pages/templates/user/settings/profile.html
···
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Settings</p>
+
</div>
+
<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>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
+
{{ template "profileInfo" . }}
+
</div>
+
</section>
+
</div>
+
{{ end }}
+
+
{{ define "profileInfo" }}
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
+
<div class="col-span-1 md:col-span-2">
+
<h2 class="text-sm pb-2 uppercase font-bold">Profile</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Your account information from your AT Protocol identity.
+
</p>
+
</div>
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
+
</div>
+
</div>
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
+
<div class="flex items-center justify-between p-4">
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Handle</span>
+
</div>
+
{{ if .LoggedInUser.Handle }}
+
<span class="font-bold">
+
@{{ .LoggedInUser.Handle }}
+
</span>
+
{{ end }}
+
</div>
+
</div>
+
<div class="flex items-center justify-between p-4">
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Decentralized Identifier (DID)</span>
+
</div>
+
<span class="font-mono font-bold">
+
{{ .LoggedInUser.Did }}
+
</span>
+
</div>
+
</div>
+
<div class="flex items-center justify-between p-4">
+
<div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]">
+
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>Personal Data Server (PDS)</span>
+
</div>
+
<span class="font-bold">
+
{{ .LoggedInUser.Pds }}
+
</span>
+
</div>
+
</div>
+
</div>
+
{{ end }}
+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(),
+359 -190
appview/pulls/pulls.go
···
"encoding/json"
"errors"
"fmt"
-
"io"
"log"
"net/http"
"sort"
···
"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/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"
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
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/go-chi/chi/v5"
"github.com/google/uuid"
)
···
return
}
-
mergeCheckResponse := s.mergeCheck(f, pull, stack)
+
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(f, pull, stack)
+
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)
···
})
}
-
func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
+
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
if pull.State == db.PullMerged {
return types.MergeCheckResponse{}
}
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
-
if err != nil {
-
log.Printf("failed to get registration key: %v", err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: this knot is unregistered",
-
}
+
scheme := "https"
+
if s.config.Core.Dev {
+
scheme = "http"
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status",
-
}
+
xrpcc := indigoxrpc.Client{
+
Host: host,
}
patch := pull.LatestPatch()
···
patch = mergeable.CombinedPatch()
}
-
resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch)
-
if err != nil {
-
log.Println("failed to check for mergeability:", err)
+
resp, xe := tangled.RepoMergeCheck(
+
r.Context(),
+
&xrpcc,
+
&tangled.RepoMergeCheck_Input{
+
Did: f.OwnerDid(),
+
Name: f.Name,
+
Branch: pull.TargetBranch,
+
Patch: patch,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
+
log.Println("failed to check for mergeability", "err", err)
return types.MergeCheckResponse{
-
Error: "failed to check merge status",
+
Error: fmt.Sprintf("failed to check merge status: %s", err.Error()),
}
}
-
switch resp.StatusCode {
-
case 404:
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: this knot does not support PRs",
-
}
-
case 400:
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: does this knot support PRs?",
+
+
// convert xrpc response to internal types
+
conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
+
for i, conflict := range resp.Conflicts {
+
conflicts[i] = types.ConflictInfo{
+
Filename: conflict.Filename,
+
Reason: conflict.Reason,
}
}
-
respBody, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Println("failed to read merge check response body")
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: knot is not speaking the right language",
-
}
+
result := types.MergeCheckResponse{
+
IsConflicted: resp.Is_conflicted,
+
Conflicts: conflicts,
}
-
defer resp.Body.Close()
-
var mergeCheckResponse types.MergeCheckResponse
-
err = json.Unmarshal(respBody, &mergeCheckResponse)
-
if err != nil {
-
log.Println("failed to unmarshal merge check response", err)
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: knot is not speaking the right language",
-
}
+
if resp.Message != nil {
+
result.Message = *resp.Message
+
}
+
+
if resp.Error != nil {
+
result.Error = *resp.Error
}
-
return mergeCheckResponse
+
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
···
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
}
-
if latestSourceRev != result.Branch.Hash {
+
if latestSourceRev != targetBranch.Hash {
return pages.ShouldResubmit
}
···
defer tx.Rollback()
createdAt := time.Now().Format(time.RFC3339)
-
ownerDid := user.Did
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
if err != nil {
···
return
}
-
atUri := f.RepoAt().String()
client, err := s.oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client", err)
···
Rkey: tid.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPullComment{
-
Repo: &atUri,
Pull: string(pullAt),
-
Owner: &ownerDid,
Body: body,
CreatedAt: createdAt,
},
···
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
+
// }
+
+
// 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
-
}
+
// 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
}
···
}
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
-
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
+
repoString := strings.SplitN(forkRepo, "/", 2)
+
forkOwnerDid := repoString[0]
+
repoName := repoString[1]
+
fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName)
if errors.Is(err, sql.ErrNoRows) {
s.pages.Notice(w, "pull", "No such fork.")
return
···
return
}
-
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
-
if err != nil {
-
log.Println("failed to fetch registration key:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create signed client:", 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
-
}
+
client, err := s.oauth.ServiceClient(
+
r,
+
oauth.WithService(fork.Knot),
+
oauth.WithLxm(tangled.RepoHiddenRefNSID),
+
oauth.WithDev(s.config.Core.Dev),
+
)
-
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
-
if err != nil {
-
log.Println("failed to create hidden ref:", err, resp.StatusCode)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
resp, err := tangled.RepoHiddenRef(
+
r.Context(),
+
client,
+
&tangled.RepoHiddenRef_Input{
+
ForkRef: sourceBranch,
+
RemoteRef: targetBranch,
+
Repo: fork.RepoAt().String(),
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
s.pages.Notice(w, "pull", err.Error())
return
}
-
switch resp.StatusCode {
-
case 404:
-
case 400:
-
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
+
if !resp.Success {
+
errorMsg := "Failed to create pull request"
+
if resp.Error != nil {
+
errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error)
+
}
+
s.pages.Notice(w, "pull", errorMsg)
return
}
···
// hiddenRef: hidden/feature-1/main (on repo-fork)
// targetBranch: main (on repo-1)
// sourceBranch: feature-1 (on repo-fork)
-
comparison, err := us.Compare(user.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
}
···
Rkey: rkey,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPull{
-
Title: title,
-
PullId: int64(pullId),
-
TargetRepo: string(f.RepoAt()),
-
TargetBranch: targetBranch,
-
Patch: patch,
-
Source: recordPullSource,
+
Title: title,
+
Target: &tangled.RepoPull_Target{
+
Repo: string(f.RepoAt()),
+
Branch: targetBranch,
+
},
+
Patch: patch,
+
Source: recordPullSource,
},
},
})
···
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
···
forkVal := r.URL.Query().Get("fork")
-
+
repoString := strings.SplitN(forkVal, "/", 2)
+
forkOwnerDid := repoString[0]
+
forkName := repoString[1]
// fork repo
-
repo, err := db.GetRepo(s.db, user.Did, forkVal)
+
repo, err := db.GetRepo(s.db, forkOwnerDid, forkName)
if err != nil {
log.Println("failed to get repo", user.Did, forkVal)
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)
+
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
+
}
+
+
// 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
-
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver for source branches", err)
-
return
+
targetScheme := "http"
+
if !s.config.Core.Dev {
+
targetScheme = "https"
+
}
+
targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
+
targetXrpcc := &indigoxrpc.Client{
+
Host: targetHost,
-
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
+
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
-
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
-
if err != nil {
-
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
+
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
// update the hidden tracking branch to latest
-
signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
+
client, err := s.oauth.ServiceClient(
+
r,
+
oauth.WithService(forkRepo.Knot),
+
oauth.WithLxm(tangled.RepoHiddenRefNSID),
+
oauth.WithDev(s.config.Core.Dev),
+
)
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
log.Printf("failed to connect to knot server: %v", err)
return
-
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
-
if err != nil || resp.StatusCode != http.StatusNoContent {
-
log.Printf("failed to update tracking branch: %s", err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
resp, err := tangled.RepoHiddenRef(
+
r.Context(),
+
client,
+
&tangled.RepoHiddenRef_Input{
+
ForkRef: pull.PullSource.Branch,
+
RemoteRef: pull.TargetBranch,
+
Repo: forkRepo.RepoAt().String(),
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
s.pages.Notice(w, "resubmit-error", err.Error())
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())
+
if !resp.Success {
+
log.Println("Failed to update tracking ref.", "err", resp.Error)
+
s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
return
+
+
// Use the fork comparison we already made
+
comparison := forkComparison
sourceRev := comparison.Rev2
patch := comparison.Patch
···
SwapRecord: ex.Cid,
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPull{
-
Title: pull.Title,
-
PullId: int64(pull.PullId),
-
TargetRepo: string(f.RepoAt()),
-
TargetBranch: pull.TargetBranch,
-
Patch: patch, // new patch
-
Source: recordPullSource,
+
Title: pull.Title,
+
Target: &tangled.RepoPull_Target{
+
Repo: string(f.RepoAt()),
+
Branch: pull.TargetBranch,
+
},
+
Patch: patch, // new patch
+
Source: recordPullSource,
},
},
})
···
patch := pullsToMerge.CombinedPatch()
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
-
if err != nil {
-
log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
if err != nil {
log.Printf("resolving identity: %s", err)
···
log.Printf("failed to get primary email: %s", err)
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, 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-merge-error", "Failed to merge pull request. Try again later.")
-
return
+
authorName := ident.Handle.String()
+
mergeInput := &tangled.RepoMerge_Input{
+
Did: f.OwnerDid(),
+
Name: f.Name,
+
Branch: pull.TargetBranch,
+
Patch: patch,
+
CommitMessage: &pull.Title,
+
AuthorName: &authorName,
-
// Merge the pull request
-
resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.Name, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
+
if pull.Body != "" {
+
mergeInput.CommitBody = &pull.Body
+
}
+
+
if email.Address != "" {
+
mergeInput.AuthorEmail = &email.Address
+
}
+
+
client, err := s.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoMergeNSID),
+
oauth.WithDev(s.config.Core.Dev),
+
)
if err != nil {
-
log.Printf("failed to merge pull request: %s", err)
+
log.Printf("failed to connect to knot server: %v", err)
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
return
-
if resp.StatusCode != http.StatusOK {
-
log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
err = tangled.RepoMerge(r.Context(), client, mergeInput)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
s.pages.Notice(w, "pull-merge-error", err.Error())
return
+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
}
+170
appview/repo/feed.go
···
+
package repo
+
+
import (
+
"context"
+
"fmt"
+
"log"
+
"net/http"
+
"slices"
+
"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"
+
"github.com/gorilla/feeds"
+
)
+
+
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
+
const feedLimitPerType = 100
+
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
+
if err != nil {
+
return nil, err
+
}
+
+
issues, err := db.GetIssuesPaginated(
+
rp.db,
+
pagination.Page{Limit: feedLimitPerType},
+
db.FilterEq("repo_at", f.RepoAt()),
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
feed := &feeds.Feed{
+
Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"},
+
Items: make([]*feeds.Item, 0),
+
Updated: time.UnixMilli(0),
+
}
+
+
for _, pull := range pulls {
+
items, err := rp.createPullItems(ctx, pull, f)
+
if err != nil {
+
return nil, err
+
}
+
feed.Items = append(feed.Items, items...)
+
}
+
+
for _, issue := range issues {
+
item, err := rp.createIssueItem(ctx, issue, f)
+
if err != nil {
+
return nil, err
+
}
+
feed.Items = append(feed.Items, item)
+
}
+
+
slices.SortFunc(feed.Items, func(a, b *feeds.Item) int {
+
if a.Created.After(b.Created) {
+
return -1
+
}
+
return 1
+
})
+
+
if len(feed.Items) > 0 {
+
feed.Updated = feed.Items[0].Created
+
}
+
+
return feed, nil
+
}
+
+
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
+
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
+
if err != nil {
+
return nil, err
+
}
+
+
var items []*feeds.Item
+
+
state := rp.getPullState(pull)
+
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
+
+
mainItem := &feeds.Item{
+
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
+
Description: description,
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
+
Created: pull.Created,
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
+
}
+
items = append(items, mainItem)
+
+
for _, round := range pull.Submissions {
+
if round == nil || round.RoundNumber == 0 {
+
continue
+
}
+
+
roundItem := &feeds.Item{
+
Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)},
+
Created: round.Created,
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
+
}
+
items = append(items, roundItem)
+
}
+
+
return items, nil
+
}
+
+
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
+
if err != nil {
+
return nil, err
+
}
+
+
state := "closed"
+
if issue.Open {
+
state = "opened"
+
}
+
+
return &feeds.Item{
+
Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
+
Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)},
+
Created: issue.Created,
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
+
}, nil
+
}
+
+
func (rp *Repo) getPullState(pull *db.Pull) string {
+
if pull.State == db.PullOpen {
+
return "opened"
+
}
+
return pull.State.String()
+
}
+
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
+
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
+
+
if pull.State == db.PullMerged {
+
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
+
}
+
+
return fmt.Sprintf("%s in %s", base, repoName)
+
}
+
+
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to fully resolve repo:", err)
+
return
+
}
+
+
feed, err := rp.getRepoFeed(r.Context(), f)
+
if err != nil {
+
log.Println("failed to get repo feed:", err)
+
rp.pages.Error500(w)
+
return
+
}
+
+
atom, err := feed.ToAtom()
+
if err != nil {
+
rp.pages.Error500(w)
+
return
+
}
+
+
w.Header().Set("content-type", "application/atom+xml")
+
w.Write([]byte(atom))
+
}
+194 -99
appview/repo/index.go
···
package repo
import (
-
"encoding/json"
+
"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/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
+
"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)
-
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
-
if err != nil {
-
log.Printf("failed to get registration key for %s: %s", f.Knot, err)
-
rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
}
-
-
signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
-
return
-
}
-
-
var forkInfo *types.ForkInfo
-
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
-
forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient)
-
if err != nil {
-
log.Printf("Failed to fetch fork information: %v", err)
-
return
-
}
-
}
-
// TODO: a bit dirty
-
languageInfo, err := rp.getLanguageInfo(f, signedClient, 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
···
}
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
-
LoggedInUser: user,
-
RepoInfo: repoInfo,
-
TagMap: tagMap,
-
RepoIndexResponse: *result,
-
CommitsTrunc: commitsTrunc,
-
TagsTrunc: tagsTrunc,
-
ForkInfo: forkInfo,
+
LoggedInUser: user,
+
RepoInfo: repoInfo,
+
TagMap: tagMap,
+
RepoIndexResponse: *result,
+
CommitsTrunc: commitsTrunc,
+
TagsTrunc: tagsTrunc,
+
// ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands
BranchesTrunc: branchesTrunc,
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
VerifiedCommits: vc,
···
}
func (rp *Repo) getLanguageInfo(
+
ctx context.Context,
f *reporesolver.ResolvedRepo,
-
signedClient *knotclient.SignedClient,
+
xrpcc *indigoxrpc.Client,
currentRef string,
isDefaultRef bool,
) ([]types.RepoLanguageDetails, error) {
···
)
if err != nil || langs == nil {
-
// non-fatal, fetch langs from ks
-
ls, err := signedClient.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
}
-
func getForkInfo(
-
repoInfo repoinfo.RepoInfo,
-
rp *Repo,
-
f *reporesolver.ResolvedRepo,
-
currentRef string,
-
user *oauth.User,
-
signedClient *knotclient.SignedClient,
-
) (*types.ForkInfo, error) {
-
if user == nil {
-
return nil, 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)
-
forkInfo := types.ForkInfo{
-
IsFork: repoInfo.Source != nil,
-
Status: types.UpToDate,
-
}
-
-
if !forkInfo.IsFork {
-
forkInfo.IsFork = false
-
return &forkInfo, nil
-
}
-
-
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
+
// first get branches to determine the ref if not specified
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
return nil, err
}
-
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
var branchesResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
return nil, err
}
-
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
-
return branch.Name == currentRef
-
}) {
-
forkInfo.Status = types.MissingBranch
-
return &forkInfo, nil
+
// 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
+
}
}
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef)
-
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
-
log.Printf("failed to update tracking branch: %s", err)
-
return nil, err
+
// check if repo is empty
+
if len(branchesResp.Branches) == 0 {
+
return &types.RepoIndexResponse{
+
IsEmpty: true,
+
Branches: branchesResp.Branches,
+
}, nil
}
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef)
+
// 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
+
}
-
var status types.AncestorCheckResponse
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef)
-
if err != nil {
-
log.Printf("failed to check if fork is ahead/behind: %s", err)
-
return nil, err
+
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
}
-
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
-
log.Printf("failed to decode fork status: %s", err)
-
return nil, err
+
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)
+
}
}
-
forkInfo.Status = status.Status
-
return &forkInfo, nil
+
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
}
+599 -331
appview/repo/repo.go
···
"strings"
"time"
+
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"
···
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/reporesolver"
+
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"
"tangled.sh/tangled.sh/core/types"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"github.com/go-git/go-git/v5/plumbing"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
)
type Repo struct {
···
enforcer *rbac.Enforcer
notifier notify.Notifier
logger *slog.Logger
+
serviceAuth *serviceauth.ServiceAuth
}
func New(
···
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 {
-
log.Println("failed to reach knotserver", err)
+
var xrpcResp types.RepoLogResponse
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
+
log.Println("failed to decode XRPC response", err)
+
rp.pages.Error503(w)
return
}
-
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
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 {
-
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 {
-
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 the tree path has a trailing slash, let's strip it
+
// so we don't 404
+
treePath = strings.TrimSuffix(treePath, "/")
+
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
}
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return
+
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)
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, 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.tree", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
var result types.RepoTreeResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
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
+
}
+
+
result := types.RepoTreeResponse{
+
Ref: xrpcResp.Ref,
+
Files: files,
+
}
+
+
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 {
-
log.Println("failed to reach knotserver", err)
+
var result types.RepoTagsResponse
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
+
rp.pages.Error503(w)
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 {
-
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)
+
rp.pages.Error503(w)
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 {
-
log.Println("failed to reach knotserver", err)
-
return
+
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.Repo.Name)
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, 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.blob", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
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)
-
resp, err := http.Get(blobURL)
+
+
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 {
-
log.Println("failed to reach knotserver:", err)
+
log.Println("failed to create request", err)
+
return
+
}
+
+
// forward the If-None-Match header
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
+
req.Header.Set("If-None-Match", clientETag)
+
}
+
+
client := &http.Client{}
+
resp, err := client.Do(req)
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
rp.pages.Error503(w)
return
}
defer resp.Body.Close()
+
// forward 304 not modified
+
if resp.StatusCode == http.StatusNotModified {
+
w.WriteHeader(http.StatusNotModified)
+
return
+
}
+
if resp.StatusCode != http.StatusOK {
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
w.WriteHeader(resp.StatusCode)
···
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)
···
fail("Failed to write record to PDS.", err)
return
}
-
l = l.With("at-uri", resp.Uri)
+
+
aturi := resp.Uri
+
l = l.With("at-uri", aturi)
l.Info("wrote record to PDS")
-
l.Info("adding to knot")
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
-
if err != nil {
-
fail("Failed to add to knot.", err)
-
return
-
}
-
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
+
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
-
fail("Failed to add to knot.", err)
+
fail("Failed to add collaborator.", err)
return
}
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.Name, collaboratorIdent.DID.String())
-
if err != nil {
-
fail("Knot was unreachable.", err)
-
return
-
}
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rp.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, client)
-
if ksResp.StatusCode != http.StatusNoContent {
-
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
-
return
-
}
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
-
tx, err := rp.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
fail("Failed to add collaborator.", err)
-
return
-
}
-
defer func() {
-
tx.Rollback()
-
err = rp.enforcer.E.LoadPolicy()
-
if err != nil {
-
fail("Failed to add collaborator.", err)
+
if errs := errors.Join(err1, err2, err3); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
}
-
}()
+
}
+
defer rollback()
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
if err != nil {
···
return
}
+
// clear aturi to when everything is successful
+
aturi = ""
+
rp.pages.HxRefresh(w)
}
func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
+
noticeId := "operation-error"
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
···
})
if err != nil {
log.Printf("failed to delete record: %s", err)
-
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
+
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
return
}
log.Println("removed repo record ", f.RepoAt().String())
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoDeleteNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
if err != nil {
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
log.Println("failed to connect to knot server:", err)
return
}
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
+
err = tangled.RepoDelete(
+
r.Context(),
+
client,
+
&tangled.RepoDelete_Input{
+
Did: f.OwnerDid(),
+
Name: f.Name,
+
Rkey: f.Rkey,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
rp.pages.Notice(w, noticeId, err.Error())
return
}
-
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.Name)
-
if err != nil {
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
-
return
-
}
-
-
if ksResp.StatusCode != http.StatusNoContent {
-
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
-
} else {
-
log.Println("removed repo from knot ", f.Knot)
-
}
+
log.Println("deleted repo from knot")
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
···
// remove collaborator RBAC
repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
if err != nil {
-
rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
+
rp.pages.Notice(w, noticeId, "Failed to remove collaborators")
return
}
for _, c := range repoCollaborators {
···
// remove repo RBAC
err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
if err != nil {
-
rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
+
rp.pages.Notice(w, noticeId, "Failed to update RBAC rules")
return
}
// remove repo from db
err = db.RemoveRepo(tx, f.OwnerDid(), f.Name)
if err != nil {
-
rp.pages.Notice(w, "settings-delete", "Failed to update appview")
+
rp.pages.Notice(w, noticeId, "Failed to update appview")
return
}
log.Println("removed repo from db")
···
return
+
noticeId := "operation-error"
branch := r.FormValue("branch")
if branch == "" {
http.Error(w, "malformed form", http.StatusBadRequest)
return
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoSetDefaultBranchNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
if err != nil {
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
log.Println("failed to connect to knot server:", err)
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
return
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
+
xe := tangled.RepoSetDefaultBranch(
+
r.Context(),
+
client,
+
&tangled.RepoSetDefaultBranch_Input{
+
Repo: f.RepoAt().String(),
+
DefaultBranch: branch,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
+
log.Println("xrpc failed", "err", xe)
+
rp.pages.Notice(w, noticeId, err.Error())
return
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.Name, branch)
-
if err != nil {
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
-
return
-
}
-
-
if ksResp.StatusCode != http.StatusNoContent {
-
rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
-
return
-
}
-
-
w.Write(fmt.Append(nil, "default branch set to: ", branch))
+
rp.pages.HxRefresh(w)
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
···
case "pipelines":
rp.pipelineSettings(w, r)
-
-
// user := rp.oauth.GetUser(r)
-
// repoCollaborators, err := f.Collaborators(r.Context())
-
// if err != nil {
-
// log.Println("failed to get collaborators", err)
-
// }
-
-
// isCollaboratorInviteAllowed := false
-
// if user != nil {
-
// ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
-
// if err == nil && ok {
-
// isCollaboratorInviteAllowed = true
-
// }
-
// }
-
-
// us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
-
// if err != nil {
-
// log.Println("failed to create unsigned client", err)
-
// return
-
// }
-
-
// result, err := us.Branches(f.OwnerDid(), f.Name)
-
// if err != nil {
-
// log.Println("failed to reach knotserver", err)
-
// return
-
// }
-
-
// // all spindles that this user is a member of
-
// spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
-
// if err != nil {
-
// log.Println("failed to fetch spindles", err)
-
// return
-
// }
-
-
// var secrets []*tangled.RepoListSecrets_Secret
-
// if f.Spindle != "" {
-
// if spindleClient, err := rp.oauth.ServiceClient(
-
// r,
-
// oauth.WithService(f.Spindle),
-
// oauth.WithLxm(tangled.RepoListSecretsNSID),
-
// oauth.WithDev(rp.config.Core.Dev),
-
// ); err != nil {
-
// log.Println("failed to create spindle client", err)
-
// } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil {
-
// log.Println("failed to fetch secrets", err)
-
// } else {
-
// secrets = resp.Secrets
-
// }
-
// }
-
-
// rp.pages.RepoSettings(w, pages.RepoSettingsParams{
-
// LoggedInUser: user,
-
// RepoInfo: f.RepoInfo(user),
-
// Collaborators: repoCollaborators,
-
// IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
-
// Branches: result.Branches,
-
// Spindles: spindles,
-
// CurrentSpindle: f.Spindle,
-
// Secrets: secrets,
-
// })
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
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 {
-
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)
+
rp.pages.Error503(w)
return
···
switch r.Method {
case http.MethodPost:
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoForkSyncNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
if err != nil {
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
return
-
client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
+
repoInfo := f.RepoInfo(user)
+
if repoInfo.Source == nil {
+
rp.pages.Notice(w, "repo", "This repository is not a fork.")
return
-
var uri string
-
if rp.config.Core.Dev {
-
uri = "http"
-
} else {
-
uri = "https"
-
}
-
forkName := fmt.Sprintf("%s", f.Name)
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
-
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
-
if err != nil {
-
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
+
err = tangled.RepoForkSync(
+
r.Context(),
+
client,
+
&tangled.RepoForkSync_Input{
+
Did: user.Did,
+
Name: f.Name,
+
Source: repoInfo.Source.RepoAt().String(),
+
Branch: ref,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
rp.pages.Notice(w, "repo", err.Error())
return
···
})
case http.MethodPost:
+
l := rp.logger.With("handler", "ForkRepo")
-
knot := r.FormValue("knot")
-
if knot == "" {
+
targetKnot := r.FormValue("knot")
+
if targetKnot == "" {
rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.")
return
+
l = l.With("targetKnot", targetKnot)
-
ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
+
ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")
if err != nil || !ok {
rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
return
-
forkName := fmt.Sprintf("%s", f.Name)
-
+
// choose a name for a fork
+
forkName := f.Name
// this check is *only* to see if the forked repo name already exists
// in the user's account.
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
···
// repo with this name already exists, append random string
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
-
secret, err := db.GetRegistrationKey(rp.db, knot)
-
if err != nil {
-
rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
-
return
-
}
-
-
client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
-
return
-
}
+
l = l.With("forkName", forkName)
-
var uri string
+
uri := "https"
if rp.config.Core.Dev {
uri = "http"
-
} else {
-
uri = "https"
+
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
+
l = l.With("cloneUrl", forkSourceUrl)
+
sourceAt := f.RepoAt().String()
+
// create an atproto record for this fork
rkey := tid.TID()
repo := &db.Repo{
Did: user.Did,
Name: forkName,
-
Knot: knot,
+
Knot: targetKnot,
Rkey: rkey,
Source: sourceAt,
-
tx, err := rp.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println(err)
-
rp.pages.Notice(w, "repo", "Failed to save repository information.")
-
return
-
}
-
defer func() {
-
tx.Rollback()
-
err = rp.enforcer.E.LoadPolicy()
-
if err != nil {
-
log.Println("failed to rollback policies")
-
}
-
}()
-
-
resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
-
if err != nil {
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
-
return
-
}
-
-
switch resp.StatusCode {
-
case http.StatusConflict:
-
rp.pages.Notice(w, "repo", "A repository with that name already exists.")
-
return
-
case http.StatusInternalServerError:
-
rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
-
case http.StatusNoContent:
-
// continue
-
}
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
log.Println("failed to get authorized client", err)
-
rp.pages.Notice(w, "repo", "Failed to create repository.")
+
l.Error("failed to create xrpcclient", "err", err)
+
rp.pages.Notice(w, "repo", "Failed to fork repository.")
return
···
}},
})
if err != nil {
-
log.Printf("failed to create record: %s", err)
+
l.Error("failed to write to PDS", "err", err)
rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
return
-
log.Println("created repo record: ", atresp.Uri)
+
+
aturi := atresp.Uri
+
l = l.With("aturi", aturi)
+
l.Info("wrote to PDS")
+
+
tx, err := rp.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
l.Info("txn failed", "err", err)
+
rp.pages.Notice(w, "repo", "Failed to save repository information.")
+
return
+
}
+
+
// The rollback function reverts a few things on failure:
+
// - the pending txn
+
// - the ACLs
+
// - the atproto record created
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rp.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
+
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if errs := errors.Join(err1, err2, err3); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
+
}
+
}
+
defer rollback()
+
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(targetKnot),
+
oauth.WithLxm(tangled.RepoCreateNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
+
if err != nil {
+
l.Error("could not create service client", "err", err)
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
+
return
+
}
+
+
err = tangled.RepoCreate(
+
r.Context(),
+
client,
+
&tangled.RepoCreate_Input{
+
Rkey: rkey,
+
Source: &forkSourceUrl,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
rp.pages.Notice(w, "repo", err.Error())
+
return
+
}
err = db.AddRepo(tx, repo)
if err != nil {
···
// acls
p, _ := securejoin.SecureJoin(user.Did, forkName)
-
err = rp.enforcer.AddRepo(user.Did, knot, p)
+
err = rp.enforcer.AddRepo(user.Did, targetKnot, p)
if err != nil {
log.Println(err)
rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
···
return
+
// reset the ATURI because the transaction completed successfully
+
aturi = ""
+
+
rp.notifier.NewRepo(r.Context(), repo)
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
-
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
+
}
+
func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
···
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
+
}
+
+
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
-
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
+
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)
+1
appview/repo/router.go
···
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
r.Get("/", rp.RepoIndex)
+
r.Get("/feed.atom", rp.RepoAtomFeed)
r.Get("/commits/{ref}", rp.RepoLog)
r.Route("/tree/{ref}", func(r chi.Router) {
r.Get("/", rp.RepoIndex)
+148
appview/serververify/verify.go
···
+
package serververify
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
+
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"
+
)
+
+
var (
+
FetchError = errors.New("failed to fetch owner")
+
)
+
+
// fetchOwner fetches the owner DID from a server's /owner endpoint
+
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
+
scheme := "https"
+
if dev {
+
scheme = "http"
+
}
+
+
host := fmt.Sprintf("%s://%s", scheme, domain)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
res, err := tangled.Owner(ctx, xrpcc)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
return "", xrpcerr
+
}
+
+
return res.Owner, nil
+
}
+
+
type OwnerMismatch struct {
+
expected string
+
observed string
+
}
+
+
func (e *OwnerMismatch) Error() string {
+
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
+
}
+
+
// RunVerification verifies that the server at the given domain has the expected owner
+
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
+
observedOwner, err := fetchOwner(ctx, domain, dev)
+
if err != nil {
+
return err
+
}
+
+
if observedOwner != expectedOwner {
+
return &OwnerMismatch{
+
expected: expectedOwner,
+
observed: observedOwner,
+
}
+
}
+
+
return nil
+
}
+
+
// MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner
+
func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
+
tx, err := d.Begin()
+
if err != nil {
+
return 0, fmt.Errorf("failed to create txn: %w", err)
+
}
+
defer func() {
+
tx.Rollback()
+
e.E.LoadPolicy()
+
}()
+
+
// mark this spindle as verified in the db
+
rowId, err := db.VerifySpindle(
+
tx,
+
db.FilterEq("owner", owner),
+
db.FilterEq("instance", instance),
+
)
+
if err != nil {
+
return 0, fmt.Errorf("failed to write to DB: %w", err)
+
}
+
+
err = e.AddSpindleOwner(instance, owner)
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return 0, fmt.Errorf("failed to commit txn: %w", err)
+
}
+
+
err = e.E.SavePolicy()
+
if err != nil {
+
return 0, fmt.Errorf("failed to update ACL: %w", err)
+
}
+
+
return rowId, nil
+
}
+
+
// MarkKnotVerified marks a knot as verified and sets up ownership/permissions
+
func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error {
+
tx, err := d.BeginTx(context.Background(), nil)
+
if err != nil {
+
return fmt.Errorf("failed to start tx: %w", err)
+
}
+
defer func() {
+
tx.Rollback()
+
e.E.LoadPolicy()
+
}()
+
+
// mark as registered
+
err = db.MarkRegistered(
+
tx,
+
db.FilterEq("did", owner),
+
db.FilterEq("domain", domain),
+
)
+
if err != nil {
+
return fmt.Errorf("failed to register domain: %w", err)
+
}
+
+
// add basic acls for this domain
+
err = e.AddKnot(domain)
+
if err != nil {
+
return fmt.Errorf("failed to add knot to enforcer: %w", err)
+
}
+
+
// add this did as owner of this domain
+
err = e.AddKnotOwner(domain, owner)
+
if err != nil {
+
return fmt.Errorf("failed to add knot owner to enforcer: %w", err)
+
}
+
+
err = tx.Commit()
+
if err != nil {
+
return fmt.Errorf("failed to commit changes: %w", err)
+
}
+
+
err = e.E.SavePolicy()
+
if err != nil {
+
return fmt.Errorf("failed to update ACLs: %w", err)
+
}
+
+
return nil
+
}
+44 -9
appview/settings/settings.go
···
Config *config.Config
}
+
type tab = map[string]any
+
+
var (
+
settingsTabs []tab = []tab{
+
{"Name": "profile", "Icon": "user"},
+
{"Name": "keys", "Icon": "key"},
+
{"Name": "emails", "Icon": "mail"},
+
}
+
)
+
func (s *Settings) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.AuthMiddleware(s.OAuth))
-
r.Get("/", s.settings)
+
// settings pages
+
r.Get("/", s.profileSettings)
+
r.Get("/profile", s.profileSettings)
r.Route("/keys", func(r chi.Router) {
+
r.Get("/", s.keysSettings)
r.Put("/", s.keys)
r.Delete("/", s.keys)
})
r.Route("/emails", func(r chi.Router) {
+
r.Get("/", s.emailsSettings)
r.Put("/", s.emails)
r.Delete("/", s.emails)
r.Get("/verify", s.emailsVerify)
···
return r
}
-
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
+
func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) {
+
user := s.OAuth.GetUser(r)
+
+
s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
+
LoggedInUser: user,
+
Tabs: settingsTabs,
+
Tab: "profile",
+
})
+
}
+
+
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
user := s.OAuth.GetUser(r)
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
if err != nil {
log.Println(err)
}
+
s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
+
LoggedInUser: user,
+
PubKeys: pubKeys,
+
Tabs: settingsTabs,
+
Tab: "keys",
+
})
+
}
+
+
func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) {
+
user := s.OAuth.GetUser(r)
emails, err := db.GetAllEmails(s.Db, user.Did)
if err != nil {
log.Println(err)
}
-
s.Pages.Settings(w, pages.SettingsParams{
+
s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
LoggedInUser: user,
-
PubKeys: pubKeys,
Emails: emails,
+
Tabs: settingsTabs,
+
Tab: "emails",
})
}
···
return
}
-
s.Pages.HxLocation(w, "/settings")
+
s.Pages.HxLocation(w, "/settings/emails")
return
}
}
···
return
}
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
+
http.Redirect(w, r, "/settings/emails", http.StatusSeeOther)
}
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
···
return
}
-
s.Pages.HxLocation(w, "/settings")
+
s.Pages.HxLocation(w, "/settings/emails")
}
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
···
return
}
-
s.Pages.HxLocation(w, "/settings")
+
s.Pages.HxLocation(w, "/settings/keys")
return
case http.MethodDelete:
···
}
log.Println("deleted successfully")
-
s.Pages.HxLocation(w, "/settings")
+
s.Pages.HxLocation(w, "/settings/keys")
return
}
}
+10 -9
appview/spindles/spindles.go
···
"tangled.sh/tangled.sh/core/appview/middleware"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
verify "tangled.sh/tangled.sh/core/appview/spindleverify"
+
"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"
···
}
// begin verification
-
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
+
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
if err != nil {
l.Error("verification failed", "err", err)
s.Pages.HxRefresh(w)
return
}
-
_, err = verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
+
_, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
if err != nil {
l.Error("failed to mark verified", "err", err)
s.Pages.HxRefresh(w)
···
}
// begin verification
-
err = verify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
+
err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev)
if err != nil {
l.Error("verification failed", "err", err)
-
if errors.Is(err, verify.FetchError) {
-
s.Pages.Notice(w, noticeId, err.Error())
+
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
}
-
if e, ok := err.(*verify.OwnerMismatch); ok {
+
if e, ok := err.(*serververify.OwnerMismatch); ok {
s.Pages.Notice(w, noticeId, e.Error())
return
}
···
return
}
-
rowId, err := verify.MarkVerified(s.Db, s.Enforcer, instance, user.Did)
+
rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did)
if err != nil {
l.Error("failed to mark verified", "err", err)
s.Pages.Notice(w, noticeId, err.Error())
···
}
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) {
-118
appview/spindleverify/verify.go
···
-
package spindleverify
-
-
import (
-
"context"
-
"errors"
-
"fmt"
-
"io"
-
"net/http"
-
"strings"
-
"time"
-
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/rbac"
-
)
-
-
var (
-
FetchError = errors.New("failed to fetch owner")
-
)
-
-
// TODO: move this to "spindleclient" or similar
-
func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
-
scheme := "https"
-
if dev {
-
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")
-
}
-
-
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)
-
}
-
-
did := strings.TrimSpace(string(body))
-
if did == "" {
-
return "", fmt.Errorf("empty DID in /owner response")
-
}
-
-
return did, nil
-
}
-
-
type OwnerMismatch struct {
-
expected string
-
observed string
-
}
-
-
func (e *OwnerMismatch) Error() string {
-
return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed)
-
}
-
-
func RunVerification(ctx context.Context, instance, expectedOwner string, dev bool) error {
-
// begin verification
-
observedOwner, err := fetchOwner(ctx, instance, dev)
-
if err != nil {
-
return fmt.Errorf("%w: %w", FetchError, err)
-
}
-
-
if observedOwner != expectedOwner {
-
return &OwnerMismatch{
-
expected: expectedOwner,
-
observed: observedOwner,
-
}
-
}
-
-
return nil
-
}
-
-
// mark this spindle as verified in the DB and add this user as its owner
-
func MarkVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) {
-
tx, err := d.Begin()
-
if err != nil {
-
return 0, fmt.Errorf("failed to create txn: %w", err)
-
}
-
defer func() {
-
tx.Rollback()
-
e.E.LoadPolicy()
-
}()
-
-
// mark this spindle as verified in the db
-
rowId, err := db.VerifySpindle(
-
tx,
-
db.FilterEq("owner", owner),
-
db.FilterEq("instance", instance),
-
)
-
if err != nil {
-
return 0, fmt.Errorf("failed to write to DB: %w", err)
-
}
-
-
err = e.AddSpindleOwner(instance, owner)
-
if err != nil {
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
-
}
-
-
err = tx.Commit()
-
if err != nil {
-
return 0, fmt.Errorf("failed to commit txn: %w", err)
-
}
-
-
err = e.E.SavePolicy()
-
if err != nil {
-
return 0, fmt.Errorf("failed to update ACL: %w", err)
-
}
-
-
return rowId, nil
-
}
+9 -12
appview/state/git_http.go
···
import (
"fmt"
"io"
+
"maps"
"net/http"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/appview/db"
)
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("resolvedId").(identity.Identity)
-
knot := r.Context().Value("knot").(string)
-
repo := chi.URLParam(r, "repo")
+
repo := r.Context().Value("repo").(*db.Repo)
scheme := "https"
if s.config.Core.Dev {
scheme = "http"
}
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
s.proxyRequest(w, r, targetURL)
}
···
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
return
}
-
knot := r.Context().Value("knot").(string)
-
repo := chi.URLParam(r, "repo")
+
repo := r.Context().Value("repo").(*db.Repo)
scheme := "https"
if s.config.Core.Dev {
scheme = "http"
}
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
s.proxyRequest(w, r, targetURL)
}
···
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
return
}
-
knot := r.Context().Value("knot").(string)
-
repo := chi.URLParam(r, "repo")
+
repo := r.Context().Value("repo").(*db.Repo)
scheme := "https"
if s.config.Core.Dev {
scheme = "http"
}
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
s.proxyRequest(w, r, targetURL)
}
···
defer resp.Body.Close()
// Copy response headers
-
for k, v := range resp.Header {
-
w.Header()[k] = v
-
}
+
maps.Copy(w.Header(), resp.Header)
// Set response status code
w.WriteHeader(resp.StatusCode)
+5 -2
appview/state/knotstream.go
···
)
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
-
knots, err := db.GetCompletedRegistrations(d)
+
knots, err := db.GetRegistrations(
+
d,
+
db.FilterIsNot("registered", "null"),
+
)
if err != nil {
return nil, err
}
srcs := make(map[ec.Source]struct{})
for _, k := range knots {
-
s := ec.NewKnotSource(k)
+
s := ec.NewKnotSource(k.Domain)
srcs[s] = struct{}{}
}
+376 -134
appview/state/profile.go
···
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
tabVal := r.URL.Query().Get("tab")
switch tabVal {
-
case "":
-
s.profilePage(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)
}
}
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
+
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
+
return nil, fmt.Errorf("empty DID or handle")
}
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
if !ok {
-
s.pages.Error404(w)
-
return
+
return nil, fmt.Errorf("failed to resolve ID")
}
+
did := ident.DID.String()
-
profile, err := db.GetProfile(s.db, ident.DID.String())
+
profile, err := db.GetProfile(s.db, did)
if err != nil {
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
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 {
+
return nil, fmt.Errorf("failed to get follower stats: %w", err)
+
}
+
+
loggedInUser := s.oauth.GetUser(r)
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
+
}
+
+
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) 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)
+
repos, err := db.GetRepos(
s.db,
0,
-
db.FilterEq("did", ident.DID.String()),
+
db.FilterEq("did", profile.UserDid),
)
if err != nil {
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
+
l.Error("failed to fetch repos", "err", err)
}
// 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, ident.DID.String())
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
if err != nil {
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), 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, ident.DID.String())
+
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
if err != nil {
-
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
+
l.Error("failed to create timeline", "err", err)
}
-
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
+
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 {
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
+
return
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
-
loggedInUser := s.oauth.GetUser(r)
-
followStatus := db.IsNotFollowing
-
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
-
}
-
-
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", ident.DID.String()),
-
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", ident.DID.String(), "err", err)
+
l.Error("failed to get repos", "err", err)
+
s.pages.Error500(w)
+
return
}
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
-
LoggedInUser: loggedInUser,
-
Repos: pinnedRepos,
-
CollaboratingRepos: pinnedCollaboratingRepos,
-
Card: pages.ProfileCard{
-
UserDid: ident.DID.String(),
-
UserHandle: ident.Handle.String(),
-
Profile: profile,
-
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
-
},
-
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) {
-
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
-
if !ok {
-
s.pages.Error404(w)
+
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)
-
profile, err := db.GetProfile(s.db, ident.DID.String())
+
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
if err != nil {
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
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", ident.DID.String()),
+
db.FilterIn("at_uri", repoAts),
)
if err != nil {
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
+
l.Error("failed to get repos", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
+
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Repos: repos,
+
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 {
+
Follows []pages.FollowCard
+
Card *pages.ProfileCard
+
}
+
+
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)
loggedInUser := s.oauth.GetUser(r)
-
followStatus := db.IsNotFollowing
+
params := FollowsPageParams{
+
Card: profile,
+
}
+
+
follows, err := fetchFollows(s.db, profile.UserDid)
+
if err != nil {
+
l.Error("failed to fetch follows", "err", err)
+
return &params, err
+
}
+
+
if len(follows) == 0 {
+
return &params, nil
+
}
+
+
followDids := make([]string, 0, len(follows))
+
for _, follow := range follows {
+
followDids = append(followDids, extractDid(follow))
+
}
+
+
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
+
if err != nil {
+
l.Error("failed to get profiles", "followDids", followDids, "err", err)
+
return &params, err
+
}
+
+
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
+
if err != nil {
+
log.Printf("getting follow counts for %s: %s", followDids, err)
+
}
+
+
loggedInUserFollowing := make(map[string]struct{})
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
+
following, err := db.GetFollowing(s.db, loggedInUser.Did)
+
if err != nil {
+
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
+
return &params, err
+
}
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
+
for _, follow := range following {
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
+
}
}
-
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
+
followCards := make([]pages.FollowCard, len(follows))
+
for i, did := range followDids {
+
followStats := followStatsMap[did]
+
followStatus := db.IsNotFollowing
+
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
+
} else {
+
profile = &db.Profile{}
+
profile.Did = did
+
}
+
followCards[i] = pages.FollowCard{
+
UserDid: did,
+
FollowStatus: followStatus,
+
FollowersCount: followStats.Followers,
+
FollowingCount: followStats.Following,
+
Profile: profile,
+
}
+
}
+
+
params.Follows = followCards
+
+
return &params, nil
+
}
+
+
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
+
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
if err != nil {
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
s.pages.Notice(w, "all-followers", "Failed to load followers")
+
return
}
-
s.pages.ReposPage(w, pages.ReposPageParams{
-
LoggedInUser: loggedInUser,
-
Repos: repos,
-
Card: pages.ProfileCard{
-
UserDid: ident.DID.String(),
-
UserHandle: ident.Handle.String(),
-
Profile: profile,
-
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
-
},
+
s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Followers: followPage.Follows,
+
Card: followPage.Card,
})
}
-
func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed {
+
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
+
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.ProfileFollowing(w, pages.ProfileFollowingParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Following: followPage.Follows,
+
Card: followPage.Card,
+
})
+
}
+
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
if !ok {
s.pages.Error404(w)
-
return nil
+
return
}
-
feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String())
+
feed, err := s.getProfileFeed(r.Context(), &ident)
if err != nil {
s.pages.Error500(w)
-
return nil
+
return
}
-
return feed
-
}
-
-
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
-
feed := s.feedFromRequest(w, r)
if feed == nil {
return
}
···
w.Write([]byte(atom))
}
-
func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) {
-
timeline, err := db.MakeProfileTimeline(s.db, did)
+
func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
if err != nil {
return nil, err
}
author := &feeds.Author{
-
Name: fmt.Sprintf("@%s", handle),
+
Name: fmt.Sprintf("@%s", id.Handle),
}
-
feed := &feeds.Feed{
-
Title: fmt.Sprintf("timeline feed for %s", author.Name),
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"},
+
+
feed := feeds.Feed{
+
Title: fmt.Sprintf("%s's timeline", author.Name),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
Items: make([]*feeds.Item, 0),
Updated: time.UnixMilli(0),
Author: author,
}
+
for _, byMonth := range timeline.ByMonth {
-
for _, pull := range byMonth.PullEvents.Items {
-
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
-
if err != nil {
-
return nil, err
-
}
-
feed.Items = append(feed.Items, &feeds.Item{
-
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
-
Created: pull.Created,
-
Author: author,
-
})
-
for _, submission := range pull.Submissions {
-
feed.Items = append(feed.Items, &feeds.Item{
-
Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name),
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
-
Created: submission.Created,
-
Author: author,
-
})
-
}
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
+
return nil, err
}
-
for _, issue := range byMonth.IssueEvents.Items {
-
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
-
if err != nil {
-
return nil, err
-
}
-
feed.Items = append(feed.Items, &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"},
-
Created: issue.Created,
-
Author: author,
-
})
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
+
return nil, err
}
-
for _, repo := range byMonth.RepoEvents {
-
var title string
-
if repo.Source != nil {
-
id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
-
if err != nil {
-
return nil, err
-
}
-
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name)
-
} else {
-
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
-
}
-
feed.Items = append(feed.Items, &feeds.Item{
-
Title: title,
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"},
-
Created: repo.Repo.Created,
-
Author: author,
-
})
+
if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
+
return nil, err
}
}
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
})
+
if len(feed.Items) > 0 {
feed.Updated = feed.Items[0].Created
}
-
return feed, nil
+
return &feed, nil
+
}
+
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
+
for _, pull := range pulls {
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
+
if err != nil {
+
return err
+
}
+
+
// Add pull request creation item
+
feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
+
}
+
return nil
+
}
+
+
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.Repo.Did)
+
if err != nil {
+
return err
+
}
+
+
feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
+
}
+
return nil
+
}
+
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
+
for _, repo := range repos {
+
item, err := s.createRepoItem(ctx, repo, author)
+
if err != nil {
+
return err
+
}
+
feed.Items = append(feed.Items, item)
+
}
+
return nil
+
}
+
+
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
+
return &feeds.Item{
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
+
Created: pull.Created,
+
Author: author,
+
}
+
}
+
+
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.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,
+
}
+
}
+
+
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
+
var title string
+
if repo.Source != nil {
+
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
+
if err != nil {
+
return nil, err
+
}
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
+
} else {
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
+
}
+
+
return &feeds.Item{
+
Title: title,
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
+
Created: repo.Repo.Created,
+
Author: author,
+
}, nil
}
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
···
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)
}
+7 -5
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) {
···
r.Mount("/settings", s.SettingsRouter())
r.Mount("/strings", s.StringsRouter(mw))
-
r.Mount("/knots", s.KnotsRouter(mw))
+
r.Mount("/knots", s.KnotsRouter())
r.Mount("/spindles", s.SpindlesRouter())
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
···
return spindles.Router()
}
-
func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler {
+
func (s *State) KnotsRouter() http.Handler {
logger := log.New("knots")
knots := &knots.Knots{
···
Logger: logger,
}
-
return knots.Router(mw)
+
return knots.Router()
}
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
···
}
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)
}
+181 -43
appview/state/state.go
···
import (
"context"
+
"database/sql"
+
"errors"
"fmt"
"log"
"log/slog"
···
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
···
"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"
"tangled.sh/tangled.sh/core/jetstream"
-
"tangled.sh/tangled.sh/core/knotclient"
tlog "tangled.sh/tangled.sh/core/log"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/tid"
+
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
type State struct {
···
repoResolver *reporesolver.RepoResolver
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 {
···
repoResolver,
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.")
}
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
+
return
+
}
+
s.pages.Timeline(w, pages.TimelineParams{
LoggedInUser: user,
Timeline: timeline,
+
Repos: repos,
+
})
+
}
+
+
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,
})
}
···
for _, k := range pubKeys {
key := strings.TrimRight(k.Key, "\n")
-
w.Write([]byte(fmt.Sprintln(key)))
+
fmt.Fprintln(w, key)
}
}
···
})
case http.MethodPost:
+
l := s.logger.With("handler", "NewRepo")
+
user := s.oauth.GetUser(r)
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
// form validation
domain := r.FormValue("domain")
if domain == "" {
s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.")
return
}
+
l = l.With("knot", domain)
repoName := r.FormValue("name")
if repoName == "" {
···
s.pages.Notice(w, "repo", err.Error())
return
}
-
repoName = stripGitExt(repoName)
+
l = l.With("repoName", repoName)
defaultBranch := r.FormValue("branch")
if defaultBranch == "" {
defaultBranch = "main"
}
+
l = l.With("defaultBranch", defaultBranch)
description := r.FormValue("description")
+
// ACL validation
ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
if err != nil || !ok {
+
l.Info("unauthorized")
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
return
}
+
// Check for existing repos
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
if err == nil && existingRepo != nil {
-
s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot))
-
return
-
}
-
-
secret, err := db.GetRegistrationKey(s.db, domain)
-
if err != nil {
-
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
-
return
-
}
-
-
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
-
if err != nil {
-
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
+
l.Info("repo exists")
+
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
return
}
+
// create atproto record for this repo
rkey := tid.TID()
repo := &db.Repo{
Did: user.Did,
···
xrpcClient, err := s.oauth.AuthorizedClient(r)
if err != nil {
+
l.Info("PDS write failed", "err", err)
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
return
}
···
}},
})
if err != nil {
-
log.Printf("failed to create record: %s", err)
+
l.Info("PDS write failed", "err", err)
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
return
}
-
log.Println("created repo record: ", atresp.Uri)
+
+
aturi := atresp.Uri
+
l = l.With("aturi", aturi)
+
l.Info("wrote to PDS")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
-
log.Println(err)
+
l.Info("txn failed", "err", err)
s.pages.Notice(w, "repo", "Failed to save repository information.")
return
}
-
defer func() {
-
tx.Rollback()
-
err = s.enforcer.E.LoadPolicy()
-
if err != nil {
-
log.Println("failed to rollback policies")
+
+
// The rollback function reverts a few things on failure:
+
// - the pending txn
+
// - the ACLs
+
// - the atproto record created
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := s.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
+
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
}
-
}()
-
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
+
if errs := errors.Join(err1, err2, err3); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
+
}
+
}
+
defer rollback()
+
+
client, err := s.oauth.ServiceClient(
+
r,
+
oauth.WithService(domain),
+
oauth.WithLxm(tangled.RepoCreateNSID),
+
oauth.WithDev(s.config.Core.Dev),
+
)
if err != nil {
-
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
+
l.Error("service auth failed", "err", err)
+
s.pages.Notice(w, "repo", "Failed to reach PDS.")
return
}
-
switch resp.StatusCode {
-
case http.StatusConflict:
-
s.pages.Notice(w, "repo", "A repository with that name already exists.")
+
xe := tangled.RepoCreate(
+
r.Context(),
+
client,
+
&tangled.RepoCreate_Input{
+
Rkey: rkey,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
+
l.Error("xrpc error", "xe", xe)
+
s.pages.Notice(w, "repo", err.Error())
return
-
case http.StatusInternalServerError:
-
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
-
case http.StatusNoContent:
-
// continue
}
err = db.AddRepo(tx, repo)
if err != nil {
-
log.Println(err)
+
l.Error("db write failed", "err", err)
s.pages.Notice(w, "repo", "Failed to save repository information.")
return
}
···
p, _ := securejoin.SecureJoin(user.Did, repoName)
err = s.enforcer.AddRepo(user.Did, domain, p)
if err != nil {
-
log.Println(err)
+
l.Error("acl setup failed", "err", err)
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
return
}
err = tx.Commit()
if err != nil {
-
log.Println("failed to commit changes", err)
+
l.Error("txn commit failed", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = s.enforcer.E.SavePolicy()
if err != nil {
-
log.Println("failed to update ACLs", err)
+
l.Error("acl save failed", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+
+
// reset the ATURI because the transaction completed successfully
+
aturi = ""
s.notifier.NewRepo(r.Context(), repo)
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
-
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
+
}
+2 -71
appview/strings/strings.go
···
"log/slog"
"net/http"
"path"
-
"slices"
"strconv"
-
"strings"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
···
}
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())
-
}
-
-
followers, following, 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,
-
Followers: followers,
-
Following: 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) {
···
fail("Empty filename.", nil)
return
}
-
if !strings.Contains(filename, ".") {
-
// TODO: make this a htmx form validation
-
fail("No extension provided for filename.", nil)
-
return
-
}
content := r.FormValue("content")
if content == "" {
···
fail("Empty filename.", nil)
return
}
-
if !strings.Contains(filename, ".") {
-
// TODO: make this a htmx form validation
-
fail("No extension provided for filename.", nil)
-
return
-
}
content := r.FormValue("content")
if content == "" {
···
}
if user.Did != id.DID.String() {
-
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
return
}
+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(),
+
}
+
}
+31
appview/xrpcclient/xrpc.go
···
import (
"bytes"
"context"
+
"errors"
"io"
+
"net/http"
"github.com/bluesky-social/indigo/api/atproto"
"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 {
···
return &out, nil
}
+
+
// produces a more manageable error
+
func HandleXrpcErr(err error) error {
+
if err == nil {
+
return nil
+
}
+
+
var xrpcerr *indigoxrpc.Error
+
if ok := errors.As(err, &xrpcerr); !ok {
+
return ErrXrpcInvalid
+
}
+
+
switch xrpcerr.StatusCode {
+
case http.StatusNotFound:
+
return ErrXrpcUnsupported
+
case http.StatusUnauthorized:
+
return ErrXrpcUnauthorized
+
default:
+
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)
+6 -4
cmd/gen.go
···
tangled.FeedReaction{},
tangled.FeedStar{},
tangled.GitRefUpdate{},
+
tangled.GitRefUpdate_CommitCountBreakdown{},
+
tangled.GitRefUpdate_IndividualEmailCommitCount{},
+
tangled.GitRefUpdate_LangBreakdown{},
+
tangled.GitRefUpdate_IndividualLanguageSize{},
tangled.GitRefUpdate_Meta{},
-
tangled.GitRefUpdate_Meta_CommitCount{},
-
tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{},
-
tangled.GitRefUpdate_Meta_LangBreakdown{},
-
tangled.GitRefUpdate_Pair{},
tangled.GraphFollow{},
+
tangled.Knot{},
tangled.KnotMember{},
tangled.Pipeline{},
tangled.Pipeline_CloneOpts{},
···
tangled.RepoPullComment{},
tangled.RepoPull_Source{},
tangled.RepoPullStatus{},
+
tangled.RepoPull_Target{},
tangled.Spindle{},
tangled.SpindleMember{},
tangled.String{},
+3 -3
docs/contributing.md
···
### message format
```
-
<service/top-level directory>: <affected package/directory>: <short summary of change>
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
Optional longer description can go here, if necessary. Explain what the
···
Here are some examples:
```
-
appview: state: fix token expiry check in middleware
+
appview/state: fix token expiry check in middleware
The previous check did not account for clock drift, leading to premature
token invalidation.
```
```
-
knotserver: git/service: improve error checking in upload-pack
+
knotserver/git/service: improve error checking in upload-pack
```
+63 -22
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, head to `http://localhost:3000/knots` in the browser
-
and create a knot with hostname `localhost:6000`. This will
-
generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it,
-
ideally in a `.envrc` with [direnv](https://direnv.net) so you
-
don't lose it.
+
<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`.
+
+
> 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 will also need to set the `$TANGLED_VM_SPINDLE_OWNER`
-
variable to some value. If you don't want to [set up a
-
spindle](#running-a-spindle), you can use any placeholder
-
value.
+
</details>
-
You can now start a lightweight NixOS VM like 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. You can now start a
+
lightweight NixOS VM like so:
```bash
nix run --impure .#vm
···
```
This starts a knot on port 6000, a spindle on port 6555
-
with `ssh` exposed on port 2222. You can push repositories
-
to this VM with this ssh config block on your main machine:
+
with `ssh` exposed on port 2222.
+
+
Once the services are running, head to
+
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:
```bash
Host nixos-shell
···
git push local-dev main
```
-
## running a spindle
+
### running a spindle
-
You will need to find out your DID by entering your login handle into
-
<https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID.
-
-
The above VM should already be running a spindle on `localhost:6555`.
-
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
-
and register a spindle with hostname `localhost:6555`. It should instantly
-
be verified. You can then configure each repository to use this spindle
-
and run CI jobs.
+
The above VM should already be running a spindle on
+
`localhost:6555`. Head to http://localhost:3000/spindles and
+
hit verify. You can then configure each repository to use
+
this spindle and run CI jobs.
Of interest when debugging spindles:
···
# 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`.
+7 -5
docs/knot-hosting.md
···
```
Create `/home/git/.knot.env` with the following, updating the values as
-
necessary. The `KNOT_SERVER_SECRET` can be obtained from the
-
[/knots](https://tangled.sh/knots) page on Tangled.
+
necessary. The `KNOT_SERVER_OWNER` should be set to your
+
DID, you can find your DID in the [Settings](https://tangled.sh/settings) page.
```
KNOT_REPO_SCAN_PATH=/home/git
KNOT_SERVER_HOSTNAME=knot.example.com
APPVIEW_ENDPOINT=https://tangled.sh
-
KNOT_SERVER_SECRET=secret
+
KNOT_SERVER_OWNER=did:plc:foobar
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
```
···
Remember to use Let's Encrypt or similar to procure a certificate for your
knot domain.
-
You should now have a running knot server! You can finalize your registration by hitting the
-
`initialize` button on the [/knots](https://tangled.sh/knots) page.
+
You should now have a running knot server! You can finalize
+
your registration by hitting the `verify` button on the
+
[/knots](https://tangled.sh/knots) page. This simply creates
+
a record on your PDS to announce the existence of the knot.
### custom paths
+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";
+
};
+
};
+
```
+
+130 -54
docs/spindle/pipeline.md
···
-
# spindle pipeline manifest
+
# spindle pipelines
+
+
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
+
+
The fields are:
-
Spindle pipelines are defined under the `.tangled/workflows` directory in a
-
repo. Generally:
+
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
+
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
+
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
+
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
+
- [Environment](#environment): An **optional** field that allows you to define environment variables.
+
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
-
* Pipelines are defined in YAML.
-
* Workflows can run using different *engines*.
+
## Trigger
-
The most barebones workflow looks like this:
+
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
+
+
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
+
- `push`: The workflow should run every time a commit is pushed to the repository.
+
- `pull_request`: The workflow should run every time a pull request is made or updated.
+
- `manual`: The workflow can be triggered manually.
+
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
+
+
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
```yaml
when:
-
- event: ["push"]
+
- event: ["push", "manual"]
+
branch: ["main", "develop"]
+
- event: ["pull_request"]
branch: ["main"]
+
```
+
## Engine
+
+
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
+
+
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
+
+
Example:
+
+
```yaml
engine: "nixery"
+
```
+
+
## Clone options
-
# optional
+
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
+
+
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
+
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
+
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
+
+
The default settings are:
+
+
```yaml
clone:
skip: false
-
depth: 50
-
submodules: true
+
depth: 1
+
submodules: false
```
-
The `when` and `engine` fields are required, while every other aspect
-
of how the definition is parsed is up to the engine. Currently, a spindle
-
provides at least one of these built-in engines:
+
## Dependencies
-
## `nixery`
+
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
-
The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run
-
steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs).
-
-
Here's an example that uses all fields:
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
```yaml
-
# build_and_test.yaml
-
when:
-
- event: ["push", "pull_request"]
-
branch: ["main", "develop"]
-
- event: ["manual"]
-
dependencies:
-
## from nixpkgs
+
# nixpkgs
nixpkgs:
- nodejs
-
## custom registry
-
git+https://tangled.sh/@oppi.li/statix:
-
- statix
+
- go
+
# custom registry
+
git+https://tangled.sh/@example.com/my_pkg:
+
- my_pkg
+
```
-
steps:
-
- name: "Install dependencies"
-
command: "npm install"
-
environment:
-
NODE_ENV: "development"
-
CI: "true"
+
Now these dependencies are available to use in your workflow!
-
- name: "Run linter"
-
command: "npm run lint"
+
## Environment
+
+
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
+
+
Example:
+
+
```yaml
+
environment:
+
GOOS: "linux"
+
GOARCH: "arm64"
+
NODE_ENV: "production"
+
MY_ENV_VAR: "MY_ENV_VALUE"
+
```
-
- name: "Run tests"
-
command: "npm test"
-
environment:
-
NODE_ENV: "test"
-
JEST_WORKERS: "2"
+
## Steps
-
- name: "Build application"
+
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
+
+
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
+
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
+
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
+
+
Example:
+
+
```yaml
+
steps:
+
- name: "Build backend"
+
command: "go build"
+
environment:
+
GOOS: "darwin"
+
GOARCH: "arm64"
+
- name: "Build frontend"
command: "npm run build"
environment:
NODE_ENV: "production"
+
```
-
environment:
-
BUILD_NUMBER: "123"
-
GIT_BRANCH: "main"
+
## Complete workflow
-
## current repository is cloned and checked out at the target ref
-
## by default.
+
```yaml
+
# .tangled/workflows/build.yml
+
+
when:
+
- event: ["push", "manual"]
+
branch: ["main", "develop"]
+
- event: ["pull_request"]
+
branch: ["main"]
+
+
engine: "nixery"
+
+
# using the default values
clone:
skip: false
-
depth: 50
-
submodules: true
-
```
+
depth: 1
+
submodules: false
-
## git push options
+
dependencies:
+
# nixpkgs
+
nixpkgs:
+
- nodejs
+
- go
+
# custom registry
+
git+https://tangled.sh/@example.com/my_pkg:
+
- my_pkg
-
These are push options that can be used with the `--push-option (-o)` flag of git push:
+
environment:
+
GOOS: "linux"
+
GOARCH: "arm64"
+
NODE_ENV: "production"
+
MY_ENV_VAR: "MY_ENV_VALUE"
+
+
steps:
+
- name: "Build backend"
+
command: "go build"
+
environment:
+
GOOS: "darwin"
+
GOARCH: "arm64"
+
- name: "Build frontend"
+
command: "npm run build"
+
environment:
+
NODE_ENV: "production"
+
```
-
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
-
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
+
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+1 -1
flake.nix
···
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
cd "$rootDir"
-
rm api/tangled/*
+
rm -f api/tangled/*
lexgen --build-file lexicon-build-config.json lexicons
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
${pkgs.gotools}/bin/goimports -w api/tangled/*
+3 -1
go.mod
···
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.3.3
github.com/whyrusleeping/cbor-gen v0.3.1
-
github.com/yuin/goldmark v1.4.15
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
+
github.com/yuin/goldmark v1.7.12
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
···
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+
github.com/wyatt915/treeblood v0.1.15 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+6 -1
go.sum
···
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
+
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
+
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-
github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
+
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
+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;
-336
knotclient/signer.go
···
-
package knotclient
-
-
import (
-
"bytes"
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
-
"encoding/json"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
"net/url"
-
"time"
-
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
type SignerTransport struct {
-
Secret string
-
}
-
-
func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-
timestamp := time.Now().Format(time.RFC3339)
-
mac := hmac.New(sha256.New, []byte(s.Secret))
-
message := req.Method + req.URL.Path + timestamp
-
mac.Write([]byte(message))
-
signature := hex.EncodeToString(mac.Sum(nil))
-
req.Header.Set("X-Signature", signature)
-
req.Header.Set("X-Timestamp", timestamp)
-
return http.DefaultTransport.RoundTrip(req)
-
}
-
-
type SignedClient struct {
-
Secret string
-
Url *url.URL
-
client *http.Client
-
}
-
-
func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) {
-
client := &http.Client{
-
Timeout: 5 * time.Second,
-
Transport: SignerTransport{
-
Secret: secret,
-
},
-
}
-
-
scheme := "https"
-
if dev {
-
scheme = "http"
-
}
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
-
if err != nil {
-
return nil, err
-
}
-
-
signedClient := &SignedClient{
-
Secret: secret,
-
client: client,
-
Url: url,
-
}
-
-
return signedClient, nil
-
}
-
-
func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) {
-
return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body))
-
}
-
-
func (s *SignedClient) Init(did string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
Endpoint = "/init"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) {
-
const (
-
Method = "PUT"
-
Endpoint = "/repo/new"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
"name": repoName,
-
"default_branch": defaultBranch,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) 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)
-
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
-
}
-
-
func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) {
-
const (
-
Method = "GET"
-
)
-
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
-
-
body, _ := json.Marshal(map[string]any{
-
"did": ownerDid,
-
"source": source,
-
"name": name,
-
"hiddenref": hiddenRef,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch))
-
-
body, _ := json.Marshal(map[string]any{
-
"did": ownerDid,
-
"source": source,
-
"name": name,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
Endpoint = "/repo/fork"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": ownerDid,
-
"source": source,
-
"name": name,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
-
const (
-
Method = "DELETE"
-
Endpoint = "/repo"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
"name": repoName,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) AddMember(did string) (*http.Response, error) {
-
const (
-
Method = "PUT"
-
Endpoint = "/member/add"
-
)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": did,
-
})
-
-
req, err := s.newRequest(Method, Endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
-
const (
-
Method = "PUT"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
-
-
body, _ := json.Marshal(map[string]any{
-
"branch": branch,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName)
-
-
body, _ := json.Marshal(map[string]any{
-
"did": memberDid,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) Merge(
-
patch []byte,
-
ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string,
-
) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo)
-
-
mr := types.MergeRequest{
-
Branch: branch,
-
CommitMessage: commitMessage,
-
CommitBody: commitBody,
-
AuthorName: authorName,
-
AuthorEmail: authorEmail,
-
Patch: string(patch),
-
}
-
-
body, _ := json.Marshal(mr)
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo)
-
-
body, _ := json.Marshal(map[string]any{
-
"patch": string(patch),
-
"branch": branch,
-
})
-
-
req, err := s.newRequest(Method, endpoint, body)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-
-
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
-
const (
-
Method = "POST"
-
)
-
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch))
-
-
req, err := s.newRequest(Method, endpoint, nil)
-
if err != nil {
-
return nil, err
-
}
-
-
return s.client.Do(req)
-
}
-250
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
-
}
+8 -1
knotserver/config/config.go
···
type Server struct {
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"`
InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"`
-
Secret string `env:"SECRET, required"`
DBPath string `env:"DB_PATH, default=knotserver.db"`
Hostname string `env:"HOSTNAME, required"`
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
+
Owner string `env:"OWNER, required"`
LogDids bool `env:"LOG_DIDS, default=true"`
// This disables signature verification so use with caution.
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
}
+9 -10
knotserver/git/post_receive.go
···
}
func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta {
-
var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem
+
var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount
for e, v := range m.CommitCount.ByEmail {
-
byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{
+
byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{
Email: e,
Count: int64(v),
})
}
-
var langs []*tangled.GitRefUpdate_Pair
+
var langs []*tangled.GitRefUpdate_IndividualLanguageSize
for lang, size := range m.LangBreakdown {
-
langs = append(langs, &tangled.GitRefUpdate_Pair{
+
langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{
Lang: lang,
Size: size,
})
}
-
langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{
-
Inputs: langs,
-
}
return tangled.GitRefUpdate_Meta{
-
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
+
CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{
ByEmail: byEmail,
},
-
IsDefaultRef: m.IsDefaultRef,
-
LangBreakdown: langBreakdown,
+
IsDefaultRef: m.IsDefaultRef,
+
LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{
+
Inputs: langs,
+
},
}
}
+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")
-211
knotserver/handler.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"
-
)
-
-
type Handle struct {
-
c *config.Config
-
db *db.DB
-
jc *jetstream.JetstreamClient
-
e *rbac.Enforcer
-
l *slog.Logger
-
n *notifier.Notifier
-
resolver *idresolver.Resolver
-
-
// init is a channel that is closed when the knot has been initailized
-
// i.e. when the first user (knot owner) has been added.
-
init chan struct{}
-
knotInitialized bool
-
}
-
-
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(),
-
init: make(chan struct{}),
-
}
-
-
err := e.AddKnot(rbac.ThisServer)
-
if err != nil {
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
-
}
-
-
// Check if the knot knows about any Dids;
-
// if it does, it is already initialized and we can repopulate the
-
// Jetstream subscriptions.
-
dids, err := db.GetAllDids()
-
if err != nil {
-
return nil, fmt.Errorf("failed to get all Dids: %w", err)
-
}
-
-
if len(dids) > 0 {
-
h.knotInitialized = true
-
close(h.init)
-
for _, d := range dids {
-
h.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.Route("/{did}", func(r chi.Router) {
-
// Repo routes
-
r.Route("/{name}", func(r chi.Router) {
-
r.Route("/collaborator", func(r chi.Router) {
-
r.Use(h.VerifySignature)
-
r.Post("/add", h.AddRepoCollaborator)
-
})
-
-
r.Route("/languages", func(r chi.Router) {
-
r.With(h.VerifySignature)
-
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.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
-
-
r.Route("/merge", func(r chi.Router) {
-
r.With(h.VerifySignature)
-
r.Post("/", h.Merge)
-
r.Post("/check", h.MergeCheck)
-
})
-
-
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.Route("/default", func(r chi.Router) {
-
r.Get("/", h.DefaultBranch)
-
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
-
})
-
})
-
})
-
})
-
-
// xrpc apis
-
r.Mount("/xrpc", h.XrpcRouter())
-
-
// Create a new repository.
-
r.Route("/repo", func(r chi.Router) {
-
r.Use(h.VerifySignature)
-
r.Put("/new", h.NewRepo)
-
r.Delete("/", h.RemoveRepo)
-
r.Route("/fork", func(r chi.Router) {
-
r.Post("/", h.RepoFork)
-
r.Post("/sync/*", h.RepoForkSync)
-
r.Get("/sync/*", h.RepoForkAheadBehind)
-
})
-
})
-
-
r.Route("/member", func(r chi.Router) {
-
r.Use(h.VerifySignature)
-
r.Put("/add", h.AddMember)
-
})
-
-
// Socket that streams git oplogs
-
r.Get("/events", h.Events)
-
-
// Initialize the knot with an owner and public key.
-
r.With(h.VerifySignature).Post("/init", h.Init)
-
-
// Health check. Used for two-way verification with appview.
-
r.With(h.VerifySignature).Get("/health", h.Health)
-
-
// All public keys on the knot.
-
r.Get("/keys", h.Keys)
-
-
return r, nil
-
}
-
-
func (h *Handle) XrpcRouter() http.Handler {
-
logger := tlog.New("knots")
-
-
xrpc := &xrpc.Xrpc{
-
Config: h.c,
-
Db: h.db,
-
Ingester: h.jc,
-
Enforcer: h.e,
-
Logger: logger,
-
Notifier: h.n,
-
Resolver: h.resolver,
-
}
-
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)
-
}
-10
knotserver/http_util.go
···
func notFound(w http.ResponseWriter) {
writeError(w, "not found", http.StatusNotFound)
}
-
-
func writeMsg(w http.ResponseWriter, msg string) {
-
writeJSON(w, map[string]string{"msg": msg})
-
}
-
-
func writeConflict(w http.ResponseWriter, data interface{}) {
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusConflict)
-
json.NewEncoder(w).Encode(data)
-
}
+34 -44
knotserver/ingester.go
···
"net/http"
"net/url"
"path/filepath"
-
"slices"
"strings"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
"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
···
}
l.Info("added member from firehose", "member", record.Subject)
-
if err := h.db.AddDid(did); err != nil {
+
if err := h.db.AddDid(record.Subject); err != nil {
l.Error("failed to add did", "error", err)
return fmt.Errorf("failed to add did: %w", err)
}
-
h.jc.AddDid(did)
+
h.jc.AddDid(record.Subject)
-
if err := h.fetchAndAddKeys(ctx, did); err != nil {
+
if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil {
return fmt.Errorf("failed to fetch and add keys: %w", err)
}
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)
-
l = l.With("target_repo", record.TargetRepo)
-
l = l.With("target_branch", record.TargetBranch)
-
if record.Source == nil {
-
reason := "not a branch-based pull request"
-
l.Info("ignoring pull record", "reason", reason)
-
return fmt.Errorf("ignoring pull record: %s", reason)
+
if record.Target == nil {
+
return fmt.Errorf("ignoring pull record: target repo is nil")
}
-
if record.Source.Repo != nil {
-
reason := "fork based pull"
-
l.Info("ignoring pull record", "reason", reason)
-
return fmt.Errorf("ignoring pull record: %s", reason)
-
}
+
l = l.With("target_repo", record.Target.Repo)
+
l = l.With("target_branch", record.Target.Branch)
-
allDids, err := h.db.GetAllDids()
-
if err != nil {
-
return err
+
if record.Source == nil {
+
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
}
-
// presently: we only process PRs from collaborators for pipelines
-
if !slices.Contains(allDids, did) {
-
reason := "not a known did"
-
l.Info("rejecting pull record", "reason", reason)
-
return fmt.Errorf("rejected pull record: %s, %s", reason, did)
+
if record.Source.Repo != nil {
+
return fmt.Errorf("ignoring pull record: fork based pull")
}
-
repoAt, err := syntax.ParseATURI(record.TargetRepo)
+
repoAt, err := syntax.ParseATURI(record.Target.Repo)
if err != nil {
-
return err
+
return fmt.Errorf("failed to parse ATURI: %w", err)
}
// resolve this aturi to extract the repo record
···
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
if err != nil {
-
return err
+
return fmt.Errorf("failed to resolver repo: %w", err)
}
repo := resp.Value.Val.(*tangled.Repo)
if repo.Knot != h.c.Server.Hostname {
-
reason := "not this knot"
-
l.Info("rejecting pull record", "reason", reason)
-
return fmt.Errorf("rejected pull record: %s", reason)
+
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
}
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
-
return err
+
return fmt.Errorf("failed to construct relative repo path: %w", err)
}
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
if err != nil {
-
return err
+
return fmt.Errorf("failed to construct absolute repo path: %w", err)
}
gr, err := git.Open(repoPath, record.Source.Branch)
if err != nil {
-
return err
+
return fmt.Errorf("failed to open git repository: %w", err)
}
workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir)
if err != nil {
-
return err
+
return fmt.Errorf("failed to open workflow directory: %w", err)
}
var pipeline workflow.RawPipeline
···
Action: "create",
SourceBranch: record.Source.Branch,
SourceSha: record.Source.Sha,
-
TargetBranch: record.TargetBranch,
+
TargetBranch: record.Target.Branch,
}
compiler := workflow.Compiler{
···
cp := compiler.Compile(compiler.Parse(pipeline))
eventJson, err := json.Marshal(cp)
if err != nil {
-
return err
+
return fmt.Errorf("failed to marshal pipeline event: %w", err)
}
// do not run empty pipelines
···
}
// 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
···
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
// check perms for this user
-
if ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo); !ok || err != nil {
-
return fmt.Errorf("insufficient permissions: %w", err)
+
ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo)
+
if err != nil {
+
return fmt.Errorf("failed to check permissions: %w", err)
+
}
+
if !ok {
+
return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo)
}
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
···
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 fmt.Errorf("error reading response body: %w", err)
}
-
for _, key := range strings.Split(string(plaintext), "\n") {
+
for key := range strings.SplitSeq(string(plaintext), "\n") {
if key == "" {
continue
}
···
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
}
-2
knotserver/internal.go
···
}
w.WriteHeader(http.StatusNoContent)
-
return
}
func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
···
data = append(data, j)
}
writeJSON(w, data)
-
return
}
type PushOptions struct {
-53
knotserver/middleware.go
···
-
package knotserver
-
-
import (
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
-
"net/http"
-
"time"
-
)
-
-
func (h *Handle) VerifySignature(next http.Handler) http.Handler {
-
if h.c.Server.Dev {
-
return next
-
}
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
signature := r.Header.Get("X-Signature")
-
if signature == "" || !h.verifyHMAC(signature, r) {
-
writeError(w, "signature verification failed", http.StatusForbidden)
-
return
-
}
-
next.ServeHTTP(w, r)
-
})
-
}
-
-
func (h *Handle) verifyHMAC(signature string, r *http.Request) bool {
-
secret := h.c.Server.Secret
-
timestamp := r.Header.Get("X-Timestamp")
-
if timestamp == "" {
-
return false
-
}
-
-
// Verify that the timestamp is not older than a minute
-
reqTime, err := time.Parse(time.RFC3339, timestamp)
-
if err != nil {
-
return false
-
}
-
if time.Since(reqTime) > time.Minute {
-
return false
-
}
-
-
message := r.Method + r.URL.Path + timestamp
-
-
mac := hmac.New(sha256.New, []byte(secret))
-
mac.Write([]byte(message))
-
expectedMAC := mac.Sum(nil)
-
-
signatureBytes, err := hex.DecodeString(signature)
-
if err != nil {
-
return false
-
}
-
-
return hmac.Equal(signatureBytes, expectedMAC)
-
}
+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
+
}
-1356
knotserver/routes.go
···
-
package knotserver
-
-
import (
-
"compress/gzip"
-
"context"
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
-
"encoding/json"
-
"errors"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"os"
-
"path/filepath"
-
"strconv"
-
"strings"
-
"sync"
-
"time"
-
-
securejoin "github.com/cyphar/filepath-securejoin"
-
"github.com/gliderlabs/ssh"
-
"github.com/go-chi/chi/v5"
-
gogit "github.com/go-git/go-git/v5"
-
"github.com/go-git/go-git/v5/plumbing"
-
"github.com/go-git/go-git/v5/plumbing/object"
-
"tangled.sh/tangled.sh/core/hook"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/knotserver/git"
-
"tangled.sh/tangled.sh/core/patchutil"
-
"tangled.sh/tangled.sh/core/rbac"
-
"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,
-
},
-
}
-
-
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)
-
return
-
}
-
-
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)
-
return
-
}
-
-
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"
-
}
-
-
// allow image, video, and text/plain files to be served directly
-
switch {
-
case strings.HasPrefix(mimeType, "image/"):
-
// allowed
-
case strings.HasPrefix(mimeType, "video/"):
-
// allowed
-
case strings.HasPrefix(mimeType, "text/plain"):
-
// allowed
-
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("Cache-Control", "public, max-age=86400") // cache for 24 hours
-
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
-
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)
-
return
-
}
-
-
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)
-
return
-
}
-
-
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)
-
return
-
}
-
-
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)
-
return
-
}
-
-
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)
-
return
-
}
-
-
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) NewRepo(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "NewRepo")
-
-
data := struct {
-
Did string `json:"did"`
-
Name string `json:"name"`
-
DefaultBranch string `json:"default_branch,omitempty"`
-
}{}
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
if data.DefaultBranch == "" {
-
data.DefaultBranch = h.c.Repo.MainBranch
-
}
-
-
did := data.Did
-
name := data.Name
-
defaultBranch := data.DefaultBranch
-
-
if err := validateRepoName(name); err != nil {
-
l.Error("creating repo", "error", err.Error())
-
writeError(w, err.Error(), http.StatusBadRequest)
-
return
-
}
-
-
relativeRepoPath := filepath.Join(did, name)
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
err := git.InitBare(repoPath, defaultBranch)
-
if err != nil {
-
l.Error("initializing bare repo", "error", err.Error())
-
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
-
writeError(w, "That repo already exists!", http.StatusConflict)
-
return
-
} else {
-
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) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "RepoForkAheadBehind")
-
-
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, _ = 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,
-
})
-
return
-
}
-
-
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "NewHiddenRef")
-
-
forkRef := chi.URLParam(r, "forkRef")
-
forkRef, _ = url.PathUnescape(forkRef)
-
-
remoteRef := chi.URLParam(r, "remoteRef")
-
remoteRef, _ = url.PathUnescape(remoteRef)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.PlainOpen(path)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
-
if err != nil {
-
l.Error("error tracking hidden remote ref", "msg", err.Error())
-
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
-
return
-
}
-
-
w.WriteHeader(http.StatusNoContent)
-
return
-
}
-
-
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "AddMember")
-
-
data := struct {
-
Did string `json:"did"`
-
}{}
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
did := data.Did
-
-
if err := h.db.AddDid(did); err != nil {
-
l.Error("adding did", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
h.jc.AddDid(did)
-
-
if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil {
-
l.Error("adding member", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
if err := h.fetchAndAddKeys(r.Context(), did); err != nil {
-
l.Error("fetching and adding keys", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
w.WriteHeader(http.StatusNoContent)
-
}
-
-
func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "AddRepoCollaborator")
-
-
data := struct {
-
Did string `json:"did"`
-
}{}
-
-
ownerDid := chi.URLParam(r, "did")
-
repo := chi.URLParam(r, "name")
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
if err := h.db.AddDid(data.Did); err != nil {
-
l.Error("adding did", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
h.jc.AddDid(data.Did)
-
-
repoName, _ := securejoin.SecureJoin(ownerDid, repo)
-
if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil {
-
l.Error("adding repo collaborator", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
-
l.Error("fetching and adding keys", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
w.WriteHeader(http.StatusNoContent)
-
}
-
-
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,
-
})
-
}
-
-
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "SetDefaultBranch")
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
-
data := struct {
-
Branch string `json:"branch"`
-
}{}
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
writeError(w, err.Error(), http.StatusBadRequest)
-
return
-
}
-
-
gr, err := git.PlainOpen(path)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
err = gr.SetDefaultBranch(data.Branch)
-
if err != nil {
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
l.Error("setting default branch", "error", err.Error())
-
return
-
}
-
-
w.WriteHeader(http.StatusNoContent)
-
}
-
-
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "Init")
-
-
if h.knotInitialized {
-
writeError(w, "knot already initialized", http.StatusConflict)
-
return
-
}
-
-
data := struct {
-
Did string `json:"did"`
-
}{}
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
l.Error("failed to decode request body", "error", err.Error())
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
if data.Did == "" {
-
l.Error("empty DID in request", "did", data.Did)
-
writeError(w, "did is empty", http.StatusBadRequest)
-
return
-
}
-
-
if err := h.db.AddDid(data.Did); err != nil {
-
l.Error("failed to add DID", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
h.jc.AddDid(data.Did)
-
-
if err := h.e.AddKnotOwner(rbac.ThisServer, data.Did); err != nil {
-
l.Error("adding owner", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
-
l.Error("fetching and adding keys", "error", err.Error())
-
writeError(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
close(h.init)
-
-
mac := hmac.New(sha256.New, []byte(h.c.Server.Secret))
-
mac.Write([]byte("ok"))
-
w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil)))
-
-
w.WriteHeader(http.StatusNoContent)
-
}
-
-
func (h *Handle) Health(w http.ResponseWriter, r *http.Request) {
-
w.Write([]byte("ok"))
-
}
-
-
func validateRepoName(name string) error {
-
// check for path traversal attempts
-
if name == "." || name == ".." ||
-
strings.Contains(name, "/") || strings.Contains(name, "\\") {
-
return fmt.Errorf("Repository name contains invalid path characters")
-
}
-
-
// check for sequences that could be used for traversal when normalized
-
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
-
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
-
return fmt.Errorf("Repository name contains invalid path sequence")
-
}
-
-
// then continue with character validation
-
for _, char := range name {
-
if !((char >= 'a' && char <= 'z') ||
-
(char >= 'A' && char <= 'Z') ||
-
(char >= '0' && char <= '9') ||
-
char == '-' || char == '_' || char == '.') {
-
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
-
}
-
}
-
-
// additional check to prevent multiple sequential dots
-
if strings.Contains(name, "..") {
-
return fmt.Errorf("Repository name cannot contain sequential dots")
-
}
-
-
// if all checks pass
-
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)
+
`,
}
}
+156
knotserver/xrpc/create_repo.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
"strings"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
gogit "github.com/go-git/go-git/v5"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/hook"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+
l := h.Logger.With("handler", "NewRepo")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
if !isMember {
+
fail(xrpcerr.AccessControlError(actorDid.String()))
+
return
+
}
+
+
var data tangled.RepoCreate_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
rkey := data.Rkey
+
+
ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String())
+
if err != nil || ident.Handle.IsInvalidHandle() {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
xrpcc := xrpc.Client{
+
Host: ident.PDSEndpoint(),
+
}
+
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
+
defaultBranch := h.Config.Repo.MainBranch
+
if data.DefaultBranch != nil && *data.DefaultBranch != "" {
+
defaultBranch = *data.DefaultBranch
+
}
+
+
if err := validateRepoName(repo.Name); err != nil {
+
l.Error("creating repo", "error", err.Error())
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
relativeRepoPath := filepath.Join(actorDid.String(), repo.Name)
+
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
+
+
if data.Source != nil && *data.Source != "" {
+
err = git.Fork(repoPath, *data.Source)
+
if err != nil {
+
l.Error("forking repo", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
} else {
+
err = git.InitBare(repoPath, defaultBranch)
+
if err != nil {
+
l.Error("initializing bare repo", "error", err.Error())
+
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
+
fail(xrpcerr.RepoExistsError("repository already exists"))
+
return
+
} else {
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
}
+
}
+
+
// add perms for this user to access the repo
+
err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath)
+
if err != nil {
+
l.Error("adding repo permissions", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
hook.SetupRepo(
+
hook.Config(
+
hook.WithScanPath(h.Config.Repo.ScanPath),
+
hook.WithInternalApi(h.Config.Server.InternalListenAddr),
+
),
+
repoPath,
+
)
+
+
w.WriteHeader(http.StatusOK)
+
}
+
+
func validateRepoName(name string) error {
+
// check for path traversal attempts
+
if name == "." || name == ".." ||
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
+
return fmt.Errorf("Repository name contains invalid path characters")
+
}
+
+
// check for sequences that could be used for traversal when normalized
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
+
return fmt.Errorf("Repository name contains invalid path sequence")
+
}
+
+
// then continue with character validation
+
for _, char := range name {
+
if !((char >= 'a' && char <= 'z') ||
+
(char >= 'A' && char <= 'Z') ||
+
(char >= '0' && char <= '9') ||
+
char == '-' || char == '_' || char == '.') {
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
+
}
+
}
+
+
// additional check to prevent multiple sequential dots
+
if strings.Contains(name, "..") {
+
return fmt.Errorf("Repository name cannot contain sequential dots")
+
}
+
+
// if all checks pass
+
return nil
+
}
+96
knotserver/xrpc/delete_repo.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"os"
+
"path/filepath"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "DeleteRepo")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoDelete_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
rkey := data.Rkey
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
+
return
+
}
+
+
ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String())
+
if err != nil || ident.Handle.IsInvalidHandle() {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
xrpcc := xrpc.Client{
+
Host: ident.PDSEndpoint(),
+
}
+
+
// ensure that the record does not exists
+
_, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
+
if err == nil {
+
fail(xrpcerr.RecordExistsError(rkey))
+
return
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
if !isDeleteAllowed {
+
fail(xrpcerr.AccessControlError(actorDid.String()))
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
err = os.RemoveAll(repoPath)
+
if err != nil {
+
l.Error("deleting repo", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath)
+
if err != nil {
+
l.Error("failed to delete repo from enforcer", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+111
knotserver/xrpc/fork_status.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "ForkStatus")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoForkStatus_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
source := data.Source
+
branch := data.Branch
+
hiddenRef := data.HiddenRef
+
+
if did == "" || source == "" || branch == "" || hiddenRef == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required")))
+
return
+
}
+
+
var name string
+
if data.Name != "" {
+
name = data.Name
+
} else {
+
name = filepath.Base(source)
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
forkCommit, err := gr.ResolveRevision(branch)
+
if err != nil {
+
l.Error("error resolving ref revision", "msg", err.Error())
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err)))
+
return
+
}
+
+
sourceCommit, err := gr.ResolveRevision(hiddenRef)
+
if err != nil {
+
l.Error("error resolving hidden ref revision", "msg", err.Error())
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err)))
+
return
+
}
+
+
status := types.UpToDate
+
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
+
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
+
if err != nil {
+
l.Error("error checking ancestor relationship", "error", err.Error())
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err)))
+
return
+
}
+
+
if isAncestor {
+
status = types.FastForwardable
+
} else {
+
status = types.Conflict
+
}
+
}
+
+
response := tangled.RepoForkStatus_Output{
+
Status: int64(status),
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+73
knotserver/xrpc/fork_sync.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "ForkSync")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoForkSync_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
branch := data.Branch
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did, name are required")))
+
return
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.Open(repoPath, branch)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
err = gr.Sync()
+
if err != nil {
+
l.Error("error syncing repo fork", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+104
knotserver/xrpc/hidden_ref.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "HiddenRef")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoHiddenRef_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
forkRef := data.ForkRef
+
remoteRef := data.RemoteRef
+
repoAtUri := data.Repo
+
+
if forkRef == "" || remoteRef == "" || repoAtUri == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required")))
+
return
+
}
+
+
repoAt, err := syntax.ParseATURI(repoAtUri)
+
if err != nil {
+
fail(xrpcerr.InvalidRepoError(repoAtUri))
+
return
+
}
+
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
+
if err != nil || ident.Handle.IsInvalidHandle() {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
+
return
+
}
+
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
+
if err != nil {
+
l.Error("error tracking hidden remote ref", "error", err.Error())
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.RepoHiddenRef_Output{
+
Success: true,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+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
+
}
+
}
+114
knotserver/xrpc/merge.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"net/http"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/patchutil"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "Merge")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoMerge_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
+
return
+
}
+
+
relativeRepoPath, err := securejoin.SecureJoin(did, name)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.Open(repoPath, data.Branch)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
mo := git.MergeOptions{}
+
if data.AuthorName != nil {
+
mo.AuthorName = *data.AuthorName
+
}
+
if data.AuthorEmail != nil {
+
mo.AuthorEmail = *data.AuthorEmail
+
}
+
if data.CommitBody != nil {
+
mo.CommitBody = *data.CommitBody
+
}
+
if data.CommitMessage != nil {
+
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)
+
if 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,
+
}
+
}
+
+
conflictErr := xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("MergeConflict"),
+
xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)),
+
)
+
writeError(w, conflictErr, http.StatusConflict)
+
return
+
} else {
+
l.Error("failed to merge", "error", err.Error())
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
+
return
+
}
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+87
knotserver/xrpc/merge_check.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"net/http"
+
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"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) MergeCheck(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "MergeCheck")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
var data tangled.RepoMergeCheck_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
+
return
+
}
+
+
relativeRepoPath, err := securejoin.SecureJoin(did, name)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.Open(repoPath, data.Branch)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
+
+
response := tangled.RepoMergeCheck_Output{
+
Is_conflicted: false,
+
}
+
+
if err != nil {
+
var mergeErr *git.ErrMerge
+
if errors.As(err, &mergeErr) {
+
response.Is_conflicted = true
+
+
conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts))
+
for i, conflict := range mergeErr.Conflicts {
+
conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{
+
Filename: conflict.Filename,
+
Reason: conflict.Reason,
+
}
+
}
+
response.Conflicts = conflicts
+
+
if mergeErr.Message != "" {
+
response.Message = &mergeErr.Message
+
}
+
} else {
+
response.Is_conflicted = true
+
errMsg := err.Error()
+
response.Error = &errMsg
+
}
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+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
+
}
+
}
-149
knotserver/xrpc/router.go
···
-
package xrpc
-
-
import (
-
"context"
-
"encoding/json"
-
"fmt"
-
"log/slog"
-
"net/http"
-
"strings"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"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/notifier"
-
"tangled.sh/tangled.sh/core/rbac"
-
-
"github.com/bluesky-social/indigo/atproto/auth"
-
"github.com/go-chi/chi/v5"
-
)
-
-
type Xrpc struct {
-
Config *config.Config
-
Db *db.DB
-
Ingester *jetstream.JetstreamClient
-
Enforcer *rbac.Enforcer
-
Logger *slog.Logger
-
Notifier *notifier.Notifier
-
Resolver *idresolver.Resolver
-
}
-
-
func (x *Xrpc) Router() http.Handler {
-
r := chi.NewRouter()
-
-
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
-
-
return r
-
}
-
-
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
l := x.Logger.With("url", r.URL)
-
-
token := r.Header.Get("Authorization")
-
token = strings.TrimPrefix(token, "Bearer ")
-
-
s := auth.ServiceAuthValidator{
-
Audience: x.Config.Server.Did().String(),
-
Dir: x.Resolver.Directory(),
-
}
-
-
did, err := s.Validate(r.Context(), token, nil)
-
if err != nil {
-
l.Error("signature verification failed", "err", err)
-
writeError(w, AuthError(err), http.StatusForbidden)
-
return
-
}
-
-
r = r.WithContext(
-
context.WithValue(r.Context(), ActorDid, did),
-
)
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
-
type XrpcError struct {
-
Tag string `json:"error"`
-
Message string `json:"message"`
-
}
-
-
func NewXrpcError(opts ...ErrOpt) XrpcError {
-
x := XrpcError{}
-
for _, o := range opts {
-
o(&x)
-
}
-
-
return x
-
}
-
-
type ErrOpt = func(xerr *XrpcError)
-
-
func WithTag(tag string) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Tag = tag
-
}
-
}
-
-
func WithMessage[S ~string](s S) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Message = string(s)
-
}
-
}
-
-
func WithError(e error) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Message = e.Error()
-
}
-
}
-
-
var MissingActorDidError = NewXrpcError(
-
WithTag("MissingActorDid"),
-
WithMessage("actor DID not supplied"),
-
)
-
-
var AuthError = func(err error) XrpcError {
-
return NewXrpcError(
-
WithTag("Auth"),
-
WithError(fmt.Errorf("signature verification failed: %w", err)),
-
)
-
}
-
-
var InvalidRepoError = func(r string) XrpcError {
-
return NewXrpcError(
-
WithTag("InvalidRepo"),
-
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
-
)
-
}
-
-
var AccessControlError = func(d string) XrpcError {
-
return NewXrpcError(
-
WithTag("AccessControl"),
-
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
-
)
-
}
-
-
var GitError = func(e error) XrpcError {
-
return NewXrpcError(
-
WithTag("Git"),
-
WithError(fmt.Errorf("git error: %w", e)),
-
)
-
}
-
-
func GenericError(err error) XrpcError {
-
return NewXrpcError(
-
WithTag("Generic"),
-
WithError(err),
-
)
-
}
-
-
// this is slightly different from http_util::write_error to follow the spec:
-
//
-
// the json object returned must include an "error" and a "message"
-
func writeError(w http.ResponseWriter, e XrpcError, status int) {
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(status)
-
json.NewEncoder(w).Encode(e)
-
}
+12 -10
knotserver/xrpc/set_default_branch.go
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/knotserver/git"
"tangled.sh/tangled.sh/core/rbac"
+
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
const ActorDid string = "ActorDid"
func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
l := x.Logger
-
fail := func(e XrpcError) {
+
fail := func(e xrpcerr.XrpcError) {
l.Error("failed", "kind", e.Tag, "error", e.Message)
writeError(w, e, http.StatusBadRequest)
}
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
if !ok {
-
fail(MissingActorDidError)
+
fail(xrpcerr.MissingActorDidError)
return
}
var data tangled.RepoSetDefaultBranch_Input
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
// unfortunately we have to resolve repo-at here
repoAt, err := syntax.ParseATURI(data.Repo)
if err != nil {
-
fail(InvalidRepoError(data.Repo))
+
fail(xrpcerr.InvalidRepoError(data.Repo))
return
}
// resolve this aturi to extract the repo record
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
if err != nil || ident.Handle.IsInvalidHandle() {
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
return
}
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
repo := resp.Value.Val.(*tangled.Repo)
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
l.Error("insufficent permissions", "did", actorDid.String())
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
return
}
path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
gr, err := git.PlainOpen(path)
if err != nil {
-
fail(InvalidRepoError(data.Repo))
+
fail(xrpcerr.GenericError(err))
return
}
err = gr.SetDefaultBranch(data.DefaultBranch)
if err != nil {
l.Error("setting default branch", "error", err.Error())
-
writeError(w, GitError(err), http.StatusInternalServerError)
+
writeError(w, xrpcerr.GitError(err), 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
+
}
+
}
+148
knotserver/xrpc/xrpc.go
···
+
package xrpc
+
+
import (
+
"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"
+
"tangled.sh/tangled.sh/core/knotserver/config"
+
"tangled.sh/tangled.sh/core/knotserver/db"
+
"tangled.sh/tangled.sh/core/notifier"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
type Xrpc struct {
+
Config *config.Config
+
Db *db.DB
+
Ingester *jetstream.JetstreamClient
+
Enforcer *rbac.Enforcer
+
Logger *slog.Logger
+
Notifier *notifier.Notifier
+
Resolver *idresolver.Resolver
+
ServiceAuth *serviceauth.ServiceAuth
+
}
+
+
func (x *Xrpc) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Group(func(r chi.Router) {
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
+
+
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
+
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
+
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
+
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+
r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
+
r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
+
r.Post("/"+tangled.RepoMergeNSID, x.Merge)
+
})
+
+
// merge check is an open endpoint
+
//
+
// TODO: should we constrain this more?
+
// - 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) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(status)
+
json.NewEncoder(w).Encode(e)
+
}
+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.
+59 -52
lexicons/git/refUpdate.json
···
"maxLength": 40
},
"meta": {
-
"type": "object",
-
"required": [
-
"isDefaultRef",
-
"commitCount"
-
],
-
"properties": {
-
"isDefaultRef": {
-
"type": "boolean",
-
"default": "false"
-
},
-
"langBreakdown": {
-
"type": "object",
-
"properties": {
-
"inputs": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#pair"
-
}
-
}
-
}
-
},
-
"commitCount": {
-
"type": "object",
-
"required": [],
-
"properties": {
-
"byEmail": {
-
"type": "array",
-
"items": {
-
"type": "object",
-
"required": [
-
"email",
-
"count"
-
],
-
"properties": {
-
"email": {
-
"type": "string"
-
},
-
"count": {
-
"type": "integer"
-
}
-
}
-
}
-
}
-
}
-
}
-
}
+
"type": "ref",
+
"ref": "#meta"
+
}
+
}
+
}
+
},
+
"meta": {
+
"type": "object",
+
"required": ["isDefaultRef", "commitCount"],
+
"properties": {
+
"isDefaultRef": {
+
"type": "boolean",
+
"default": false
+
},
+
"langBreakdown": {
+
"type": "ref",
+
"ref": "#langBreakdown"
+
},
+
"commitCount": {
+
"type": "ref",
+
"ref": "#commitCountBreakdown"
+
}
+
}
+
},
+
"langBreakdown": {
+
"type": "object",
+
"properties": {
+
"inputs": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#individualLanguageSize"
}
}
}
},
-
"pair": {
+
"individualLanguageSize": {
"type": "object",
-
"required": [
-
"lang",
-
"size"
-
],
+
"required": ["lang", "size"],
"properties": {
"lang": {
"type": "string"
},
"size": {
+
"type": "integer"
+
}
+
}
+
},
+
"commitCountBreakdown": {
+
"type": "object",
+
"required": [],
+
"properties": {
+
"byEmail": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#individualEmailCommitCount"
+
}
+
}
+
}
+
},
+
"individualEmailCommitCount": {
+
"type": "object",
+
"required": ["email", "count"],
+
"properties": {
+
"email": {
+
"type": "string"
+
},
+
"count": {
"type": "integer"
}
}
+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"
+24
lexicons/knot/knot.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": [
+
"createdAt"
+
],
+
"properties": {
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
]
+
}
+
}
+
}
-11
lexicons/pulls/comment.json
···
"type": "string",
"format": "at-uri"
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"commentId": {
-
"type": "integer"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
"body": {
"type": "string"
},
+20 -12
lexicons/pulls/pull.json
···
"record": {
"type": "object",
"required": [
-
"targetRepo",
-
"targetBranch",
-
"pullId",
+
"target",
"title",
"patch",
"createdAt"
],
"properties": {
-
"targetRepo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"targetBranch": {
-
"type": "string"
-
},
-
"pullId": {
-
"type": "integer"
+
"target": {
+
"type": "ref",
+
"ref": "#target"
},
"title": {
"type": "string"
···
"type": "string",
"format": "datetime"
}
+
}
+
}
+
},
+
"target": {
+
"type": "object",
+
"required": [
+
"repo",
+
"branch"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"branch": {
+
"type": "string"
}
}
},
+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"
+
}
+
]
+
}
+
}
+
}
+33
lexicons/repo/create.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.create",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a new repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"rkey"
+
],
+
"properties": {
+
"rkey": {
+
"type": "string",
+
"description": "Rkey of the repository record"
+
},
+
"defaultBranch": {
+
"type": "string",
+
"description": "Default branch to push to"
+
},
+
"source": {
+
"type": "string",
+
"description": "A source URL to clone from, populate this when forking or importing a repository."
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+32
lexicons/repo/delete.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.delete",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Delete a repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "rkey"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the repository owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the repository to delete"
+
},
+
"rkey": {
+
"type": "string",
+
"description": "Rkey of the repository record"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
]
+
}
+
}
+
}
+53
lexicons/repo/forkStatus.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.forkStatus",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Check fork status relative to upstream source",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "source", "branch", "hiddenRef"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the fork owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the forked repository"
+
},
+
"source": {
+
"type": "string",
+
"description": "Source repository URL"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Branch to check status for"
+
},
+
"hiddenRef": {
+
"type": "string",
+
"description": "Hidden ref to use for comparison"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["status"],
+
"properties": {
+
"status": {
+
"type": "integer",
+
"description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+42
lexicons/repo/forkSync.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.forkSync",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Sync a forked repository with its upstream source",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"did",
+
"source",
+
"name",
+
"branch"
+
],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the fork owner"
+
},
+
"source": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the source repository"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the forked repository"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Branch to sync"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
}
+
}
+
}
+
}
+59
lexicons/repo/hiddenRef.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.hiddenRef",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Create a hidden ref in a repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"forkRef",
+
"remoteRef"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the repository"
+
},
+
"forkRef": {
+
"type": "string",
+
"description": "Fork reference name"
+
},
+
"remoteRef": {
+
"type": "string",
+
"description": "Remote reference name"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"success"
+
],
+
"properties": {
+
"success": {
+
"type": "boolean",
+
"description": "Whether the hidden ref was created successfully"
+
},
+
"ref": {
+
"type": "string",
+
"description": "The created hidden ref name"
+
},
+
"error": {
+
"type": "string",
+
"description": "Error message if creation failed"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
]
+
}
+
}
+
}
+52
lexicons/repo/merge.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.merge",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Merge a patch into a repository branch",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "patch", "branch"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the repository owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the repository"
+
},
+
"patch": {
+
"type": "string",
+
"description": "Patch content to merge"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Target branch to merge into"
+
},
+
"authorName": {
+
"type": "string",
+
"description": "Author name for the merge commit"
+
},
+
"authorEmail": {
+
"type": "string",
+
"description": "Author email for the merge commit"
+
},
+
"commitBody": {
+
"type": "string",
+
"description": "Additional commit message body"
+
},
+
"commitMessage": {
+
"type": "string",
+
"description": "Merge commit message"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+79
lexicons/repo/mergeCheck.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.mergeCheck",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Check if a merge is possible between two branches",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "name", "patch", "branch"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the repository owner"
+
},
+
"name": {
+
"type": "string",
+
"description": "Name of the repository"
+
},
+
"patch": {
+
"type": "string",
+
"description": "Patch or pull request to check for merge conflicts"
+
},
+
"branch": {
+
"type": "string",
+
"description": "Target branch to merge into"
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["is_conflicted"],
+
"properties": {
+
"is_conflicted": {
+
"type": "boolean",
+
"description": "Whether the merge has conflicts"
+
},
+
"conflicts": {
+
"type": "array",
+
"description": "List of files with merge conflicts",
+
"items": {
+
"type": "ref",
+
"ref": "#conflictInfo"
+
}
+
},
+
"message": {
+
"type": "string",
+
"description": "Additional message about the merge check"
+
},
+
"error": {
+
"type": "string",
+
"description": "Error message if check failed"
+
}
+
}
+
}
+
}
+
},
+
"conflictInfo": {
+
"type": "object",
+
"required": ["filename", "reason"],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"description": "Name of the conflicted file"
+
},
+
"reason": {
+
"type": "string",
+
"description": "Reason for the conflict"
+
}
+
}
+
}
+
}
+
}
-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="
+5 -5
nix/modules/knot.nix
···
description = "Internal address for inter-service communication";
};
-
secretFile = mkOption {
-
type = lib.types.path;
-
example = "KNOT_SERVER_SECRET=<hash>";
-
description = "File containing secret key provided by appview (required)";
+
owner = mkOption {
+
type = types.str;
+
example = "did:plc:qfpnj4og54vl56wngdriaxug";
+
description = "DID of owner (required)";
};
dbPath = mkOption {
···
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
+
"KNOT_SERVER_OWNER=${cfg.server.owner}"
];
-
EnvironmentFile = cfg.server.secretFile;
ExecStart = "${cfg.package}/bin/knot server";
Restart = "always";
};
+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;
+
}
+4 -1
nix/vm.nix
···
};
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
networking.firewall.enable = false;
+
time.timeZone = "Europe/London";
services.getty.autologinUser = "root";
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
services.tangled-knot = {
enable = true;
motd = "Welcome to the development knot!\n";
server = {
-
secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET"));
+
owner = envVar "TANGLED_VM_KNOT_OWNER";
hostname = "localhost:6000";
listenAddr = "0.0.0.0:6000";
};
···
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
+13
rbac/rbac.go
···
return err
}
+
func (e *Enforcer) RemoveKnot(knot string) error {
+
_, err := e.E.DeleteDomains(knot)
+
return err
+
}
+
func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) {
keepFunc := isNotSpindle
stripFunc := unSpindle
···
func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) {
return e.isInviteAllowed(user, intoSpindle(domain))
+
}
+
+
func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) {
+
return e.E.Enforce(user, domain, domain, "repo:create")
+
}
+
+
func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) {
+
return e.E.Enforce(user, domain, repo, "repo:delete")
}
func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+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 {
+10 -6
spindle/engines/nixery/engine.go
···
io.Copy(os.Stdout, reader)
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
-
Image: addl.image,
-
Cmd: []string{"cat"},
-
OpenStdin: true, // so cat stays alive :3
-
Tty: false,
-
Hostname: "spindle",
+
Image: addl.image,
+
Cmd: []string{"cat"},
+
OpenStdin: true, // so cat stays alive :3
+
Tty: false,
+
Hostname: "spindle",
+
WorkingDir: workspaceDir,
+
Labels: map[string]string{
+
"sh.tangled.pipeline/workflow_id": wid.String(),
+
},
// TODO(winter): investigate whether environment variables passed here
// get propagated to ContainerExec processes
}, &container.HostConfig{
···
envs.AddEnv(k, v)
}
envs.AddEnv("HOME", homeDir)
-
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
Cmd: []string{"bash", "-c", step.command},
AttachStdout: true,
AttachStderr: true,
+
Env: envs,
})
if err != nil {
return fmt.Errorf("creating exec: %w", err)
+13 -11
spindle/server.go
···
"tangled.sh/tangled.sh/core/spindle/queue"
"tangled.sh/tangled.sh/core/spindle/secrets"
"tangled.sh/tangled.sh/core/spindle/xrpc"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
)
//go:embed motd
···
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())
···
func (s *Spindle) XrpcRouter() http.Handler {
logger := s.l.With("route", "xrpc")
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
+
x := xrpc.Xrpc{
-
Logger: logger,
-
Db: s.db,
-
Enforcer: s.e,
-
Engines: s.engs,
-
Config: s.cfg,
-
Resolver: s.res,
-
Vault: s.vault,
+
Logger: logger,
+
Db: s.db,
+
Enforcer: s.e,
+
Engines: s.engs,
+
Config: s.cfg,
+
Resolver: s.res,
+
Vault: s.vault,
+
ServiceAuth: serviceAuth,
}
return x.Router()
+11 -10
spindle/xrpc/add_secret.go
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/spindle/secrets"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
l := x.Logger
-
fail := func(e XrpcError) {
+
fail := func(e xrpcerr.XrpcError) {
l.Error("failed", "kind", e.Tag, "error", e.Message)
writeError(w, e, http.StatusBadRequest)
}
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
if !ok {
-
fail(MissingActorDidError)
+
fail(xrpcerr.MissingActorDidError)
return
}
var data tangled.RepoAddSecret_Input
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
if err := secrets.ValidateKey(data.Key); err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
// unfortunately we have to resolve repo-at here
repoAt, err := syntax.ParseATURI(data.Repo)
if err != nil {
-
fail(InvalidRepoError(data.Repo))
+
fail(xrpcerr.InvalidRepoError(data.Repo))
return
}
// resolve this aturi to extract the repo record
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
if err != nil || ident.Handle.IsInvalidHandle() {
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
return
}
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
repo := resp.Value.Val.(*tangled.Repo)
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
l.Error("insufficent permissions", "did", actorDid.String())
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
return
}
···
err = x.Vault.AddSecret(r.Context(), secret)
if err != nil {
l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err)
-
writeError(w, GenericError(err), http.StatusInternalServerError)
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
return
}
+10 -9
spindle/xrpc/list_secrets.go
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/spindle/secrets"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
l := x.Logger
-
fail := func(e XrpcError) {
+
fail := func(e xrpcerr.XrpcError) {
l.Error("failed", "kind", e.Tag, "error", e.Message)
writeError(w, e, http.StatusBadRequest)
}
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
if !ok {
-
fail(MissingActorDidError)
+
fail(xrpcerr.MissingActorDidError)
return
}
repoParam := r.URL.Query().Get("repo")
if repoParam == "" {
-
fail(GenericError(fmt.Errorf("empty params")))
+
fail(xrpcerr.GenericError(fmt.Errorf("empty params")))
return
}
// unfortunately we have to resolve repo-at here
repoAt, err := syntax.ParseATURI(repoParam)
if err != nil {
-
fail(InvalidRepoError(repoParam))
+
fail(xrpcerr.InvalidRepoError(repoParam))
return
}
// resolve this aturi to extract the repo record
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
if err != nil || ident.Handle.IsInvalidHandle() {
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
return
}
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
repo := resp.Value.Val.(*tangled.Repo)
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
l.Error("insufficent permissions", "did", actorDid.String())
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
return
}
ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath))
if err != nil {
l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err)
-
writeError(w, GenericError(err), http.StatusInternalServerError)
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
return
}
+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 -9
spindle/xrpc/remove_secret.go
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/rbac"
"tangled.sh/tangled.sh/core/spindle/secrets"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
)
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
l := x.Logger
-
fail := func(e XrpcError) {
+
fail := func(e xrpcerr.XrpcError) {
l.Error("failed", "kind", e.Tag, "error", e.Message)
writeError(w, e, http.StatusBadRequest)
}
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
if !ok {
-
fail(MissingActorDidError)
+
fail(xrpcerr.MissingActorDidError)
return
}
var data tangled.RepoRemoveSecret_Input
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
// unfortunately we have to resolve repo-at here
repoAt, err := syntax.ParseATURI(data.Repo)
if err != nil {
-
fail(InvalidRepoError(data.Repo))
+
fail(xrpcerr.InvalidRepoError(data.Repo))
return
}
// resolve this aturi to extract the repo record
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
if err != nil || ident.Handle.IsInvalidHandle() {
-
fail(GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
return
}
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
repo := resp.Value.Val.(*tangled.Repo)
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
if err != nil {
-
fail(GenericError(err))
+
fail(xrpcerr.GenericError(err))
return
}
if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
l.Error("insufficent permissions", "did", actorDid.String())
-
writeError(w, AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
return
}
···
err = x.Vault.RemoveSecret(r.Context(), secret)
if err != nil {
l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err)
-
writeError(w, GenericError(err), http.StatusInternalServerError)
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
return
}
+19 -107
spindle/xrpc/xrpc.go
···
package xrpc
import (
-
"context"
_ "embed"
"encoding/json"
-
"fmt"
"log/slog"
"net/http"
-
"strings"
-
"github.com/bluesky-social/indigo/atproto/auth"
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/api/tangled"
···
"tangled.sh/tangled.sh/core/spindle/db"
"tangled.sh/tangled.sh/core/spindle/models"
"tangled.sh/tangled.sh/core/spindle/secrets"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
)
const ActorDid string = "ActorDid"
type Xrpc struct {
-
Logger *slog.Logger
-
Db *db.DB
-
Enforcer *rbac.Enforcer
-
Engines map[string]models.Engine
-
Config *config.Config
-
Resolver *idresolver.Resolver
-
Vault secrets.Manager
+
Logger *slog.Logger
+
Db *db.DB
+
Enforcer *rbac.Enforcer
+
Engines map[string]models.Engine
+
Config *config.Config
+
Resolver *idresolver.Resolver
+
Vault secrets.Manager
+
ServiceAuth *serviceauth.ServiceAuth
}
func (x *Xrpc) Router() http.Handler {
r := chi.NewRouter()
-
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
-
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
-
r.With(x.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
-
-
return r
-
}
+
r.Group(func(r chi.Router) {
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
-
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
l := x.Logger.With("url", r.URL)
-
-
token := r.Header.Get("Authorization")
-
token = strings.TrimPrefix(token, "Bearer ")
-
-
s := auth.ServiceAuthValidator{
-
Audience: x.Config.Server.Did().String(),
-
Dir: x.Resolver.Directory(),
-
}
-
-
did, err := s.Validate(r.Context(), token, nil)
-
if err != nil {
-
l.Error("signature verification failed", "err", err)
-
writeError(w, AuthError(err), http.StatusForbidden)
-
return
-
}
-
-
r = r.WithContext(
-
context.WithValue(r.Context(), ActorDid, did),
-
)
-
-
next.ServeHTTP(w, r)
+
r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
+
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
+
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
})
-
}
-
type XrpcError struct {
-
Tag string `json:"error"`
-
Message string `json:"message"`
-
}
-
-
func NewXrpcError(opts ...ErrOpt) XrpcError {
-
x := XrpcError{}
-
for _, o := range opts {
-
o(&x)
-
}
-
-
return x
-
}
-
-
type ErrOpt = func(xerr *XrpcError)
-
-
func WithTag(tag string) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Tag = tag
-
}
-
}
-
-
func WithMessage[S ~string](s S) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Message = string(s)
-
}
-
}
+
// service query endpoints (no auth required)
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
-
func WithError(e error) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Message = e.Error()
-
}
-
}
-
-
var MissingActorDidError = NewXrpcError(
-
WithTag("MissingActorDid"),
-
WithMessage("actor DID not supplied"),
-
)
-
-
var AuthError = func(err error) XrpcError {
-
return NewXrpcError(
-
WithTag("Auth"),
-
WithError(fmt.Errorf("signature verification failed: %w", err)),
-
)
-
}
-
-
var InvalidRepoError = func(r string) XrpcError {
-
return NewXrpcError(
-
WithTag("InvalidRepo"),
-
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
-
)
-
}
-
-
func GenericError(err error) XrpcError {
-
return NewXrpcError(
-
WithTag("Generic"),
-
WithError(err),
-
)
-
}
-
-
var AccessControlError = func(d string) XrpcError {
-
return NewXrpcError(
-
WithTag("AccessControl"),
-
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
-
)
+
return r
}
// this is slightly different from http_util::write_error to follow the spec:
//
// the json object returned must include an "error" and a "message"
-
func writeError(w http.ResponseWriter, e XrpcError, status int) {
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(e)
+115
xrpc/errors/errors.go
···
+
package errors
+
+
import (
+
"encoding/json"
+
"fmt"
+
)
+
+
type XrpcError struct {
+
Tag string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
func (x XrpcError) Error() string {
+
if x.Message != "" {
+
return fmt.Sprintf("%s: %s", x.Tag, x.Message)
+
}
+
return x.Tag
+
}
+
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
+
x := XrpcError{}
+
for _, o := range opts {
+
o(&x)
+
}
+
+
return x
+
}
+
+
type ErrOpt = func(xerr *XrpcError)
+
+
func WithTag(tag string) ErrOpt {
+
return func(xerr *XrpcError) {
+
xerr.Tag = tag
+
}
+
}
+
+
func WithMessage[S ~string](s S) ErrOpt {
+
return func(xerr *XrpcError) {
+
xerr.Message = string(s)
+
}
+
}
+
+
func WithError(e error) ErrOpt {
+
return func(xerr *XrpcError) {
+
xerr.Message = e.Error()
+
}
+
}
+
+
var MissingActorDidError = NewXrpcError(
+
WithTag("MissingActorDid"),
+
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"),
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
+
)
+
}
+
+
var InvalidRepoError = func(r string) XrpcError {
+
return NewXrpcError(
+
WithTag("InvalidRepo"),
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
+
)
+
}
+
+
var GitError = func(e error) XrpcError {
+
return NewXrpcError(
+
WithTag("Git"),
+
WithError(fmt.Errorf("git error: %w", e)),
+
)
+
}
+
+
var AccessControlError = func(d string) XrpcError {
+
return NewXrpcError(
+
WithTag("AccessControl"),
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
+
)
+
}
+
+
var RepoExistsError = func(r string) XrpcError {
+
return NewXrpcError(
+
WithTag("RepoExists"),
+
WithError(fmt.Errorf("repo already exists: %s", r)),
+
)
+
}
+
+
var RecordExistsError = func(r string) XrpcError {
+
return NewXrpcError(
+
WithTag("RecordExists"),
+
WithError(fmt.Errorf("repo already exists: %s", r)),
+
)
+
}
+
+
func GenericError(err error) XrpcError {
+
return NewXrpcError(
+
WithTag("Generic"),
+
WithError(err),
+
)
+
}
+
+
func Unmarshal(errStr string) (XrpcError, error) {
+
var xerr XrpcError
+
err := json.Unmarshal([]byte(errStr), &xerr)
+
if err != nil {
+
return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err)
+
}
+
return xerr, nil
+
}
+65
xrpc/serviceauth/service_auth.go
···
+
package serviceauth
+
+
import (
+
"context"
+
"encoding/json"
+
"log/slog"
+
"net/http"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/auth"
+
"tangled.sh/tangled.sh/core/idresolver"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
const ActorDid string = "ActorDid"
+
+
type ServiceAuth struct {
+
logger *slog.Logger
+
resolver *idresolver.Resolver
+
audienceDid string
+
}
+
+
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
+
return &ServiceAuth{
+
logger: logger,
+
resolver: resolver,
+
audienceDid: audienceDid,
+
}
+
}
+
+
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
l := sa.logger.With("url", r.URL)
+
+
token := r.Header.Get("Authorization")
+
token = strings.TrimPrefix(token, "Bearer ")
+
+
s := auth.ServiceAuthValidator{
+
Audience: sa.audienceDid,
+
Dir: sa.resolver.Directory(),
+
}
+
+
did, err := s.Validate(r.Context(), token, nil)
+
if err != nil {
+
l.Error("signature verification failed", "err", err)
+
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
+
return
+
}
+
+
r = r.WithContext(
+
context.WithValue(r.Context(), ActorDid, did),
+
)
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
+
// this is slightly different from http_util::write_error to follow the spec:
+
//
+
// the json object returned must include an "error" and a "message"
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(status)
+
json.NewEncoder(w).Encode(e)
+
}