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

Compare changes

Choose any two refs to compare.

Changed files
+16077 -10782
.air
.tangled
.zed
api
appview
cache
session
config
db
issues
knots
middleware
oauth
pages
markup
repoinfo
templates
errors
fragments
knots
layouts
legal
repo
spindles
strings
timeline
user
posthog
pulls
repo
reporesolver
serververify
settings
spindles
spindleverify
state
strings
validator
xrpcclient
cmd
appview
punchcardPopulate
docs
eventconsumer
cursor
jetstream
knotclient
knotserver
legal
lexicons
log
nix
patchutil
rbac
spindle
workflow
xrpc
errors
serviceauth
+1 -1
.air/appview.toml
···
exclude_regex = [".*_templ.go"]
include_ext = ["go", "templ", "html", "css"]
-
exclude_dir = ["target", "atrium"]
+
exclude_dir = ["target", "atrium", "nix"]
+2 -1
.gitignore
···
*.rdb
.envrc
# Created if following hacking.md
-
genjwks.out
+
genjwks.out
+
/nix/vm-data
+12
.prettierrc.json
···
+
{
+
"overrides": [
+
{
+
"files": ["*.html"],
+
"options": {
+
"parser": "go-template"
+
}
+
}
+
],
+
"bracketSameLine": true,
+
"htmlWhitespaceSensitivity": "ignore"
+
}
+2
.tangled/workflows/build.yml
···
- event: ["push", "pull_request"]
branch: ["master"]
+
engine: nixery
+
dependencies:
nixpkgs:
- go
+3 -12
.tangled/workflows/fmt.yml
···
- event: ["push", "pull_request"]
branch: ["master"]
-
dependencies:
-
nixpkgs:
-
- go
-
- alejandra
+
engine: nixery
steps:
-
- name: "nix fmt"
+
- name: "Check formatting"
command: |
-
alejandra -c nix/**/*.nix flake.nix
-
-
- name: "go fmt"
-
command: |
-
unformatted=$(gofmt -l .)
-
test -z "$unformatted" || (echo "$unformatted" && exit 1)
-
+
nix run .#fmt -- --ci
+2
.tangled/workflows/test.yml
···
- event: ["push", "pull_request"]
branch: ["master"]
+
engine: nixery
+
dependencies:
nixpkgs:
- go
-16
.zed/settings.json
···
-
// Folder-specific settings
-
//
-
// For a full list of overridable settings, and general information on folder-specific settings,
-
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
-
{
-
"languages": {
-
"HTML": {
-
"prettier": {
-
"format_on_save": false,
-
"allowed": true,
-
"parser": "go-template",
-
"plugins": ["prettier-plugin-go-template"]
-
}
-
}
-
}
-
}
+571 -1332
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)
···
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":
+
+
{
+
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 *KnotMember) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
t.Submodules = true
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
-
}
-
-
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 *Pipeline_Dependency) 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.Packages ([]string) (slice)
-
if len("packages") > 1000000 {
-
return xerrors.Errorf("Value in field \"packages\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("packages")); err != nil {
-
return err
-
}
-
-
if len(t.Packages) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Packages was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil {
-
return err
-
}
-
for _, v := range t.Packages {
-
if len(v) > 1000000 {
-
return xerrors.Errorf("Value in field v was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(v)); err != nil {
-
return err
-
}
-
-
}
-
-
// t.Registry (string) (string)
-
if len("registry") > 1000000 {
-
return xerrors.Errorf("Value in field \"registry\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("registry")); err != nil {
-
return err
-
}
-
-
if len(t.Registry) > 1000000 {
-
return xerrors.Errorf("Value in field t.Registry was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.Registry)); err != nil {
-
return err
-
}
-
return nil
-
}
-
-
func (t *Pipeline_Dependency) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = Pipeline_Dependency{}
-
-
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("Pipeline_Dependency: map struct too large (%d)", extra)
-
}
-
-
n := extra
-
-
nameBuf := make([]byte, 8)
-
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.Packages ([]string) (slice)
-
case "packages":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Packages: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Packages = make([]string, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Packages[i] = string(sval)
-
}
-
-
}
-
}
-
// t.Registry (string) (string)
-
case "registry":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Registry = string(sval)
default:
···
return nil
-
func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error {
-
if t == nil {
-
_, err := w.Write(cbg.CborNull)
-
return err
-
}
-
-
cw := cbg.NewCborWriter(w)
-
fieldCount := 3
-
-
if t.Environment == nil {
-
fieldCount--
-
}
-
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
-
return err
-
}
-
-
// t.Name (string) (string)
-
if len("name") > 1000000 {
-
return xerrors.Errorf("Value in field \"name\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("name")); err != nil {
-
return err
-
}
-
-
if len(t.Name) > 1000000 {
-
return xerrors.Errorf("Value in field t.Name was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.Name)); err != nil {
-
return err
-
}
-
-
// t.Command (string) (string)
-
if len("command") > 1000000 {
-
return xerrors.Errorf("Value in field \"command\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("command")); err != nil {
-
return err
-
}
-
-
if len(t.Command) > 1000000 {
-
return xerrors.Errorf("Value in field t.Command was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.Command)); err != nil {
-
return err
-
}
-
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
-
if t.Environment != nil {
-
-
if len("environment") > 1000000 {
-
return xerrors.Errorf("Value in field \"environment\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("environment")); err != nil {
-
return err
-
}
-
-
if len(t.Environment) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
-
return err
-
}
-
for _, v := range t.Environment {
-
if err := v.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
-
}
-
}
-
return nil
-
}
-
-
func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) {
-
*t = Pipeline_Step{}
-
-
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("Pipeline_Step: map struct too large (%d)", extra)
-
}
-
-
n := extra
-
-
nameBuf := make([]byte, 11)
-
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.Name (string) (string)
-
case "name":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Name = string(sval)
-
}
-
// t.Command (string) (string)
-
case "command":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Command = string(sval)
-
}
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
-
case "environment":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Environment = make([]*Pipeline_Pair, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
t.Environment[i] = new(Pipeline_Pair)
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
-
return xerrors.Errorf("unmarshaling t.Environment[i] 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 *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
cw := cbg.NewCborWriter(w)
-
if _, err := cw.Write([]byte{165}); err != nil {
+
if _, err := cw.Write([]byte{164}); err != nil {
+
return err
+
}
+
+
// t.Raw (string) (string)
+
if len("raw") > 1000000 {
+
return xerrors.Errorf("Value in field \"raw\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("raw")); err != nil {
+
return err
+
}
+
+
if len(t.Raw) > 1000000 {
+
return xerrors.Errorf("Value in field t.Raw was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Raw)); err != nil {
return err
···
return err
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
-
if len("steps") > 1000000 {
-
return xerrors.Errorf("Value in field \"steps\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("steps")); err != nil {
-
return err
-
}
-
-
if len(t.Steps) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Steps was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil {
-
return err
-
}
-
for _, v := range t.Steps {
-
if err := v.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
-
}
-
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
-
if len("environment") > 1000000 {
-
return xerrors.Errorf("Value in field \"environment\" was too long")
+
// t.Engine (string) (string)
+
if len("engine") > 1000000 {
+
return xerrors.Errorf("Value in field \"engine\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil {
return err
-
if _, err := cw.WriteString(string("environment")); err != nil {
+
if _, err := cw.WriteString(string("engine")); err != nil {
return err
-
if len(t.Environment) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Environment was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil {
-
return err
-
}
-
for _, v := range t.Environment {
-
if err := v.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
+
if len(t.Engine) > 1000000 {
+
return xerrors.Errorf("Value in field t.Engine was too long")
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
-
if len("dependencies") > 1000000 {
-
return xerrors.Errorf("Value in field \"dependencies\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("dependencies")); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil {
return err
-
-
if len(t.Dependencies) > 8192 {
-
return xerrors.Errorf("Slice value in field t.Dependencies was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil {
+
if _, err := cw.WriteString(string(t.Engine)); err != nil {
return err
-
}
-
for _, v := range t.Dependencies {
-
if err := v.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
return nil
···
n := extra
-
nameBuf := make([]byte, 12)
+
nameBuf := make([]byte, 6)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
switch string(nameBuf[:nameLen]) {
-
// t.Name (string) (string)
+
// t.Raw (string) (string)
+
case "raw":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Raw = string(sval)
+
}
+
// t.Name (string) (string)
case "name":
···
-
// t.Steps ([]*tangled.Pipeline_Step) (slice)
-
case "steps":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Steps: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Steps = make([]*Pipeline_Step, extra)
-
}
+
// t.Engine (string) (string)
+
case "engine":
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
t.Steps[i] = new(Pipeline_Step)
-
if err := t.Steps[i].UnmarshalCBOR(cr); err != nil {
-
return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err)
-
}
-
}
-
-
}
-
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
-
}
-
// t.Environment ([]*tangled.Pipeline_Pair) (slice)
-
case "environment":
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Environment: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Environment = make([]*Pipeline_Pair, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
t.Environment[i] = new(Pipeline_Pair)
-
if err := t.Environment[i].UnmarshalCBOR(cr); err != nil {
-
return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err)
-
}
-
}
-
-
}
-
-
}
-
}
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
-
case "dependencies":
-
-
maj, extra, err = cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
-
if extra > 8192 {
-
return fmt.Errorf("t.Dependencies: array too large (%d)", extra)
-
}
-
-
if maj != cbg.MajArray {
-
return fmt.Errorf("expected cbor array")
-
}
-
-
if extra > 0 {
-
t.Dependencies = make([]*Pipeline_Dependency, extra)
-
}
-
-
for i := 0; i < int(extra); i++ {
-
{
-
var maj byte
-
var extra uint64
-
var err error
-
_ = maj
-
_ = extra
-
_ = err
-
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
t.Dependencies[i] = new(Pipeline_Dependency)
-
if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil {
-
return xerrors.Errorf("unmarshaling t.Dependencies[i] pointer: %w", err)
-
}
-
}
-
-
}
-
-
}
+
t.Engine = string(sval)
default:
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 5
if t.Body == nil {
fieldCount--
···
return err
-
// t.Owner (string) (string)
-
if len("owner") > 1000000 {
-
return xerrors.Errorf("Value in field \"owner\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("owner")); err != nil {
-
return err
-
}
-
-
if len(t.Owner) > 1000000 {
-
return xerrors.Errorf("Value in field t.Owner was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(t.Owner)); err != nil {
-
return err
-
}
-
// t.Title (string) (string)
if len("title") > 1000000 {
return xerrors.Errorf("Value in field \"title\" was too long")
···
return err
-
// t.IssueId (int64) (int64)
-
if len("issueId") > 1000000 {
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("issueId")); err != nil {
-
return err
-
}
-
-
if t.IssueId >= 0 {
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
-
return err
-
}
-
} else {
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
-
return err
-
}
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
t.LexiconTypeID = string(sval)
-
// t.Owner (string) (string)
-
case "owner":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Owner = string(sval)
-
}
// t.Title (string) (string)
case "title":
···
t.Title = string(sval)
-
// t.IssueId (int64) (int64)
-
case "issueId":
-
{
-
maj, extra, err := cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
var extraI int64
-
switch maj {
-
case cbg.MajUnsignedInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 positive overflow")
-
}
-
case cbg.MajNegativeInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 negative overflow")
-
}
-
extraI = -1 - extraI
-
default:
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
-
}
-
-
t.IssueId = int64(extraI)
-
}
// t.CreatedAt (string) (string)
case "createdAt":
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
+
fieldCount := 5
-
if t.CommentId == nil {
-
fieldCount--
-
}
-
-
if t.Owner == nil {
-
fieldCount--
-
}
-
-
if t.Repo == nil {
+
if t.ReplyTo == nil {
fieldCount--
···
return err
-
// t.Repo (string) (string)
-
if t.Repo != nil {
-
-
if len("repo") > 1000000 {
-
return xerrors.Errorf("Value in field \"repo\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("repo")); err != nil {
-
return err
-
}
-
-
if t.Repo == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if len(*t.Repo) > 1000000 {
-
return xerrors.Errorf("Value in field t.Repo was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
-
return err
-
}
-
}
-
}
-
// t.LexiconTypeID (string) (string)
if len("$type") > 1000000 {
return xerrors.Errorf("Value in field \"$type\" was too long")
···
return err
-
// t.Owner (string) (string)
-
if t.Owner != nil {
+
// t.ReplyTo (string) (string)
+
if t.ReplyTo != nil {
-
if len("owner") > 1000000 {
-
return xerrors.Errorf("Value in field \"owner\" was too long")
+
if len("replyTo") > 1000000 {
+
return xerrors.Errorf("Value in field \"replyTo\" was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil {
return err
-
if _, err := cw.WriteString(string("owner")); err != nil {
+
if _, err := cw.WriteString(string("replyTo")); err != nil {
return err
-
if t.Owner == nil {
+
if t.ReplyTo == nil {
if _, err := cw.Write(cbg.CborNull); err != nil {
return err
} else {
-
if len(*t.Owner) > 1000000 {
-
return xerrors.Errorf("Value in field t.Owner was too long")
+
if len(*t.ReplyTo) > 1000000 {
+
return xerrors.Errorf("Value in field t.ReplyTo was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil {
return err
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
+
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
return err
-
// t.CommentId (int64) (int64)
-
if t.CommentId != nil {
-
-
if len("commentId") > 1000000 {
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("commentId")); err != nil {
-
return err
-
}
-
-
if t.CommentId == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if *t.CommentId >= 0 {
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
-
return err
-
}
-
} else {
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
-
return err
-
}
-
}
-
}
-
-
}
-
// t.CreatedAt (string) (string)
if len("createdAt") > 1000000 {
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
t.Body = string(sval)
-
// t.Repo (string) (string)
-
case "repo":
-
-
{
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
-
}
-
-
t.Repo = (*string)(&sval)
-
}
-
}
// t.LexiconTypeID (string) (string)
case "$type":
···
t.Issue = string(sval)
-
// t.Owner (string) (string)
-
case "owner":
+
// t.ReplyTo (string) (string)
+
case "replyTo":
b, err := cr.ReadByte()
···
return err
-
t.Owner = (*string)(&sval)
-
}
-
}
-
// t.CommentId (int64) (int64)
-
case "commentId":
-
{
-
-
b, err := cr.ReadByte()
-
if err != nil {
-
return err
-
}
-
if b != cbg.CborNull[0] {
-
if err := cr.UnreadByte(); err != nil {
-
return err
-
}
-
maj, extra, err := cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
var extraI int64
-
switch maj {
-
case cbg.MajUnsignedInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 positive overflow")
-
}
-
case cbg.MajNegativeInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 negative overflow")
-
}
-
extraI = -1 - extraI
-
default:
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
-
}
-
-
t.CommentId = (*int64)(&extraI)
+
t.ReplyTo = (*string)(&sval)
// t.CreatedAt (string) (string)
···
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 err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); 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 len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); 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)
-
if err != nil {
-
return err
-
}
-
t.CreatedAt = string(sval)
-
}
-
// t.TargetRepo (string) (string)
-
case "targetRepo":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
b, err := cr.ReadByte()
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 -18
api/tangled/tangledpipeline.go
···
Submodules bool `json:"submodules" cborgen:"submodules"`
}
-
// Pipeline_Dependency is a "dependency" in the sh.tangled.pipeline schema.
-
type Pipeline_Dependency struct {
-
Packages []string `json:"packages" cborgen:"packages"`
-
Registry string `json:"registry" cborgen:"registry"`
-
}
-
// Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema.
type Pipeline_ManualTriggerData struct {
Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
···
Ref string `json:"ref" cborgen:"ref"`
}
-
// Pipeline_Step is a "step" in the sh.tangled.pipeline schema.
-
type Pipeline_Step struct {
-
Command string `json:"command" cborgen:"command"`
-
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
-
Name string `json:"name" cborgen:"name"`
-
}
-
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
type Pipeline_TriggerMetadata struct {
Kind string `json:"kind" cborgen:"kind"`
···
// Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema.
type Pipeline_Workflow struct {
-
Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"`
-
Dependencies []*Pipeline_Dependency `json:"dependencies" cborgen:"dependencies"`
-
Environment []*Pipeline_Pair `json:"environment" cborgen:"environment"`
-
Name string `json:"name" cborgen:"name"`
-
Steps []*Pipeline_Step `json:"steps" cborgen:"steps"`
+
Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"`
+
Engine string `json:"engine" cborgen:"engine"`
+
Name string `json:"name" cborgen:"name"`
+
Raw string `json:"raw" cborgen:"raw"`
}
+1
appview/cache/session/store.go
···
PkceVerifier string
DpopAuthserverNonce string
DpopPrivateJwk string
+
ReturnUrl string
}
type SessionStore struct {
+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 {
+240 -23
appview/db/db.go
···
}
func Make(dbPath string) (*DB, error) {
-
db, err := sql.Open("sqlite3", dbPath)
+
// https://github.com/mattn/go-sqlite3#connection-string
+
opts := []string{
+
"_foreign_keys=1",
+
"_journal_mode=WAL",
+
"_synchronous=NORMAL",
+
"_auto_vacuum=incremental",
+
}
+
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
return nil, err
}
-
_, err = db.Exec(`
-
pragma journal_mode = WAL;
-
pragma synchronous = normal;
-
pragma foreign_keys = on;
-
pragma temp_store = memory;
-
pragma mmap_size = 30000000000;
-
pragma page_size = 32768;
-
pragma auto_vacuum = incremental;
-
pragma busy_timeout = 5000;
+
+
ctx := context.Background()
+
conn, err := db.Conn(ctx)
+
if err != nil {
+
return nil, err
+
}
+
defer conn.Close()
+
+
_, err = conn.ExecContext(ctx, `
create table if not exists registrations (
id integer primary key autoincrement,
domain text not null unique,
···
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
}
// run migrations
-
runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error {
+
runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error {
tx.Exec(`
alter table repos add column description text check (length(description) <= 200);
`)
return nil
})
-
runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
+
runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error {
// add unconstrained column
_, err := tx.Exec(`
alter table public_keys
···
return nil
})
-
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
+
runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table comments drop column comment_at;
alter table comments add column rkey text;
···
return err
})
-
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
+
runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table comments add column deleted text; -- timestamp
alter table comments add column edited text; -- timestamp
···
return err
})
-
runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
+
runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table pulls add column source_branch text;
alter table pulls add column source_repo_at text;
···
return err
})
-
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
+
runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table repos add column source text;
`)
···
// NOTE: this cannot be done in a transaction, so it is run outside [0]
//
// [0]: https://sqlite.org/pragma.html#pragma_foreign_keys
-
db.Exec("pragma foreign_keys = off;")
-
runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
+
runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error {
_, err := tx.Exec(`
create table pulls_new (
-- identifiers
···
`)
return err
})
-
db.Exec("pragma foreign_keys = on;")
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
// run migrations
-
runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error {
+
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
tx.Exec(`
alter table repos add column spindle text;
`)
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(db, "rework-collaborators-table", func(tx *sql.Tx) error {
+
runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
// create new table
// - repo_at instead of repo integer
// - rkey field
···
return err
})
+
runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error {
+
_, err := tx.Exec(`
+
alter table issues add column rkey text not null default '';
+
+
-- get last url section from issue_at and save to rkey column
+
update issues
+
set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), '');
+
`)
+
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
}
type migrationFn = func(*sql.Tx) error
-
func runMigration(d *sql.DB, name string, migrationFn migrationFn) error {
-
tx, err := d.Begin()
+
func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error {
+
tx, err := c.BeginTx(context.Background(), nil)
if err != nil {
return err
}
···
}
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
+
}
}
+455 -311
appview/db/issues.go
···
import (
"database/sql"
+
"fmt"
+
"maps"
+
"slices"
+
"sort"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/pagination"
)
type Issue struct {
-
ID int64
-
RepoAt syntax.ATURI
-
OwnerDid string
-
IssueId int
-
IssueAt 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 NewIssue(tx *sql.Tx, issue *Issue) error {
-
defer tx.Rollback()
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
+
}
-
_, err := tx.Exec(`
-
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
-
values (?, 1)
-
`, issue.RepoAt)
-
if err != nil {
-
return err
+
type CommentListItem struct {
+
Self *IssueComment
+
Replies []*IssueComment
+
}
+
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
+
+
// collect top level comments into the map
+
for _, comment := range i.Comments {
+
if comment.IsTopLevel() {
+
toplevel[comment.AtUri().String()] = &CommentListItem{
+
Self: &comment,
+
}
+
} else {
+
replies = append(replies, &comment)
+
}
}
-
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, issue_id, title, body)
-
values (?, ?, ?, ?, ?)
-
`, issue.RepoAt, issue.OwnerDid, 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 SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error {
-
_, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId)
-
return 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 GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
-
var issueAt string
-
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
-
return issueAt, err
+
func (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
}
-
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) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
+
}
}
-
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) 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()
}
-
rows, err := e.Query(
-
`
-
with numbered_issue as (
-
select
-
i.id,
-
i.owner_did,
-
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,
-
issue_id,
-
created,
-
title,
-
body,
-
open,
-
comment_count
-
from
-
numbered_issue
-
where
-
row_num between ? and ?`,
-
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
-
if err != nil {
+
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.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()
-
rows, err := e.Query(
-
`select
-
i.id,
-
i.owner_did,
-
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)
+
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(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 := range issueMap {
+
i := issueMap[issueAt]
+
r := repoMap[string(i.RepoAt)]
+
i.Repo = r
+
}
+
+
// 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) {
-
query := `select id, owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
+
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
row := e.QueryRow(query, repoAt, issueId)
var issue Issue
var createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &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, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
-
row := e.QueryRow(query, repoAt, issueId)
-
-
var issue Issue
-
var createdAt string
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
+
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
+
result, err := e.Exec(
+
`insert into issue_comments (
+
did,
+
rkey,
+
issue_at,
+
body,
+
reply_to,
+
created,
+
edited
+
)
+
values (?, ?, ?, ?, ?, ?, null)
+
on conflict(did, rkey) do update set
+
issue_at = excluded.issue_at,
+
body = excluded.body,
+
edited = case
+
when
+
issue_comments.issue_at != excluded.issue_at
+
or issue_comments.body != excluded.body
+
or issue_comments.reply_to != excluded.reply_to
+
then ?
+
else issue_comments.edited
+
end`,
+
c.Did,
+
c.Rkey,
+
c.IssueAt,
+
c.Body,
+
c.ReplyTo,
+
c.Created.Format(time.RFC3339),
+
time.Now().Format(time.RFC3339),
+
)
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
id, err := result.LastInsertId()
if err != nil {
-
return nil, nil, err
+
return 0, err
}
-
issue.Created = createdTime
-
comments, err := GetComments(e, repoAt, issueId)
-
if err != nil {
-
return nil, nil, err
+
return id, nil
+
}
+
+
func DeleteIssueComments(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
return &issue, comments, nil
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
func NewIssueComment(e Execer, comment *Comment) error {
-
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
-
_, err := e.Exec(
-
query,
-
comment.OwnerDid,
-
comment.RepoAt,
-
comment.Rkey,
-
comment.Issue,
-
comment.CommentId,
-
comment.Body,
-
)
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
return err
}
-
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
-
var comments []Comment
+
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
+
var comments []IssueComment
+
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
-
rows, err := e.Query(`
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`
select
-
owner_did,
-
issue_id,
-
comment_id,
+
id,
+
did,
rkey,
+
issue_at,
+
reply_to,
body,
created,
edited,
deleted
from
-
comments
-
where
-
repo_at = ? and issue_id = ?
-
order by
-
created asc`,
-
repoAt,
-
issueId,
-
)
-
if err == sql.ErrNoRows {
-
return []Comment{}, nil
-
}
+
issue_comments
+
%s
+
`, whereClause)
+
+
rows, err := e.Query(query, args...)
if err != nil {
return nil, err
}
-
defer rows.Close()
for rows.Next() {
-
var comment Comment
-
var createdAt string
-
var deletedAt, editedAt, rkey sql.NullString
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
+
var comment IssueComment
+
var created string
+
var rkey, edited, deleted, replyTo sql.Null[string]
+
err := rows.Scan(
+
&comment.Id,
+
&comment.Did,
+
&rkey,
+
&comment.IssueAt,
+
&replyTo,
+
&comment.Body,
+
&created,
+
&edited,
+
&deleted,
+
)
if err != nil {
return nil, err
}
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
// this is a remnant from old times, newer comments always have rkey
+
if rkey.Valid {
+
comment.Rkey = rkey.V
}
-
comment.Created = &createdAtTime
-
if deletedAt.Valid {
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
-
if err != nil {
-
return nil, err
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
+
comment.Created = t
+
}
+
+
if edited.Valid {
+
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
+
comment.Edited = &t
}
-
comment.Deleted = &deletedTime
}
-
if editedAt.Valid {
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
-
if err != nil {
-
return nil, err
+
if deleted.Valid {
+
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
+
comment.Deleted = &t
}
-
comment.Edited = &editedTime
}
-
if rkey.Valid {
-
comment.Rkey = rkey.String
+
if replyTo.Valid {
+
comment.ReplyTo = &replyTo.V
}
comments = append(comments, comment)
}
-
if err := rows.Err(); err != nil {
+
if err = rows.Err(); err != nil {
return nil, err
}
return comments, nil
}
-
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
-
query := `
-
select
-
owner_did, body, rkey, created, deleted, edited
-
from
-
comments where repo_at = ? and issue_id = ? and comment_id = ?
-
`
-
row := e.QueryRow(query, repoAt, issueId, commentId)
-
-
var comment Comment
-
var createdAt string
-
var deletedAt, editedAt, rkey sql.NullString
-
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
-
if err != nil {
-
return nil, err
+
func DeleteIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, err
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
comment.Created = &createdTime
-
if deletedAt.Valid {
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
-
if err != nil {
-
return nil, err
-
}
-
comment.Deleted = &deletedTime
-
}
+
query := fmt.Sprintf(`delete from issues %s`, whereClause)
+
_, err := e.Exec(query, args...)
+
return err
+
}
-
if editedAt.Valid {
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
-
if err != nil {
-
return nil, err
-
}
-
comment.Edited = &editedTime
+
func CloseIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
if rkey.Valid {
-
comment.Rkey = rkey.String
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
comment.RepoAt = repoAt
-
comment.Issue = issueId
-
comment.CommentId = commentId
-
-
return &comment, nil
-
}
-
-
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = ?,
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where repo_at = ? and issue_id = ? and comment_id = ?
-
`, newBody, repoAt, issueId, commentId)
+
query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
-
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
-
_, err := e.Exec(
-
`
-
update comments
-
set body = "",
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where repo_at = ? and issue_id = ? and comment_id = ?
-
`, repoAt, issueId, commentId)
-
return err
-
}
+
func ReopenIssues(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
-
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
-
_, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
-
return err
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
-
func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
-
_, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
+
query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
+
_, err := e.Exec(query, args...)
return err
}
+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)
+7 -7
appview/db/reaction.go
···
const (
Like ReactionKind = "๐Ÿ‘"
-
Unlike = "๐Ÿ‘Ž"
-
Laugh = "๐Ÿ˜†"
-
Celebration = "๐ŸŽ‰"
-
Confused = "๐Ÿซค"
-
Heart = "โค๏ธ"
-
Rocket = "๐Ÿš€"
-
Eyes = "๐Ÿ‘€"
+
Unlike ReactionKind = "๐Ÿ‘Ž"
+
Laugh ReactionKind = "๐Ÿ˜†"
+
Celebration ReactionKind = "๐ŸŽ‰"
+
Confused ReactionKind = "๐Ÿซค"
+
Heart ReactionKind = "โค๏ธ"
+
Rocket ReactionKind = "๐Ÿš€"
+
Eyes ReactionKind = "๐Ÿ‘€"
)
func (rk ReactionKind) String() string {
+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
}
+35 -139
appview/db/repos.go
···
import (
"database/sql"
+
"errors"
"fmt"
"log"
"slices"
···
Knot string
Rkey string
Created time.Time
-
AtUri string
Description string
Spindle string
···
func (r Repo) DidSlashRepo() string {
p, _ := securejoin.SecureJoin(r.Did, r.Name)
return p
-
}
-
-
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
-
var repos []Repo
-
-
rows, err := e.Query(
-
`select did, name, knot, rkey, description, created, source
-
from repos
-
order by created desc
-
limit ?
-
`,
-
limit,
-
)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
for rows.Next() {
-
var repo Repo
-
err := scanRepo(
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
-
)
-
if err != nil {
-
return nil, err
-
}
-
repos = append(repos, repo)
-
}
-
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
-
return repos, nil
}
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
···
slices.SortFunc(repos, func(a, b Repo) int {
if a.Created.After(b.Created) {
-
return 1
+
return -1
}
-
return -1
+
return 1
})
return repos, nil
}
-
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
-
var repos []Repo
-
-
rows, err := e.Query(
-
`select
-
r.did,
-
r.name,
-
r.knot,
-
r.rkey,
-
r.description,
-
r.created,
-
count(s.id) as star_count,
-
r.source
-
from
-
repos r
-
left join
-
stars s on r.at_uri = s.repo_at
-
where
-
r.did = ?
-
group by
-
r.at_uri
-
order by r.created desc`,
-
did)
-
if err != nil {
-
return nil, err
+
func CountRepos(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
defer rows.Close()
-
for rows.Next() {
-
var repo Repo
-
var repoStats RepoStats
-
var createdAt string
-
var nullableDescription sql.NullString
-
var nullableSource sql.NullString
-
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
-
if err != nil {
-
return nil, err
-
}
-
-
if nullableDescription.Valid {
-
repo.Description = nullableDescription.String
-
}
-
-
if nullableSource.Valid {
-
repo.Source = nullableSource.String
-
}
-
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
repo.Created = time.Now()
-
} else {
-
repo.Created = createdAtTime
-
}
-
-
repo.RepoStats = &repoStats
-
-
repos = append(repos, repo)
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
if err := rows.Err(); 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 !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
}
-
return repos, nil
+
return count, nil
}
func GetRepo(e Execer, did, name string) (*Repo, error) {
···
var description, spindle sql.NullString
row := e.QueryRow(`
-
select did, name, knot, created, at_uri, description, spindle
+
select did, name, knot, created, description, spindle, rkey
from repos
where did = ? and name = ?
`,
···
)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil {
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
var repo Repo
var nullableDescription sql.NullString
-
row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri)
+
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
var createdAt string
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil {
+
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
return nil, err
}
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
`insert into repos
(did, name, knot, rkey, at_uri, description, source)
values (?, ?, ?, ?, ?, ?, ?)`,
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
)
return err
}
···
var repos []Repo
rows, err := e.Query(
-
`select did, name, knot, rkey, description, created, at_uri, 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
···
var nullableDescription sql.NullString
var nullableSource sql.NullString
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
if err != nil {
return nil, err
}
···
var nullableSource sql.NullString
row := e.QueryRow(
-
`select did, name, knot, rkey, description, created, at_uri, source
+
`select did, name, knot, rkey, description, created, source
from repos
where did = ? and name = ? and source is not null and source != ''`,
did, name,
)
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
+
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
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 {
+100 -6
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,
···
r.name,
r.knot,
r.rkey,
-
r.created,
-
r.at_uri
+
r.created
from stars s
join repos r on s.repo_at = r.at_uri
`)
···
&repo.Knot,
&repo.Rkey,
&repoCreatedAt,
-
&repo.AtUri,
); err != nil {
return nil, err
}
···
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
+
}
+36 -11
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 err
}
-
func GetStrings(e Execer, filters ...filter) ([]String, error) {
+
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
var all []String
var conditions []string
···
whereClause = " where " + strings.Join(conditions, " and ")
}
+
limitClause := ""
+
if limit != 0 {
+
limitClause = fmt.Sprintf(" limit %d ", limit)
+
}
+
query := fmt.Sprintf(`select
did,
rkey,
···
content,
created,
edited
-
from strings %s`,
+
from strings
+
%s
+
order by created desc
+
%s`,
whereClause,
+
limitClause,
)
rows, err := e.Query(query, args...)
···
}
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,
})
+297 -10
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)
}
if err != nil {
-
l.Error("error ingesting record", "err", err)
+
l.Debug("error ingesting record", "err", err)
}
-
return err
+
return nil
}
}
···
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
+
}
+474 -332
appview/issues/issues.go
···
package issues
import (
+
"context"
+
"database/sql"
+
"errors"
"fmt"
"log"
-
mathrand "math/rand/v2"
+
"log/slog"
"net/http"
"slices"
-
"strconv"
"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/pages"
"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)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
if err != nil {
-
log.Println("failed to get issue and comments", err)
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
-
return
-
}
-
-
reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt))
-
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{}
if user != nil {
-
userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt))
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
}
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
+
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Issue: issue,
+
CommentList: issue.CommentList(),
+
OrderedReactionKinds: db.OrderedReactionKinds,
+
Reactions: reactionCountMap,
+
UserReacted: userReactions,
+
})
+
}
+
+
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 {
-
log.Println("failed to resolve issue owner", err)
+
log.Println("failed to get repo and knot", err)
+
return
}
-
identsToResolve := make([]string, len(comments))
-
for i, comment := range comments {
-
identsToResolve[i] = comment.OwnerDid
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
+
return
}
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
+
+
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")
+
+
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
}
-
}
-
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Issue: *issue,
-
Comments: comments,
+
newRecord := newIssue.AsRecord()
-
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
-
DidHandleMap: didHandleMap,
+
// edit 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, noticeId, "Failed to edit issue.")
+
return
+
}
-
OrderedReactionKinds: db.OrderedReactionKinds,
-
Reactions: reactionCountMap,
-
UserReacted: userReactions,
-
})
+
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.RepoIssueNSID,
+
Repo: user.Did,
+
Rkey: newIssue.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
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 {
+
l.Error("failed to edit issue on DB", "err", err)
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.PutIssue(tx, newIssue)
+
if err != nil {
+
log.Println("failed to edit issue", err)
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
l.Error("failed to edit issue", "err", err)
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
+
return
+
}
+
rp.pages.HxRefresh(w)
+
}
}
-
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
+
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 {
-
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.Notice(w, noticeId, "Failed to delete issue.")
return
}
+
l = l.With("did", issue.Did, "rkey", issue.Rkey)
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
+
// delete from PDS
+
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
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
}
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
// 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 {
-
log.Println("failed to get issue", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
+
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
}
···
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
return user.Did == collab.Did
})
-
isIssueOwner := user.Did == issue.OwnerDid
+
isIssueOwner := user.Did == issue.Did
// TODO: make this more granular
if isIssueOwner || isCollaborator {
-
-
closed := tangled.RepoIssueStateClosed
-
-
client, err := rp.oauth.AuthorizedClient(r)
-
if err != nil {
-
log.Println("failed to get authorized client", err)
-
return
-
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoIssueStateNSID,
-
Repo: user.Did,
-
Rkey: tid.TID(),
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssueState{
-
Issue: issue.IssueAt,
-
State: closed,
-
},
-
},
-
})
-
-
if err != nil {
-
log.Println("failed to update issue state", err)
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
-
return
-
}
-
-
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
+
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()
-
-
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
-
}
-
-
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
-
}
-
-
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
-
}
-
_, 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
-
}
+
replyToUri := r.FormValue("reply-to")
+
var replyTo *string
+
if replyToUri != "" {
+
replyTo = &replyToUri
+
}
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
+
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()
-
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
-
user := rp.oauth.GetUser(r)
-
f, err := rp.repoResolver.Resolve(r)
+
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get authorized client", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
+
// 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 {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
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)
+
}
+
}()
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
+
commentId, err := db.AddIssueComment(rp.db, comment)
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
l.Error("failed to create comment", "err", err)
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
return
}
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
+
// 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 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
}
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
+
commentId := chi.URLParam(r, "commentId")
+
comments, err := db.GetIssueComments(
+
rp.db,
+
db.FilterEq("id", commentId),
+
)
if err != nil {
-
log.Println("failed to resolve did")
+
l.Error("failed to fetch comment", "id", commentId)
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
return
}
-
-
didHandleMap := make(map[string]string)
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
+
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),
-
DidHandleMap: didHandleMap,
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
}
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
+
l := rp.logger.With("handler", "EditIssueComment")
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
l.Error("failed to get repo and knot", "err", err)
return
}
-
issueId := chi.URLParam(r, "issue")
-
issueIdInt, err := strconv.Atoi(issueId)
-
if err != nil {
-
http.Error(w, "bad issue id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
+
issue, ok := r.Context().Value("issue").(*db.Issue)
+
if !ok {
+
l.Error("failed to get issue")
+
rp.pages.Error404(w)
return
}
-
commentId := chi.URLParam(r, "comment_id")
-
commentIdInt, err := strconv.Atoi(commentId)
-
if err != nil {
-
http.Error(w, "bad comment id", http.StatusBadRequest)
-
log.Println("failed to parse issue id", err)
-
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
-
// optimistic update
-
edited := time.Now()
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
+
now := time.Now()
+
newComment := comment
+
newComment.Body = newBody
+
newComment.Edited = &now
+
record := newComment.AsRecord()
+
+
_, err = db.AddIssueComment(rp.db, newComment)
if err != nil {
log.Println("failed to perferom update-description query", err)
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
}
// rkey is optional, it was introduced later
-
if comment.Rkey != "" {
+
if newComment.Rkey != "" {
// update the record on pds
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
if err != nil {
-
// failed to get record
-
log.Println(err, rkey)
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
return
}
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
-
record, _ := data.UnmarshalJSON(value)
-
-
repoAt := record["repo"].(string)
-
issueAt := record["issue"].(string)
-
createdAt := record["createdAt"].(string)
-
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
-
didHandleMap := map[string]string{
-
user.Did: user.Handle,
-
}
-
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),
-
DidHandleMap: didHandleMap,
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,
})
···
}
// optimistic update for htmx
-
didHandleMap := map[string]string{
-
user.Did: user.Handle,
-
}
comment.Body = ""
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),
-
DidHandleMap: didHandleMap,
Issue: issue,
-
Comment: comment,
+
Comment: &comment,
})
-
return
}
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
···
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.")
return
}
-
identsToResolve := make([]string, len(issues))
-
for i, issue := range issues {
-
identsToResolve[i] = issue.OwnerDid
-
}
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
LoggedInUser: rp.oauth.GetUser(r),
RepoInfo: f.RepoInfo(user),
Issues: issues,
-
DidHandleMap: didHandleMap,
FilteringByOpen: isOpen,
Page: page,
})
-
return
}
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")
-
-
if title == "" || body == "" {
-
rp.pages.Notice(w, "issues", "Title and body are required")
-
return
+
issue := &db.Issue{
+
RepoAt: f.RepoAt(),
+
Rkey: tid.TID(),
+
Title: r.FormValue("title"),
+
Body: r.FormValue("body"),
+
Did: user.Did,
+
Created: time.Now(),
}
-
tx, err := rp.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
+
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
}
-
issue := &db.Issue{
-
RepoAt: f.RepoAt,
-
Title: title,
-
Body: body,
-
OwnerDid: user.Did,
-
}
-
err = db.NewIssue(tx, issue)
-
if err != nil {
-
log.Println("failed to create issue", err)
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
-
return
-
}
+
record := issue.AsRecord()
+
// create 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, "issues", "Failed to create issue.")
return
}
-
atUri := f.RepoAt.String()
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueNSID,
Repo: user.Did,
-
Rkey: tid.TID(),
+
Rkey: issue.Rkey,
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoIssue{
-
Repo: atUri,
-
Title: title,
-
Body: &body,
-
Owner: user.Did,
-
IssueId: int64(issue.IssueId),
-
},
+
Val: &record,
},
})
if err != nil {
-
log.Println("failed to create issue", err)
+
l.Error("failed to create issue", "err", err)
rp.pages.Notice(w, "issues", "Failed to create issue.")
return
}
+
atUri := resp.Uri
-
err = db.SetIssueAt(rp.db, f.RepoAt, issue.IssueId, resp.Uri)
+
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
-
log.Println("failed to set issue at", err)
+
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
+
return
+
}
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rollbackRecord(context.Background(), atUri, client)
+
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if err := errors.Join(err1, err2); err != nil {
+
l.Error("failed to rollback txn", "err", err)
+
}
+
}
+
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
}
-
rp.notifier.NewIssue(r.Context(), issue)
+
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)
})
})
+415 -233
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)
+
l = l.With("user", user.Did)
-
user := k.OAuth.GetUser(r)
-
l = l.With("did", 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
}
-
}
-
repos, err := db.GetRepos(
-
k.Db,
-
0,
-
db.FilterEq("knot", domain),
-
db.FilterIn("did", members),
-
)
-
if err != nil {
-
l.Error("failed to get repos list", "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)
-
}
+
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
var exCid *string
+
if ex != nil {
+
exCid = ex.Cid
+
}
-
var didsToResolve []string
-
for _, m := range members {
-
didsToResolve = append(didsToResolve, m)
-
}
-
didsToResolve = append(didsToResolve, reg.ByDid)
-
resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
+
// 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)
}
}
-
k.Pages.Knot(w, pages.KnotParams{
-
LoggedInUser: user,
-
DidHandleMap: didHandleMap,
-
Registration: reg,
-
Members: members,
-
Repos: repoByMember,
-
IsOwner: true,
-
})
-
}
+
// add this knot to knotstream
+
go k.Knotstream.AddSource(
+
r.Context(),
+
eventconsumer.NewKnotSource(domain),
+
)
-
// 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")
-
-
domain := chi.URLParam(r, "domain")
-
if domain == "" {
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
shouldRefresh := r.Header.Get("shouldRefresh")
+
if shouldRefresh == "true" {
+
k.Pages.HxRefresh(w)
return
}
-
l = l.With("domain", domain)
-
// list all members for this domain
-
memberDids, err := k.Enforcer.GetUserByRole("server:member", domain)
+
// Get updated registration to show
+
registrations, err = db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("domain", domain),
+
)
if err != nil {
-
w.Write([]byte("failed to fetch member list"))
+
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
+
}
+
updatedRegistration := registrations[0]
-
w.Write([]byte(strings.Join(memberDids, "\n")))
+
w.Header().Set("HX-Reswap", "outerHTML")
+
k.Pages.KnotListing(w, pages.KnotListingParams{
+
Registration: &updatedRegistration,
+
})
}
-
// 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")
+
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)
-
reg, err := db.RegistrationByDomain(k.Db, domain)
+
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 get registration by domain", "err", err)
-
http.Error(w, "malformed url", http.StatusBadRequest)
+
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
}
+
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
-
func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+
// commit everything
+
err = k.Enforcer.E.SavePolicy()
+
if err != nil {
+
l.Error("failed to save ACLs", "err", err)
+
fail()
+
return
+
}
+
+
// ok
+
k.Pages.HxRefresh(w)
}
+56 -13
appview/middleware/middleware.go
···
"fmt"
"log"
"net/http"
+
"net/url"
"slices"
"strconv"
"strings"
-
"time"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
···
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
returnURL := "/"
+
if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
+
returnURL = u.RequestURI()
+
}
+
+
loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL))
+
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
}
if r.Header.Get("HX-Request") == "true" {
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
-
w.Header().Set("HX-Redirect", "/login")
+
w.Header().Set("HX-Redirect", loginURL)
w.WriteHeader(http.StatusOK)
}
}
···
if err != nil {
// invalid did or handle
log.Println("failed to resolve repo")
-
mw.pages.Error404(w)
+
mw.pages.ErrorKnot404(w)
return
}
-
ctx := context.WithValue(req.Context(), "knot", repo.Knot)
-
ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
-
ctx = context.WithValue(ctx, "repoDescription", repo.Description)
-
ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle)
-
ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
+
ctx := context.WithValue(req.Context(), "repo", repo)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
···
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
}
···
return
}
-
pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt)
+
pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt)
if err != nil {
log.Println("failed to get pull and comments", err)
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
}
-
fullName := f.OwnerHandle() + "/" + f.RepoName
+
fullName := f.OwnerHandle() + "/" + f.Name
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
if r.URL.Query().Get("go-get") == "1" {
+124 -86
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"
)
···
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
-
o.pages.Login(w, pages.LoginParams{})
+
returnURL := r.URL.Query().Get("return_url")
+
o.pages.Login(w, pages.LoginParams{
+
ReturnUrl: returnURL,
+
})
case http.MethodPost:
handle := r.FormValue("handle")
···
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
DpopPrivateJwk: string(dpopKeyJson),
State: parResp.State,
+
ReturnUrl: r.FormValue("return_url"),
})
if err != nil {
log.Println("failed to save oauth request:", err)
···
iss := r.FormValue("iss")
if iss == "" {
log.Println("missing iss for state: ", state)
+
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
+
return
+
}
+
+
if iss != oauthRequest.AuthserverIss {
+
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
return
}
···
}
}
-
http.Redirect(w, r, "/", http.StatusFound)
+
returnUrl := oauthRequest.ReturnUrl
+
if returnUrl == "" {
+
returnUrl = "/"
+
}
+
+
http.Redirect(w, r, returnUrl, http.StatusFound)
}
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
···
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
+
}
+
+
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("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
-
if appPassword == "" {
-
log.Println("no app password configured, skipping spindle member addition")
+
allKnots, err := o.enforcer.GetKnotsForUser(did)
+
if err != nil {
+
log.Printf("failed to get knot members for did %s: %v", did, err)
return
}
-
log.Printf("adding %s to default spindle", did)
+
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
-
}
-
-
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
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
}
-
if resp.StatusCode != http.StatusNoContent {
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
-
return
-
}
+
return nil
}
+16 -3
appview/oauth/oauth.go
···
if err != nil {
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
}
-
if expiry.Sub(time.Now()) <= 5*time.Minute {
+
if time.Until(expiry) <= 5*time.Minute {
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
if err != nil {
return nil, false, err
···
s.service = service
}
}
+
+
// Specify the Duration in seconds for the expiry of this token
+
//
+
// The time of expiry is calculated as time.Now().Unix() + exp
func WithExp(exp int64) ServiceClientOpt {
return func(s *ServiceClientOpts) {
-
s.exp = exp
+
s.exp = time.Now().Unix() + exp
}
}
···
return nil, err
}
+
// force expiry to atleast 60 seconds in the future
+
sixty := time.Now().Unix() + 60
+
if opts.exp < sixty {
+
opts.exp = sixty
+
}
+
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
if err != nil {
return nil, err
···
AccessJwt: resp.Token,
},
Host: opts.Host(),
+
Client: &http.Client{
+
Timeout: time.Second * 5,
+
},
}, nil
}
···
redirectURIs := makeRedirectURIs(clientURI)
if o.config.Core.Dev {
-
clientURI = fmt.Sprintf("http://127.0.0.1:3000")
+
clientURI = "http://127.0.0.1:3000"
redirectURIs = makeRedirectURIs(clientURI)
query := url.Values{}
+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)
+
}
+42 -6
appview/pages/funcmap.go
···
package pages
import (
+
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
···
"github.com/dustin/go-humanize"
"github.com/go-enry/go-enry/v2"
-
"github.com/microcosm-cc/bluemonday"
"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 {
···
"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)
+
+
if err != nil {
+
return s
+
}
+
+
if identity.Handle.IsInvalidHandle() {
+
return "handle.invalid"
+
}
+
+
return "@" + identity.Handle.String()
+
},
"truncateAt30": func(s string) string {
if len(s) <= 30 {
return s
···
"negf64": func(a float64) float64 {
return -a
},
-
"cond": func(cond interface{}, a, b string) string {
+
"cond": func(cond any, a, b string) string {
if cond == nil {
return b
}
···
return html.UnescapeString(s)
},
"nl2br": func(text string) template.HTML {
-
return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1))
+
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
},
"unwrapText": func(text string) string {
paragraphs := strings.Split(text, "\n\n")
···
}
return v.Slice(0, min(n, v.Len())).Interface()
},
-
"markdown": func(text string) template.HTML {
-
rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault}
-
return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text)))
+
p.rctx.RendererType = markup.RendererTypeDefault
+
htmlString := p.rctx.RenderMarkdown(text)
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
return template.HTML(sanitized)
+
},
+
"description": func(text string) template.HTML {
+
p.rctx.RendererType = markup.RendererTypeDefault
+
htmlString := p.rctx.RenderMarkdown(text)
+
sanitized := p.rctx.SanitizeDescription(htmlString)
+
return template.HTML(sanitized)
},
"isNil": func(t any) bool {
// returns false for other "zero" values
···
},
"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 {
+73 -39
appview/pages/markup/markdown.go
···
"path"
"strings"
-
"github.com/microcosm-cc/bluemonday"
+
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/extension"
"github.com/yuin/goldmark/parser"
···
"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"
)
···
repoinfo.RepoInfo
IsDev bool
RendererType RendererType
+
Sanitizer Sanitizer
}
func (rctx *RenderContext) RenderMarkdown(source string) string {
md := goldmark.New(
-
goldmark.WithExtensions(extension.GFM),
+
goldmark.WithExtensions(
+
extension.GFM,
+
highlighting.NewHighlighting(
+
highlighting.WithFormatOptions(
+
chromahtml.Standalone(false),
+
chromahtml.WithClasses(true),
+
),
+
highlighting.WithCustomStyle(styles.Get("catppuccin-latte")),
+
),
+
extension.NewFootnote(
+
extension.WithFootnoteIDPrefix([]byte("footnote")),
+
),
+
treeblood.MathML(),
+
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
···
}
}
-
func (rctx *RenderContext) Sanitize(html string) string {
-
policy := bluemonday.UGCPolicy()
+
func (rctx *RenderContext) SanitizeDefault(html string) string {
+
return rctx.Sanitizer.SanitizeDefault(html)
+
}
-
// video
-
policy.AllowElements("video")
-
policy.AllowAttrs("controls").OnElements("video")
-
policy.AllowElements("source")
-
policy.AllowAttrs("src", "type").OnElements("source")
-
-
// centering content
-
policy.AllowElements("center")
-
-
policy.AllowAttrs("align", "style", "width", "height").Globally()
-
policy.AllowStyles(
-
"margin",
-
"padding",
-
"text-align",
-
"font-weight",
-
"text-decoration",
-
"padding-left",
-
"padding-right",
-
"padding-top",
-
"padding-bottom",
-
"margin-left",
-
"margin-right",
-
"margin-top",
-
"margin-bottom",
-
)
-
return policy.Sanitize(html)
+
func (rctx *RenderContext) SanitizeDescription(html string) string {
+
return rctx.Sanitizer.SanitizeDescription(html)
}
type MarkdownTransformer struct {
···
switch a.rctx.RendererType {
case RendererTypeRepoMarkdown:
switch n := n.(type) {
+
case *ast.Heading:
+
a.rctx.anchorHeadingTransformer(n)
case *ast.Link:
a.rctx.relativeLinkTransformer(n)
case *ast.Image:
···
}
case RendererTypeDefault:
switch n := n.(type) {
+
case *ast.Heading:
+
a.rctx.anchorHeadingTransformer(n)
case *ast.Image:
a.rctx.imageFromKnotAstTransformer(n)
a.rctx.camoImageLinkAstTransformer(n)
···
dst := string(link.Destination)
-
if isAbsoluteUrl(dst) {
+
if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) {
return
}
···
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
···
img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
}
+
func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) {
+
idGeneric, exists := h.AttributeString("id")
+
if !exists {
+
return // no id, nothing to do
+
}
+
id, ok := idGeneric.([]byte)
+
if !ok {
+
return
+
}
+
+
// create anchor link
+
anchor := ast.NewLink()
+
anchor.Destination = fmt.Appendf(nil, "#%s", string(id))
+
anchor.SetAttribute([]byte("class"), []byte("anchor"))
+
+
// create icon text
+
iconText := ast.NewString([]byte("#"))
+
anchor.AppendChild(anchor, iconText)
+
+
// set class on heading
+
h.SetAttribute([]byte("class"), []byte("heading"))
+
+
// append anchor to heading
+
h.AppendChild(h, anchor)
+
}
+
// actualPath decides when to join the file path with the
// current repository directory (essentially only when the link
// destination is relative. if it's absolute then we assume the
···
}
return parsed.IsAbs()
}
+
+
func isFragment(link string) bool {
+
return strings.HasPrefix(link, "#")
+
}
+
+
func isMail(link string) bool {
+
return strings.HasPrefix(link, "mailto:")
+
}
+134
appview/pages/markup/sanitizer.go
···
+
package markup
+
+
import (
+
"maps"
+
"regexp"
+
"slices"
+
"strings"
+
+
"github.com/alecthomas/chroma/v2"
+
"github.com/microcosm-cc/bluemonday"
+
)
+
+
type Sanitizer struct {
+
defaultPolicy *bluemonday.Policy
+
descriptionPolicy *bluemonday.Policy
+
}
+
+
func NewSanitizer() Sanitizer {
+
return Sanitizer{
+
defaultPolicy: defaultPolicy(),
+
descriptionPolicy: descriptionPolicy(),
+
}
+
}
+
+
func (s *Sanitizer) SanitizeDefault(html string) string {
+
return s.defaultPolicy.Sanitize(html)
+
}
+
func (s *Sanitizer) SanitizeDescription(html string) string {
+
return s.descriptionPolicy.Sanitize(html)
+
}
+
+
func defaultPolicy() *bluemonday.Policy {
+
policy := bluemonday.UGCPolicy()
+
+
// Allow generally safe attributes
+
generalSafeAttrs := []string{
+
"abbr", "accept", "accept-charset",
+
"accesskey", "action", "align", "alt",
+
"aria-describedby", "aria-hidden", "aria-label", "aria-labelledby",
+
"axis", "border", "cellpadding", "cellspacing", "char",
+
"charoff", "charset", "checked",
+
"clear", "cols", "colspan", "color",
+
"compact", "coords", "datetime", "dir",
+
"disabled", "enctype", "for", "frame",
+
"headers", "height", "hreflang",
+
"hspace", "ismap", "label", "lang",
+
"maxlength", "media", "method",
+
"multiple", "name", "nohref", "noshade",
+
"nowrap", "open", "prompt", "readonly", "rel", "rev",
+
"rows", "rowspan", "rules", "scope",
+
"selected", "shape", "size", "span",
+
"start", "summary", "tabindex", "target",
+
"title", "type", "usemap", "valign", "value",
+
"vspace", "width", "itemprop",
+
}
+
+
generalSafeElements := []string{
+
"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
+
"div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label",
+
"dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary",
+
"details", "caption", "figure", "figcaption",
+
"abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr",
+
}
+
+
policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...)
+
+
// video
+
policy.AllowAttrs("src", "autoplay", "controls").OnElements("video")
+
+
// checkboxes
+
policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
+
policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input")
+
+
// for code blocks
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre")
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a")
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8")
+
policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span")
+
+
// centering content
+
policy.AllowElements("center")
+
+
policy.AllowAttrs("align", "style", "width", "height").Globally()
+
policy.AllowStyles(
+
"margin",
+
"padding",
+
"text-align",
+
"font-weight",
+
"text-decoration",
+
"padding-left",
+
"padding-right",
+
"padding-top",
+
"padding-bottom",
+
"margin-left",
+
"margin-right",
+
"margin-top",
+
"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
+
}
+
+
func descriptionPolicy() *bluemonday.Policy {
+
policy := bluemonday.NewPolicy()
+
policy.AllowStandardURLs()
+
+
// allow italics and bold.
+
policy.AllowElements("i", "b", "em", "strong")
+
+
// allow code.
+
policy.AllowElements("code")
+
+
// allow links
+
policy.AllowAttrs("href", "target", "rel").OnElements("a")
+
+
return policy
+
}
+381 -252
appview/pages/pages.go
···
"html/template"
"io"
"io/fs"
-
"log"
+
"log/slog"
"net/http"
"os"
"path/filepath"
···
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
"tangled.sh/tangled.sh/core/appview/pagination"
+
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
···
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) *Pages {
+
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
// initialized with safe defaults, can be overriden per use
rctx := &markup.RenderContext{
IsDev: config.Core.Dev,
CamoUrl: config.Camo.Host,
CamoSecret: config.Camo.SharedSecret,
+
Sanitizer: markup.NewSanitizer(),
}
p := &Pages{
mu: sync.RWMutex{},
-
t: make(map[string]*template.Template),
+
cache: NewTmplCache[string, *template.Template](),
dev: config.Core.Dev,
avatar: config.Avatar,
-
embedFS: Files,
rctx: rctx,
+
resolver: res,
templateDir: "appview/pages",
+
logger: slog.Default().With("component", "pages"),
}
-
// Initial load of all templates
-
p.loadAllTemplates()
+
if p.dev {
+
p.embedFS = os.DirFS(p.templateDir)
+
} else {
+
p.embedFS = Files
+
}
return p
}
-
func (p *Pages) loadAllTemplates() {
-
templates := make(map[string]*template.Template)
-
var fragmentPaths []string
+
func (p *Pages) pathToName(s string) string {
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
+
}
-
// Use embedded FS for initial loading
-
// First, collect all fragment paths
+
// reverse of pathToName
+
func (p *Pages) nameToPath(s string) string {
+
return "templates/" + s + ".html"
+
}
+
+
func (p *Pages) fragmentPaths() ([]string, error) {
+
var fragmentPaths []string
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
···
if !strings.Contains(path, "fragments/") {
return nil
}
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
-
tmpl, err := template.New(name).
-
Funcs(p.funcMap()).
-
ParseFS(p.embedFS, path)
-
if err != nil {
-
log.Fatalf("setting up fragment: %v", err)
-
}
-
templates[name] = tmpl
fragmentPaths = append(fragmentPaths, path)
-
log.Printf("loaded fragment: %s", name)
return nil
})
if err != nil {
-
log.Fatalf("walking template dir for fragments: %v", err)
+
return nil, err
}
-
// Then walk through and setup the rest of the templates
-
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
-
if err != nil {
-
return err
-
}
-
if d.IsDir() {
-
return nil
-
}
-
if !strings.HasSuffix(path, "html") {
-
return nil
-
}
-
// Skip fragments as they've already been loaded
-
if strings.Contains(path, "fragments/") {
-
return nil
-
}
-
// Skip layouts
-
if strings.Contains(path, "layouts/") {
-
return nil
-
}
-
name := strings.TrimPrefix(path, "templates/")
-
name = strings.TrimSuffix(name, ".html")
-
// Add the page template on top of the base
-
allPaths := []string{}
-
allPaths = append(allPaths, "templates/layouts/*.html")
-
allPaths = append(allPaths, fragmentPaths...)
-
allPaths = append(allPaths, path)
-
tmpl, err := template.New(name).
-
Funcs(p.funcMap()).
-
ParseFS(p.embedFS, allPaths...)
-
if err != nil {
-
return fmt.Errorf("setting up template: %w", err)
-
}
-
templates[name] = tmpl
-
log.Printf("loaded template: %s", name)
-
return nil
-
})
+
return fragmentPaths, nil
+
}
+
+
// parse without memoization
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
+
paths, err := p.fragmentPaths()
+
if err != nil {
+
return nil, err
+
}
+
for _, s := range stack {
+
paths = append(paths, p.nameToPath(s))
+
}
+
+
funcs := p.funcMap()
+
top := stack[len(stack)-1]
+
parsed, err := template.New(top).
+
Funcs(funcs).
+
ParseFS(p.embedFS, paths...)
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
+
return parsed, nil
}
-
// loadTemplateFromDisk loads a template from the filesystem in dev mode
-
func (p *Pages) loadTemplateFromDisk(name string) error {
-
if !p.dev {
-
return 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
}
-
log.Printf("reloading template from disk: %s", name)
+
result, err := p.rawParse(stack...)
+
if err != nil {
+
return nil, err
+
}
-
// 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
-
})
-
if err != nil {
-
return fmt.Errorf("walking disk template dir for fragments: %w", err)
+
p.cache.Set(key, result)
+
return result, nil
+
}
+
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
top,
}
+
return p.parse(stack...)
+
}
-
// 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)
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/repobase",
+
top,
}
+
return p.parse(stack...)
+
}
-
// Create a new template
-
tmpl := template.New(name).Funcs(p.funcMap())
+
func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/profilebase",
+
top,
+
}
+
return p.parse(stack...)
+
}
-
// Parse layouts
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
-
layouts, err := filepath.Glob(layoutGlob)
+
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
+
tpl, err := p.parse(name)
if err != nil {
-
return fmt.Errorf("finding layout templates: %w", err)
+
return err
}
-
// Create paths for parsing
-
allFiles := append(layouts, fragmentPaths...)
-
allFiles = append(allFiles, templatePath)
+
return tpl.Execute(w, params)
+
}
-
// Parse all templates
-
tmpl, err = tmpl.ParseFiles(allFiles...)
+
func (p *Pages) execute(name string, w io.Writer, params any) error {
+
tpl, err := p.parseBase(name)
if err != nil {
-
return fmt.Errorf("parsing template files: %w", err)
+
return err
}
-
// 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
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
}
-
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) executeRepo(name string, w io.Writer, params any) error {
+
tpl, err := p.parseRepoBase(name)
+
if err != nil {
+
return err
}
-
p.mu.RLock()
-
defer p.mu.RUnlock()
-
tmpl, exists := p.t[templateName]
-
if !exists {
-
return fmt.Errorf("template not found: %s", templateName)
-
}
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
+
}
-
if base == "" {
-
return tmpl.Execute(w, params)
-
} else {
-
return tmpl.ExecuteTemplate(w, base, params)
+
func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
+
tpl, err := p.parseProfileBase(name)
+
if err != nil {
+
return err
}
-
}
-
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "layouts/base", params)
-
}
-
-
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)
+
func (p *Pages) Favicon(w io.Writer) error {
+
return p.executePlain("favicon", w, nil)
}
type LoginParams struct {
+
ReturnUrl string
}
func (p *Pages) Login(w io.Writer, params LoginParams) 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
-
DidHandleMap map[string]string
+
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
}
-
type SettingsParams struct {
+
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
+
return p.execute("user/settings/profile", w, params)
+
}
+
+
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) 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) Settings(w io.Writer, params SettingsParams) error {
-
return p.execute("settings", w, params)
+
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
+
return p.executePlain("banner", w, params)
}
type KnotsParams struct {
···
type KnotParams struct {
LoggedInUser *oauth.User
-
DidHandleMap map[string]string
Registration *db.Registration
Members []string
Repos map[string][]db.Repo
···
}
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
···
Spindle db.Spindle
Members []string
Repos map[string][]db.Repo
-
DidHandleMap map[string]string
}
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
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
+
}
-
DidHandleMap map[string]string
+
func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/overview", w, params)
}
-
type ProfileCard struct {
-
UserDid string
-
UserHandle string
-
FollowStatus db.FollowStatus
-
Followers int
-
Following int
-
-
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
+
}
-
DidHandleMap map[string]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 {
···
LoggedInUser *oauth.User
Profile *db.Profile
AllRepos []PinnedRepo
-
DidHandleMap map[string]string
}
type PinnedRepo 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
}
···
return p.executeRepo("repo/empty", w, params)
}
+
if params.NeedsKnotUpgrade {
+
return p.executeRepo("repo/needsUpgrade", w, params)
+
}
+
p.rctx.RepoInfo = params.RepoInfo
+
p.rctx.RepoInfo.Ref = params.Ref
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
if params.ReadmeFileName != "" {
-
var htmlString string
ext := filepath.Ext(params.ReadmeFileName)
switch ext {
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
-
htmlString = p.rctx.Sanitize(htmlString)
-
htmlString = p.rctx.RenderMarkdown(params.Readme)
params.Raw = false
-
params.HTMLReadme = template.HTML(htmlString)
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.HTMLReadme = template.HTML(sanitized)
default:
params.Raw = true
}
···
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 {
···
p.rctx.RepoInfo = params.RepoInfo
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
htmlString := p.rctx.RenderMarkdown(params.Contents)
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.RenderedContents = template.HTML(sanitized)
}
}
-
if params.Lines < 5000 {
-
c := params.Contents
-
formatter := chromahtml.New(
-
chromahtml.InlineCode(false),
-
chromahtml.WithLineNumbers(true),
-
chromahtml.WithLinkableLineNumbers(true, "L"),
-
chromahtml.Standalone(false),
-
chromahtml.WithClasses(true),
-
)
+
c := params.Contents
+
formatter := chromahtml.New(
+
chromahtml.InlineCode(false),
+
chromahtml.WithLineNumbers(true),
+
chromahtml.WithLinkableLineNumbers(true, "L"),
+
chromahtml.Standalone(false),
+
chromahtml.WithClasses(true),
+
)
-
lexer := lexers.Get(filepath.Base(params.Path))
-
if lexer == nil {
-
lexer = lexers.Fallback
-
}
+
lexer := lexers.Get(filepath.Base(params.Path))
+
if lexer == nil {
+
lexer = lexers.Fallback
+
}
-
iterator, err := lexer.Tokenise(nil, c)
-
if err != nil {
-
return fmt.Errorf("chroma tokenize: %w", err)
-
}
+
iterator, err := lexer.Tokenise(nil, c)
+
if err != nil {
+
return fmt.Errorf("chroma tokenize: %w", err)
+
}
-
var code bytes.Buffer
-
err = formatter.Format(&code, style, iterator)
-
if err != nil {
-
return fmt.Errorf("chroma format: %w", err)
-
}
-
-
params.Contents = code.String()
+
var code bytes.Buffer
+
err = formatter.Format(&code, style, iterator)
+
if err != nil {
+
return fmt.Errorf("chroma format: %w", err)
}
+
params.Contents = code.String()
params.Active = "overview"
return p.executeRepo("repo/blob", w, params)
}
···
RepoInfo repoinfo.RepoInfo
Active string
Issues []db.Issue
-
DidHandleMap map[string]string
Page pagination.Page
FilteringByOpen bool
}
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Active string
-
Issue db.Issue
-
Comments []db.Comment
+
Issue *db.Issue
+
CommentList []db.CommentListItem
IssueOwnerHandle string
-
DidHandleMap map[string]string
OrderedReactionKinds []db.ReactionKind
Reactions map[db.ReactionKind]int
UserReacted map[db.ReactionKind]bool
+
}
+
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/issues/issue", w, params)
+
}
-
State string
+
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
-
DidHandleMap map[string]string
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.IssueComment
+
}
+
+
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.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) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
}
type RepoNewPullParams struct {
···
RepoInfo repoinfo.RepoInfo
Pulls []*db.Pull
Active string
-
DidHandleMap map[string]string
FilteringBy db.PullState
Stacks map[string]db.Stack
Pipelines map[string]db.Pipeline
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Active string
-
DidHandleMap map[string]string
Pull *db.Pull
Stack db.Stack
AbandonedPulls []*db.Pull
···
type RepoPullPatchParams struct {
LoggedInUser *oauth.User
-
DidHandleMap map[string]string
RepoInfo repoinfo.RepoInfo
Pull *db.Pull
Stack db.Stack
···
type RepoPullInterdiffParams struct {
LoggedInUser *oauth.User
-
DidHandleMap map[string]string
RepoInfo repoinfo.RepoInfo
Pull *db.Pull
Round int
···
return p.execute("strings/dashboard", w, params)
+
type StringTimelineParams struct {
+
LoggedInUser *oauth.User
+
Strings []db.String
+
}
+
+
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
+
return p.execute("strings/timeline", w, params)
+
}
+
type SingleStringParams struct {
LoggedInUser *oauth.User
ShowRendered bool
···
if params.ShowRendered {
switch markup.GetFormat(params.String.Filename) {
case markup.FormatMarkdown:
-
p.rctx.RendererType = markup.RendererTypeDefault
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
-
params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString))
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.RenderedContents = template.HTML(sanitized)
···
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 }}
+26
appview/pages/templates/favicon.html
···
+
{{ define "favicon" }}
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
+
<style>
+
.favicon-text {
+
fill: #000000;
+
stroke: none;
+
}
+
+
@media (prefers-color-scheme: dark) {
+
.favicon-text {
+
fill: #ffffff;
+
stroke: none;
+
}
+
}
+
</style>
+
+
<g style="display:inline">
+
<path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/>
+
<path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z"
+
aria-label="tangled.sh"
+
class="favicon-text"
+
style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1"
+
transform="translate(11.01 6.9)"/>
+
</g>
+
</svg>
+
{{ 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 }}
+96 -32
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 }}
-
</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>
+
<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 }}
-
{{ 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>
{{ end }}
-
</div>
+
{{ 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>
+
{{ 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 }}
+
{{ 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">
<div class="flex items-center gap-2">
-
{{ i "user" "size-4" }}
-
{{ $user := index $.DidHandleMap . }}
-
<a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a>
+
{{ 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 . }}
{{ range $repos }}
<div class="flex gap-2 items-center">
{{ i "book-marked" "size-4" }}
-
<a href="/{{ .Did }}/{{ .Name }}">
+
<a href="/{{ resolve .Did }}/{{ .Name }}">
{{ .Name }}
</a>
</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 -24
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" . }}
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentLeft" . }} {{ end }}
-
</div>
<main class="col-span-1 md:col-span-8">
{{ block "content" . }}{{ end }}
</main>
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentRight" . }} {{ end }}
-
</div>
{{ end }}
{{ block "contentAfterLayout" . }}
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterLeft" . }} {{ end }}
-
</div>
<main class="col-span-1 md:col-span-8">
{{ block "contentAfter" . }}{{ end }}
</main>
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterRight" . }} {{ end }}
-
</div>
{{ end }}
</div>
{{ 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 }}
+
+20 -29
appview/pages/templates/layouts/repobase.html
···
{{ if .RepoInfo.Source }}
<p class="text-sm">
<div class="flex items-center">
-
{{ i "git-fork" "w-3 h-3 mr-1"}}
+
{{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }}
forked from
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
···
</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 }}
-
{{ if .RepoInfo.DisableFork }}
-
<button
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
-
disabled
-
title="Empty repositories cannot be forked"
-
>
-
{{ i "git-fork" "w-4 h-4" }}
-
fork
-
</button>
-
{{ else }}
-
<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>
-
{{ end }}
+
<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-semibold 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 }}
+3 -3
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 }}
···
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
{{ template "repo/fragments/diffOpts" .DiffOpts }}
</div>
-
<div class="sticky top-0 flex-grow max-h-screen">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/diffChangedFiles" .Diff }}
</div>
{{end}}
+3 -3
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 }}
···
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
{{ template "repo/fragments/diffOpts" .DiffOpts }}
</div>
-
<div class="sticky top-0 flex-grow max-h-screen">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/diffChangedFiles" .Diff }}
</div>
{{end}}
-4
appview/pages/templates/repo/empty.html
···
{{ end }}
</main>
{{ end }}
-
-
{{ define "repoAfter" }}
-
{{ template "repo/fragments/cloneInstructions" . }}
-
{{ 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>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
···
+
{{ define "repo/fragments/cloneDropdown" }}
+
{{ $knot := .RepoInfo.Knot }}
+
{{ if eq $knot "knot1.tangled.sh" }}
+
{{ $knot = "tangled.sh" }}
+
{{ end }}
+
+
<details id="clone-dropdown" class="relative inline-block text-left group">
+
<summary class="btn-create cursor-pointer list-none flex items-center gap-2">
+
{{ i "download" "w-4 h-4" }}
+
<span class="hidden md:inline">code</span>
+
<span class="group-open:hidden">
+
{{ i "chevron-down" "w-4 h-4" }}
+
</span>
+
<span class="hidden group-open:flex">
+
{{ i "chevron-up" "w-4 h-4" }}
+
</span>
+
</summary>
+
+
<div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]">
+
<div class="p-4">
+
<div class="mb-3">
+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3>
+
</div>
+
+
<!-- HTTPS Clone -->
+
<div class="mb-3">
+
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label>
+
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
+
<code
+
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
+
onclick="window.getSelection().selectAllChildren(this)"
+
data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
+
>https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
+
<button
+
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
+
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+
title="Copy to clipboard"
+
>
+
{{ i "copy" "w-4 h-4" }}
+
</button>
+
</div>
+
</div>
+
+
<!-- SSH Clone -->
+
<div class="mb-3">
+
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label>
+
<div class="flex items-center border border-gray-300 dark:border-gray-600 rounded">
+
<code
+
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
+
onclick="window.getSelection().selectAllChildren(this)"
+
data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}"
+
>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
+
<button
+
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
+
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+
title="Copy to clipboard"
+
>
+
{{ i "copy" "w-4 h-4" }}
+
</button>
+
</div>
+
</div>
+
+
<!-- Note for self-hosted -->
+
<p class="text-xs text-gray-500 dark:text-gray-400">
+
For self-hosted knots, clone URLs may differ based on your setup.
+
</p>
+
+
<!-- Download Archive -->
+
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700">
+
<a
+
href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}"
+
class="flex items-center gap-2 px-3 py-2 text-sm"
+
>
+
{{ i "download" "w-4 h-4" }}
+
Download tar.gz
+
</a>
+
</div>
+
+
</div>
+
</div>
+
</details>
+
+
<script>
+
function copyToClipboard(button, text) {
+
navigator.clipboard.writeText(text).then(() => {
+
const originalContent = button.innerHTML;
+
button.innerHTML = `{{ i "check" "w-4 h-4" }}`;
+
setTimeout(() => {
+
button.innerHTML = originalContent;
+
}, 2000);
+
});
+
}
+
+
// Close clone dropdown when clicking outside
+
document.addEventListener('click', function(event) {
+
const cloneDropdown = document.getElementById('clone-dropdown');
+
if (cloneDropdown && cloneDropdown.hasAttribute('open')) {
+
if (!cloneDropdown.contains(event.target)) {
+
cloneDropdown.removeAttribute('open');
+
}
+
}
+
});
+
</script>
+
{{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
···
-
{{ define "repo/fragments/cloneInstructions" }}
-
{{ $knot := .RepoInfo.Knot }}
-
{{ if eq $knot "knot1.tangled.sh" }}
-
{{ $knot = "tangled.sh" }}
-
{{ end }}
-
<section
-
class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
-
>
-
<div class="flex flex-col gap-2">
-
<strong>push</strong>
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
-
<code class="dark:text-gray-100"
-
>git remote add origin
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
-
>
-
</div>
-
</div>
-
-
<div class="flex flex-col gap-2">
-
<strong>clone</strong>
-
<div class="md:pl-4 flex flex-col gap-2">
-
<div class="flex items-center gap-3">
-
<span
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
-
>HTTP</span
-
>
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
-
<code class="dark:text-gray-100"
-
>git clone
-
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
-
>
-
</div>
-
</div>
-
-
<div class="flex items-center gap-3">
-
<span
-
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
-
>SSH</span
-
>
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
-
<code class="dark:text-gray-100"
-
>git clone
-
git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
-
>
-
</div>
-
</div>
-
</div>
-
</div>
-
-
<p class="py-2 text-gray-500 dark:text-gray-400">
-
Note that for self-hosted knots, clone URLs may be different based
-
on your setup.
-
</p>
-
</section>
-
{{ end }}
+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 }}
+
+4 -4
appview/pages/templates/repo/fragments/fileTree.html
···
<details open>
<summary class="cursor-pointer list-none pt-1">
<span class="tree-directory inline-flex items-center gap-2 ">
-
{{ i "folder" "size-4 fill-current" }}
-
<span class="filename text-black dark:text-white">{{ .Name }}</span>
+
{{ i "folder" "flex-shrink-0 size-4 fill-current" }}
+
<span class="filename truncate text-black dark:text-white">{{ .Name }}</span>
</span>
</summary>
<div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700">
···
</details>
{{ else if .Name }}
<div class="tree-file flex items-center gap-2 pt-1">
-
{{ i "file" "size-4" }}
-
<a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
+
{{ i "file" "flex-shrink-0 size-4" }}
+
<a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
</div>
{{ else }}
{{ range $child := .Children }}
+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>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
···
{{ define "repo/fragments/interdiffFiles" }}
{{ $fileTree := fileTree .AffectedFiles }}
-
<section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm">
+
<section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm">
<div class="diff-stat">
<div class="flex gap-2 items-center">
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
+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 }}
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
···
{{ define "repo/fragments/repoDescription" }}
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
{{ if .RepoInfo.Description }}
-
{{ .RepoInfo.Description }}
+
{{ .RepoInfo.Description | description }}
{{ else }}
<span class="italic">this repo has no description</span>
{{ 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 }}
+
+
+116 -117
appview/pages/templates/repo/index.html
···
{{ end }}
<div class="flex items-center justify-between pb-5">
{{ block "branchSelector" . }}{{ end }}
-
<div class="flex md:hidden items-center gap-4">
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1">
+
<div class="flex md:hidden items-center gap-2">
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold">
{{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }}
</a>
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1">
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold">
{{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }}
</a>
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1">
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold">
{{ i "tags" "w-4" "h-4" }} {{ len .Tags }}
</a>
+
{{ template "repo/fragments/cloneDropdown" . }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
···
{{ 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 }}%'
-
class="h-[4px] rounded-full"
-
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
-
></div>
+
<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
+
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 items-stretch justify-center">
-
<select
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
-
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
-
>
-
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
-
{{ range .Branches }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if eq .Reference.Name $.Ref }}
-
selected
-
{{ end }}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</optgroup>
-
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
-
{{ range .Tags }}
-
<option
-
value="{{ .Reference.Name }}"
-
class="py-1"
-
{{ if eq .Reference.Name $.Ref }}
-
selected
-
{{ end }}
-
>
-
{{ .Reference.Name }}
-
</option>
-
{{ else }}
-
<option class="py-1" disabled>no tags found</option>
-
{{ end }}
-
</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 }}
+
<div class="flex gap-2 items-center justify-between w-full">
+
<div class="flex gap-2 items-center">
+
<select
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
+
class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
+
>
+
<optgroup label="branches ({{len .Branches}})" class="bold text-sm">
+
{{ range .Branches }}
+
<option
+
value="{{ .Reference.Name }}"
+
class="py-1"
+
{{ if eq .Reference.Name $.Ref }}
+
selected
+
{{ end }}
+
>
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</optgroup>
+
<optgroup label="tags ({{len .Tags}})" class="bold text-sm">
+
{{ range .Tags }}
+
<option
+
value="{{ .Reference.Name }}"
+
class="py-1"
+
{{ if eq .Reference.Name $.Ref }}
+
selected
+
{{ end }}
+
>
+
{{ .Reference.Name }}
+
</option>
+
{{ else }}
+
<option class="py-1" disabled>no tags found</option>
+
{{ end }}
+
</optgroup>
+
</select>
+
<div class="flex items-center gap-2">
+
<a
+
href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}"
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
+
title="Compare branches or tags"
+
>
+
{{ i "git-compare" "w-4 h-4" }}
+
</a>
+
</div>
+
</div>
-
<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"
-
title="Compare branches or tags"
-
>
-
{{ i "git-compare" "w-4 h-4" }}
-
</a>
+
<!-- Clone dropdown in top right -->
+
<div class="hidden md:flex items-center ">
+
{{ template "repo/fragments/cloneDropdown" . }}
</div>
-
</div>
+
</div>
{{ end }}
{{ define "fileTree" }}
···
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
{{ range .Files }}
-
<div class="grid grid-cols-2 gap-4 items-center py-1">
-
<div class="col-span-1">
+
<div class="grid grid-cols-3 gap-4 items-center py-1">
+
<div class="col-span-2">
{{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }}
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
···
{{ end }}
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
-
{{ i $icon $iconStyle }}{{ .Name }}
+
{{ i $icon $iconStyle "flex-shrink-0" }}
+
<span class="truncate">{{ .Name }}</span>
</div>
</a>
</div>
-
<div class="text-xs col-span-1 text-right">
+
<div class="text-sm col-span-1 text-right">
{{ with .LastCommit }}
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
{{ end }}
···
</div>
<!-- commit info bar -->
-
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center">
+
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap">
{{ $verified := $.VerifiedCommits.IsVerified .Hash.String }}
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
{{ if $verified }}
···
</a>
<div class="flex flex-col gap-1">
{{ range .BranchesTrunc }}
-
<div class="text-base flex items-center justify-between">
-
<div class="flex items-center gap-2">
+
<div class="text-base flex items-center justify-between overflow-hidden">
+
<div class="flex items-center gap-2 min-w-0 flex-1">
<a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}"
-
class="inline no-underline hover:underline dark:text-white">
+
class="inline-block truncate no-underline hover:underline dark:text-white">
{{ .Reference.Name }}
</a>
{{ if .Commit }}
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
-
<span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
+
<span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span>
{{ end }}
{{ if .IsDefault }}
-
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span>
+
<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span>
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span>
{{ end }}
</div>
{{ if ne $.Ref .Reference.Name }}
<a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}"
-
class="text-xs flex gap-2 items-center"
+
class="text-xs flex gap-2 items-center shrink-0 ml-2"
title="Compare branches or tags">
{{ i "git-compare" "w-3 h-3" }} compare
</a>
-
{{end}}
+
{{ end }}
</div>
{{ end }}
</div>
···
{{ 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 -}}
-
-
{{ template "repo/fragments/cloneInstructions" . }}
{{ 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 }}
-59
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">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
{{ template "user/fragments/picHandleLink" $owner }}
-
-
<!-- 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 }}
-
<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.IssueAt)
-
}}
-
{{ 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 "DidHandleMap" $.DidHandleMap "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 -45
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 }}
-
<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">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
{{ template "user/fragments/picHandleLink" $owner }}
-
</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/pipelines/fragments/pipelineSymbol.html
···
</div>
{{ else if $allFail }}
<div class="flex gap-1 items-center">
-
{{ i "x" "size-4 text-red-600" }}
+
{{ i "x" "size-4 text-red-500" }}
<span>0/{{ $total }}</span>
</div>
{{ else if $allTimeout }}
<div class="flex gap-1 items-center">
-
{{ i "clock-alert" "size-4 text-orange-400" }}
+
{{ i "clock-alert" "size-4 text-orange-500" }}
<span>0/{{ $total }}</span>
</div>
{{ else }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
···
{{ $color = "text-gray-600 dark:text-gray-500" }}
{{ else if eq $kind "timeout" }}
{{ $icon = "clock-alert" }}
-
{{ $color = "text-orange-400 dark:text-orange-300" }}
+
{{ $color = "text-orange-400 dark:text-orange-500" }}
{{ else }}
{{ $icon = "x" }}
{{ $color = "text-red-600 dark:text-red-500" }}
+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>
+3 -3
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
{{ define "repo/pulls/fragments/pullHeader" }}
<header class="pb-4">
<h1 class="text-2xl dark:text-white">
-
{{ .Pull.Title }}
+
{{ .Pull.Title | description }}
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
</h1>
</header>
···
</div>
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
opened by
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
-
{{ template "user/fragments/picHandleLink" $owner }}
+
{{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
<span class="select-none before:content-['\00B7']"></span>
{{ template "repo/fragments/time" .Pull.Created }}
···
<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>
+2 -2
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 }}
···
</div>
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
-
{{ .Title }}
+
{{ .Title | description }}
</span>
</div>
+3 -3
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 }}
···
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
{{ template "repo/fragments/diffOpts" .DiffOpts }}
</div>
-
<div class="sticky top-0 flex-grow max-h-screen">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
</div>
{{end}}
+3 -3
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 }}
···
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
{{ template "repo/fragments/diffOpts" .DiffOpts }}
</div>
-
<div class="sticky top-0 flex-grow max-h-screen">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/diffChangedFiles" .Diff }}
</div>
{{end}}
+4 -5
appview/pages/templates/repo/pulls/pull.html
···
<!-- round summary -->
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
<span class="gap-1 flex items-center">
-
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
+
{{ $owner := resolve $.Pull.OwnerDid }}
{{ $re := "re" }}
{{ if eq .RoundNumber 0 }}
{{ $re = "" }}
{{ end }}
<span class="hidden md:inline">{{$re}}submitted</span>
-
by {{ template "user/fragments/picHandleLink" $owner }}
+
by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }}
<span class="select-none before:content-['\00B7']"></span>
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a>
<span class="select-none before:content-['ยท']"></span>
···
{{ end }}
</div>
<div class="flex items-center">
-
<span>{{ .Title }}</span>
+
<span>{{ .Title | description }}</span>
{{ if gt (len .Body) 0 }}
<button
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
···
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
{{ end }}
<div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
-
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
-
{{ template "user/fragments/picHandleLink" $owner }}
+
{{ template "user/fragments/picHandleLink" $c.OwnerDid }}
<span class="before:content-['ยท']"></span>
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a>
</div>
+3 -4
appview/pages/templates/repo/pulls/pulls.html
···
<div class="px-6 py-4 z-5">
<div class="pb-2">
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white">
-
{{ .Title }}
+
{{ .Title | description }}
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
</a>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
{{ $icon := "ban" }}
···
</span>
<span class="ml-1">
-
{{ template "user/fragments/picHandleLink" $owner }}
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
</span>
<span class="before:content-['ยท']">
···
<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" }}
+8 -8
appview/pages/templates/repo/tree.html
···
<div class="flex flex-col md:flex-row md:justify-between gap-2">
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
{{ range .BreadCrumbs }}
-
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> /
+
<a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> /
{{ end }}
</div>
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
{{ $stats := .TreeStats }}
-
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
+
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span>
{{ if eq $stats.NumFolders 1 }}
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
<span>{{ $stats.NumFolders }} folder</span>
···
{{ range .Files }}
<div class="grid grid-cols-12 gap-4 items-center py-1">
-
<div class="col-span-6 md:col-span-3">
-
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }}
+
<div class="col-span-8 md:col-span-4">
+
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }}
{{ $icon := "folder" }}
{{ $iconStyle := "size-4 fill-current" }}
{{ if .IsFile }}
{{ $icon = "file" }}
-
{{ $iconStyle = "flex-shrink-0 size-4" }}
+
{{ $iconStyle = "size-4" }}
{{ end }}
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
-
{{ i $icon $iconStyle }}
+
{{ i $icon $iconStyle "flex-shrink-0" }}
<span class="truncate">{{ .Name }}</span>
</div>
</a>
</div>
-
<div class="col-span-0 md:col-span-7 hidden md:block overflow-hidden">
+
<div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden">
{{ with .LastCommit }}
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a>
{{ end }}
</div>
-
<div class="col-span-6 md:col-span-2 text-right">
+
<div class="col-span-4 md:col-span-2 text-sm text-right">
{{ with .LastCommit }}
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a>
{{ end }}
-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 -4
appview/pages/templates/spindles/dashboard.html
···
<div>
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
-
{{ i "user" "size-4" }}
-
{{ $user := index $.DidHandleMap . }}
-
<a href="/{{ $user }}">{{ $user }}</a>
+
{{ template "user/fragments/picHandleLink" . }}
</div>
{{ if ne $.LoggedInUser.Did . }}
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
···
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
hx-swap="none"
hx-vals='{"member": "{{$member}}" }'
-
hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?"
+
hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
>
{{ i "user-minus" "w-4 h-4" }}
remove
+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" }}
+3 -2
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"
···
name="content"
id="content-textarea"
wrap="off"
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono"
rows="20"
+
spellcheck="false"
placeholder="Paste your string here!"
required>{{ .String.Contents }}</textarea>
<div class="flex justify-between items-center">
-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" }}
+2 -6
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>
···
{{ end }}
</div>
</div>
-
<div class="overflow-auto relative">
+
<div class="overflow-x-auto overflow-y-hidden relative">
{{ if .ShowRendered }}
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
{{ else }}
+61
appview/pages/templates/strings/timeline.html
···
+
{{ define "title" }} all strings {{ end }}
+
+
{{ define "content" }}
+
{{ block "timeline" $ }}{{ end }}
+
{{ end }}
+
+
{{ define "timeline" }}
+
<div>
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">All strings</p>
+
</div>
+
+
<div class="flex flex-col gap-4">
+
{{ range $i, $s := .Strings }}
+
<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 }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "stringCard" $s }}
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "stringCard" }}
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
+
<div class="font-medium dark:text-white flex gap-2 items-center">
+
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
+
</div>
+
{{ with .Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
+
{{ . }}
+
</div>
+
{{ end }}
+
+
{{ template "stringCardInfo" . }}
+
</div>
+
{{ end }}
+
+
{{ define "stringCardInfo" }}
+
{{ $stat := .Stats }}
+
{{ $resolved := resolve .Did.String }}
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
+
<a href="/strings/{{ $resolved }}" class="flex items-center">
+
{{ template "user/fragments/picHandle" $resolved }}
+
</a>
+
<span class="select-none [&:before]:content-['ยท']"></span>
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
+
<span class="select-none [&:before]:content-['ยท']"></span>
+
{{ with .Edited }}
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
+
{{ end }}
+
</div>
+
{{ 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 }}
-161
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 := index $root.DidHandleMap $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" $userHandle }}
-
{{ with $source }}
-
forked
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline">
-
{{ index $root.DidHandleMap .Did }}/{{ .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 := index $root.DidHandleMap .StarredByDid }}
-
{{ $repoOwnerHandle := index $root.DidHandleMap .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 := index $root.DidHandleMap $follow.UserDid }}
-
{{ $subjectHandle := index $root.DidHandleMap $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>
+1 -1
appview/pages/templates/user/fragments/editPins.html
···
<input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}>
<label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full">
<div class="flex justify-between items-center w-full">
-
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span>
+
<span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span>
<div class="flex gap-1 items-center">
{{ i "star" "size-4 fill-current" }}
<span>{{ .RepoStats.StarCount }}</span>
+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 }}
+3 -2
appview/pages/templates/user/fragments/picHandleLink.html
···
{{ define "user/fragments/picHandleLink" }}
-
<a href="/{{ . }}" class="flex items-center">
-
{{ template "user/fragments/picHandle" . }}
+
{{ $resolved := resolve . }}
+
<a href="/{{ $resolved }}" class="flex items-center">
+
{{ template "user/fragments/picHandle" $resolved }}
</a>
{{ end }}
+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 }}
+15 -21
appview/pages/templates/user/fragments/repoCard.html
···
{{ $fullName := index . 2 }}
{{ with $repo }}
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
-
<div class="font-medium dark:text-white flex gap-2 items-center">
+
<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" }}
+
{{ else }}
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
+
{{ end }}
+
+
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"
-
>{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a
-
>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"
-
>{{ .Name }}</a
-
>
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
{{- end -}}
</div>
-
{{ if .Source }}
-
<div class="flex items-center my-0.5 text-sm">
-
{{ i "git-fork" "w-3 h-3 mr-1" }}
-
fork
-
</div>
-
{{ end }}
{{ 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 }}
···
{{ end }}
{{ define "repoStats" }}
-
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
+
<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 }}
+3 -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.
···
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
</span>
</div>
+
<input type="hidden" name="return_url" value="{{ .ReturnUrl }}">
<button
class="btn w-full my-2 mt-6 text-base "
+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 }}
+
-325
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" (list .RepoEvents $.DidHandleMap) }} {{ end }}
-
{{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }}
-
{{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ 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" }}
-
{{ $items := index . 0 }}
-
{{ $handleMap := index . 1 }}
-
-
{{ 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 "book-plus" "w-4 h-4" }}
-
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
-
</div>
-
</summary>
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
-
{{ range $items }}
-
<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="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
-
{{- .Repo.Name -}}
-
</a>
-
</div>
-
{{ end }}
-
</div>
-
</details>
-
{{ end }}
-
{{ end }}
-
-
{{ define "issueEvents" }}
-
{{ $i := index . 0 }}
-
{{ $items := $i.Items }}
-
{{ $stats := $i.Stats }}
-
{{ $handleMap := index . 1 }}
-
-
{{ 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 := index $handleMap .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" }}
-
{{ $i := index . 0 }}
-
{{ $items := $i.Items }}
-
{{ $stats := $i.Stats }}
-
{{ $handleMap := index . 1 }}
-
{{ 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 := index $handleMap .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(),
+389 -270
appview/pulls/pulls.go
···
"encoding/json"
"errors"
"fmt"
-
"io"
"log"
"net/http"
"sort"
···
"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/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"
-
"github.com/bluesky-social/indigo/atproto/syntax"
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{
···
}
}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
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)
···
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
RepoInfo: repoInfo,
-
DidHandleMap: didHandleMap,
Pull: pull,
Stack: stack,
AbandonedPulls: abandonedPulls,
···
})
}
-
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.RepoName, 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,
+
}
+
+
if resp.Message != nil {
+
result.Message = *resp.Message
}
-
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.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
}
···
// pulls within the same repo
knot = f.Knot
ownerDid = f.OwnerDid()
-
repoName = f.RepoName
+
repoName = f.Name
}
-
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
-
return pages.Unknown
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
+
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
return pages.Unknown
+
}
log.Println("failed to reach knotserver", err)
return pages.Unknown
}
+
targetBranch := branchResp
+
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
if pull.IsStacked() && stack != nil {
···
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
}
-
if latestSourceRev != result.Branch.Hash {
+
if latestSourceRev != targetBranch.Hash {
return pages.ShouldResubmit
}
···
return
}
-
identsToResolve := []string{pull.OwnerDid}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
patch := pull.Submissions[roundIdInt].Patch
diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
LoggedInUser: user,
-
DidHandleMap: didHandleMap,
RepoInfo: f.RepoInfo(user),
Pull: pull,
Stack: stack,
···
return
}
-
identsToResolve := []string{pull.OwnerDid}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
if err != nil {
log.Println("failed to interdiff; current patch malformed")
···
RepoInfo: f.RepoInfo(user),
Pull: pull,
Round: roundIdInt,
-
DidHandleMap: didHandleMap,
Interdiff: interdiff,
DiffOpts: diffOpts,
})
···
return
}
-
identsToResolve := []string{pull.OwnerDid}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
}
···
pulls, err := db.GetPulls(
s.db,
-
db.FilterEq("repo_at", f.RepoAt),
+
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("state", state),
)
if err != nil {
···
m[p.Sha] = p
}
-
identsToResolve := make([]string, len(pulls))
-
for i, pull := range pulls {
-
identsToResolve[i] = pull.OwnerDid
-
}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
s.pages.RepoPulls(w, pages.RepoPullsParams{
LoggedInUser: s.oauth.GetUser(r),
RepoInfo: f.RepoInfo(user),
Pulls: pulls,
-
DidHandleMap: didHandleMap,
FilteringBy: state,
Stacks: stacks,
Pipelines: m,
···
defer tx.Rollback()
createdAt := time.Now().Format(time.RFC3339)
-
ownerDid := user.Did
-
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
if err != nil {
log.Println("failed to get pull at", err)
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
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,
},
···
comment := &db.PullComment{
OwnerDid: user.Did,
-
RepoAt: f.RepoAt.String(),
+
RepoAt: f.RepoAt().String(),
PullId: pull.PullId,
Body: body,
CommentAt: atResp.Uri,
···
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.RepoName)
-
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
}
···
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
return
}
+
sanitizer := markup.NewSanitizer()
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
+
s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
+
return
+
}
}
// Validate we have at least one valid PR creation method
···
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.RepoName, 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
+
}
+
sourceRev := comparison.Rev2
patch := comparison.Patch
···
}
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
-
}
+
client, err := s.oauth.ServiceClient(
+
r,
+
oauth.WithService(fork.Knot),
+
oauth.WithLxm(tangled.RepoHiddenRefNSID),
+
oauth.WithDev(s.config.Core.Dev),
+
)
-
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.")
+
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
}
-
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
resp, err := 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.")
-
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
}
···
return
}
-
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
-
if err != nil {
-
log.Println("failed to parse fork AT URI", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
+
forkAtUri := fork.RepoAt()
+
forkAtUriStr := forkAtUri.String()
pullSource := &db.PullSource{
Branch: sourceBranch,
···
}
recordPullSource := &tangled.RepoPull_Source{
Branch: sourceBranch,
-
Repo: &fork.AtUri,
+
Repo: &forkAtUriStr,
Sha: sourceRev,
}
···
Body: body,
TargetBranch: targetBranch,
OwnerDid: user.Did,
-
RepoAt: f.RepoAt,
+
RepoAt: f.RepoAt(),
Rkey: rkey,
Submissions: []*db.PullSubmission{
&initialSubmission,
···
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
return
-
pullId, err := db.NextPullId(tx, f.RepoAt)
+
pullId, err := db.NextPullId(tx, f.RepoAt())
if err != nil {
log.Println("failed to get pull id", err)
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···
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.RepoName)
-
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)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", repo.Knot)
-
s.pages.Error503(w)
-
return
+
sourceScheme := "http"
+
if !s.config.Core.Dev {
+
sourceScheme = "https"
+
}
+
sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot)
+
sourceXrpcc := &indigoxrpc.Client{
+
Host: sourceHost,
-
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
+
sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name)
+
sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo)
if err != nil {
-
log.Println("failed to reach knotserver for source branches", err)
+
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
-
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
+
// 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
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
+
targetScheme := "http"
+
if !s.config.Core.Dev {
+
targetScheme = "https"
+
}
+
targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
+
targetXrpcc := &indigoxrpc.Client{
+
Host: targetHost,
+
}
+
+
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
if err != nil {
-
log.Println("failed to reach knotserver for target branches", err)
+
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
-
sourceBranches := sourceResult.Branches
-
sort.Slice(sourceBranches, func(i int, j int) bool {
-
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
+
// 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
+
}
+
+
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.RepoName, 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,
},
},
})
···
// deleted pulls are marked as deleted in the DB
for _, p := range deletions {
+
// do not do delete already merged PRs
+
if p.State == db.PullMerged {
+
continue
+
}
+
err := db.DeletePull(tx, p.RepoAt, p.PullId)
if err != nil {
log.Println("failed to delete pull", err, p.PullId)
···
for id := range updated {
op, _ := origById[id]
np, _ := newById[id]
+
+
// do not update already merged PRs
+
if op.State == db.PullMerged {
+
continue
+
}
submission := np.Submissions[np.LastRoundNumber()]
···
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.RepoName, 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
···
defer tx.Rollback()
for _, p := range pullsToMerge {
-
err := db.MergePull(tx, f.RepoAt, p.PullId)
+
err := db.MergePull(tx, f.RepoAt(), p.PullId)
if err != nil {
log.Printf("failed to update pull request status in database: %s", err)
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
···
return
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
···
for _, p := range pullsToClose {
// Close the pull in the database
-
err = db.ClosePull(tx, f.RepoAt, p.PullId)
+
err = db.ClosePull(tx, f.RepoAt(), p.PullId)
if err != nil {
log.Println("failed to close pull", err)
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
for _, p := range pullsToReopen {
// Close the pull in the database
-
err = db.ReopenPull(tx, f.RepoAt, p.PullId)
+
err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
if err != nil {
log.Println("failed to close pull", err)
s.pages.Notice(w, "pull-close", "Failed to close pull.")
···
Body: body,
TargetBranch: targetBranch,
OwnerDid: user.Did,
-
RepoAt: f.RepoAt,
+
RepoAt: f.RepoAt(),
Rkey: rkey,
Submissions: []*db.PullSubmission{
&initialSubmission,
+31 -13
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")
···
Artifact: uploadBlobResp.Blob,
CreatedAt: createdAt.Format(time.RFC3339),
Name: handler.Filename,
-
Repo: f.RepoAt.String(),
+
Repo: f.RepoAt().String(),
Tag: tag.Tag.Hash[:],
},
},
···
artifact := db.Artifact{
Did: user.Did,
Rkey: rkey,
-
RepoAt: f.RepoAt,
+
RepoAt: f.RepoAt(),
Tag: tag.Tag.Hash,
CreatedAt: createdAt,
BlobCid: cid.Cid(uploadBlobResp.Blob.Ref),
···
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")
···
artifacts, err := db.GetArtifact(
rp.db,
-
db.FilterEq("repo_at", f.RepoAt),
+
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("tag", tag.Tag.Hash[:]),
db.FilterEq("name", filename),
)
···
artifacts, err := db.GetArtifact(
rp.db,
-
db.FilterEq("repo_at", f.RepoAt),
+
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("tag", tag[:]),
db.FilterEq("name", filename),
)
···
defer tx.Rollback()
err = db.DeleteArtifact(tx,
-
db.FilterEq("repo_at", f.RepoAt),
+
db.FilterEq("repo_at", f.RepoAt()),
db.FilterEq("tag", artifact.Tag[:]),
db.FilterEq("name", filename),
)
···
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.RepoName)
+
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))
+
}
+198 -101
appview/repo/index.go
···
package repo
import (
-
"encoding/json"
+
"errors"
"fmt"
"log"
"net/http"
+
"net/url"
"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"
···
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to fully resolve repo", err)
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.RepoName, ref)
-
if err != nil {
+
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
+
}
+
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
+
log.Println("failed to build index response", err)
return
}
···
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, 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, chi.URLParam(r, "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) {
// first attempt to fetch from db
langs, err := db.GetRepoLanguages(
rp.db,
-
db.FilterEq("repo_at", f.RepoAt),
-
db.FilterEq("ref", f.Ref),
+
db.FilterEq("repo_at", f.RepoAt()),
+
db.FilterEq("ref", currentRef),
)
if err != nil || langs == nil {
-
// non-fatal, fetch langs from ks
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref)
+
// 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: f.Ref,
+
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,
-
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)
+
+
// first get branches to determine the ref if not specified
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
+
if err != nil {
+
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
}
-
forkInfo := types.ForkInfo{
-
IsFork: repoInfo.Source != nil,
-
Status: types.UpToDate,
+
var branchesResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal branches response: %w", err)
}
-
if !forkInfo.IsFork {
-
forkInfo.IsFork = false
-
return &forkInfo, nil
+
// if no ref specified, use default branch or first available
+
if ref == "" {
+
for _, branch := range branchesResp.Branches {
+
if branch.IsDefault {
+
ref = branch.Name
+
break
+
}
+
}
}
-
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
-
return nil, err
+
// if ref is still empty, this means the default branch is not set
+
if ref == "" {
+
return &types.RepoIndexResponse{
+
IsEmpty: true,
+
Branches: branchesResp.Branches,
+
}, nil
}
-
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return nil, err
-
}
+
// 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, fmt.Errorf("failed to call repoTags: %w", err))
+
return
+
}
+
+
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", 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, fmt.Errorf("failed to call repoTree: %w", 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, fmt.Errorf("failed to call repoLog: %w", err))
+
return
+
}
+
+
if err := json.Unmarshal(logBytes, &logResp); err != nil {
+
errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", 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 !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
-
return branch.Name == f.Ref
-
}) {
-
forkInfo.Status = types.MissingBranch
-
return &forkInfo, nil
-
}
+
if blobResp == nil {
+
continue
+
}
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
-
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
-
log.Printf("failed to update tracking branch: %s", err)
-
return nil, err
-
}
+
readmeContent = blobResp.Content
+
readmeFileName = filename
+
break
+
}
+
}()
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
+
wg.Wait()
-
var status types.AncestorCheckResponse
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
-
if err != nil {
-
log.Printf("failed to check if fork is ahead/behind: %s", err)
-
return nil, err
+
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
}
+618 -369
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(
···
}
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
-
refParam := chi.URLParam(r, "ref")
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
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,
}
-
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam))
-
http.Redirect(w, r, url, http.StatusFound)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
+
// Set headers for file download, just pass along whatever the knot specifies
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
+
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)))
+
+
// Write the archive data directly
+
w.Write(archiveBytes)
}
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
···
}
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.log", xrpcerr)
+
rp.pages.Error503(w)
return
}
-
repolog, err := us.Log(f.OwnerDid(), f.RepoName, 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.RepoName)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
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.RepoName)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
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
}
-
repoAt := f.RepoAt
+
repoAt := f.RepoAt()
rkey := repoAt.RecordKey().String()
if rkey == "" {
log.Println("invalid aturi for repo", err)
···
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.Repo{
Knot: f.Knot,
-
Name: f.RepoName,
+
Name: f.Name,
Owner: user.Did,
-
CreatedAt: f.CreatedAt,
+
CreatedAt: f.Created.Format(time.RFC3339),
Description: &newDescription,
Spindle: &f.Spindle,
},
···
return
}
ref := chi.URLParam(r, "ref")
-
protocol := "http"
-
if !rp.config.Core.Dev {
-
protocol = "https"
-
}
+
ref, _ = url.PathUnescape(ref)
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.RepoName, 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)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
+
rp.pages.Error503(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")
+
ref, _ = url.PathUnescape(ref)
+
+
// if the tree path has a trailing slash, let's strip it
+
// so we don't 404
treePath := chi.URLParam(r, "*")
-
protocol := "http"
+
treePath, _ = url.PathUnescape(treePath)
+
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.RepoName, ref, treePath))
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
+
rp.pages.Error503(w)
return
}
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", 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,
}
-
var result types.RepoTreeResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
return
+
if xrpcResp.Parent != nil {
+
result.Parent = *xrpcResp.Parent
+
}
+
if xrpcResp.Dotdot != nil {
+
result.DotDot = *xrpcResp.Dotdot
}
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
// so we can safely redirect to the "parent" (which is the same file).
-
unescapedTreePath, _ := url.PathUnescape(treePath)
-
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
-
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
+
if len(result.Files) == 0 && result.Parent == treePath {
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
+
http.Redirect(w, r, redirectTo, http.StatusFound)
return
}
user := rp.oauth.GetUser(r)
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
if treePath != "" {
for idx, elem := range strings.Split(treePath, "/") {
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
}
}
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
return
}
-
result, err := us.Tags(f.OwnerDid(), f.RepoName)
-
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
}
-
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt))
+
artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt()))
if err != nil {
log.Println("failed grab artifacts", err)
return
···
rp.pages.RepoTags(w, pages.RepoTagsParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
-
RepoTagsResponse: *result,
+
RepoTagsResponse: result,
ArtifactMap: artifactMap,
DanglingArtifacts: danglingArtifacts,
})
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
return
}
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
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")
+
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
-
protocol := "http"
+
filePath, _ = url.PathUnescape(filePath)
+
+
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.RepoName, 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)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
+
rp.pages.Error503(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.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
if filePath != "" {
for idx, elem := range strings.Split(filePath, "/") {
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
}
}
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/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
+
}
+
query := baseURL.Query()
+
query.Set("repo", repoName)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
+
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")
+
ref, _ = url.PathUnescape(ref)
+
filePath := chi.URLParam(r, "*")
+
filePath, _ = url.PathUnescape(filePath)
-
protocol := "http"
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
baseURL := &url.URL{
+
Scheme: scheme,
+
Host: f.Knot,
+
Path: "/xrpc/sh.tangled.repo.blob",
}
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
-
resp, err := http.Get(blobURL)
+
query := baseURL.Query()
+
query.Set("repo", repo)
+
query.Set("ref", ref)
+
query.Set("path", filePath)
+
query.Set("raw", "true")
+
baseURL.RawQuery = query.Encode()
+
blobURL := baseURL.String()
+
+
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)
···
return
}
-
repoAt := f.RepoAt
+
repoAt := f.RepoAt()
rkey := repoAt.RecordKey().String()
if rkey == "" {
fail("Failed to resolve repo. Try again later", err)
···
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.Repo{
Knot: f.Knot,
-
Name: f.RepoName,
+
Name: f.Name,
Owner: user.Did,
-
CreatedAt: f.CreatedAt,
+
CreatedAt: f.Created.Format(time.RFC3339),
Description: &f.Description,
Spindle: spindlePtr,
},
···
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoCollaborator{
Subject: collaboratorIdent.DID.String(),
-
Repo: string(f.RepoAt),
+
Repo: string(f.RepoAt()),
CreatedAt: createdAt.Format(time.RFC3339),
}},
})
···
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)
+
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
}
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
fail("Failed to add to knot.", err)
-
return
-
}
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rp.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, client)
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
-
if err != nil {
-
fail("Knot was unreachable.", err)
-
return
-
}
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
-
if ksResp.StatusCode != http.StatusNoContent {
-
fail(fmt.Sprintf("Knot returned unexpected status code: %d.", ksResp.StatusCode), nil)
-
return
+
if errs := errors.Join(err1, err2, err3); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
+
}
}
-
-
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)
-
}
-
}()
+
defer rollback()
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
if err != nil {
···
Did: syntax.DID(currentUser.Did),
Rkey: rkey,
SubjectDid: collaboratorIdent.DID,
-
RepoAt: f.RepoAt,
+
RepoAt: f.RepoAt(),
Created: createdAt,
})
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)
···
log.Println("failed to get authorized client", err)
return
}
-
repoRkey := f.RepoAt.RecordKey().String()
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
Collection: tangled.RepoNSID,
Repo: user.Did,
-
Rkey: repoRkey,
+
Rkey: f.Rkey,
})
if err != nil {
log.Printf("failed to delete record: %s", err)
-
rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
-
return
-
}
-
log.Println("removed repo record ", f.RepoAt.String())
-
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
-
if err != nil {
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
return
}
+
log.Println("removed repo record ", f.RepoAt().String())
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoDeleteNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
+
log.Println("failed to connect to knot server:", err)
return
}
-
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
-
if err != nil {
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
+
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
}
-
-
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.RepoName)
+
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)
-
return
-
}
-
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
-
return
-
}
-
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
-
if err != nil {
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
+
log.Println("failed to connect to knot server:", err)
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
return
-
if ksResp.StatusCode != http.StatusNoContent {
-
rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
+
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
-
w.Write(fmt.Append(nil, "default branch set to: ", branch))
+
rp.pages.HxRefresh(w)
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
···
r,
oauth.WithService(f.Spindle),
oauth.WithLxm(lxm),
+
oauth.WithExp(60),
oauth.WithDev(rp.config.Core.Dev),
if err != nil {
···
r.Context(),
spindleClient,
&tangled.RepoAddSecret_Input{
-
Repo: f.RepoAt.String(),
+
Repo: f.RepoAt().String(),
Key: key,
Value: value,
},
···
r.Context(),
spindleClient,
&tangled.RepoRemoveSecret_Input{
-
Repo: f.RepoAt.String(),
+
Repo: f.RepoAt().String(),
Key: key,
},
···
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.RepoName)
-
// 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)
-
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
return
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
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
···
r,
oauth.WithService(f.Spindle),
oauth.WithLxm(tangled.RepoListSecretsNSID),
+
oauth.WithExp(60),
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 {
+
} 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
···
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
+
ref := chi.URLParam(r, "ref")
+
ref, _ = url.PathUnescape(ref)
+
user := rp.oauth.GetUser(r)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
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.RepoName)
-
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
-
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.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.RepoName)
-
+
// 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.RepoName)
+
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// no existing repo with this name found, we can use the name as is
···
// 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
-
}
+
l = l.With("forkName", forkName)
-
client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
rp.pages.Notice(w, "repo", "Failed to reach knot server.")
-
return
-
}
-
-
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.RepoName)
-
sourceAt := f.RepoAt.String()
+
+
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()
-
repo.AtUri = atresp.Uri
+
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 {
log.Println(err)
···
// 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)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
+
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
rp.pages.Error503(w)
return
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
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.RepoName)
-
if err != nil {
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
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.")
-
log.Println("failed to reach knotserver", err)
return
···
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)
+
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 xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
rp.pages.Error503(w)
return
-
branches, err := us.Branches(f.OwnerDid(), f.RepoName)
-
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
+
}
+
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
return
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
-
if err != nil {
+
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.")
-
log.Println("failed to reach knotserver", err)
+
return
+
}
+
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
+
rp.pages.Error503(w)
return
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
-
if err != nil {
+
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.")
-
log.Println("failed to compare", err)
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)
+37 -104
appview/reporesolver/resolver.go
···
"fmt"
"log"
"net/http"
-
"net/url"
"path"
+
"regexp"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
-
"github.com/bluesky-social/indigo/atproto/syntax"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/appview/config"
···
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/rbac"
)
type ResolvedRepo struct {
-
Knot string
-
OwnerId identity.Identity
-
RepoName string
-
RepoAt syntax.ATURI
-
Description string
-
Spindle string
-
CreatedAt string
-
Ref string
-
CurrentDir string
+
db.Repo
+
OwnerId identity.Identity
+
CurrentDir string
+
Ref string
rr *RepoResolver
}
···
}
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
-
repoName := chi.URLParam(r, "repo")
-
knot, ok := r.Context().Value("knot").(string)
+
repo, ok := r.Context().Value("repo").(*db.Repo)
if !ok {
-
log.Println("malformed middleware")
+
log.Println("malformed middleware: `repo` not exist in context")
return nil, fmt.Errorf("malformed middleware")
}
id, ok := r.Context().Value("resolvedId").(identity.Identity)
···
return nil, fmt.Errorf("malformed middleware")
}
-
repoAt, ok := r.Context().Value("repoAt").(string)
-
if !ok {
-
log.Println("malformed middleware")
-
return nil, fmt.Errorf("malformed middleware")
-
}
-
-
parsedRepoAt, err := syntax.ParseATURI(repoAt)
-
if err != nil {
-
log.Println("malformed repo at-uri")
-
return nil, fmt.Errorf("malformed middleware")
-
}
-
+
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath()))
ref := chi.URLParam(r, "ref")
-
if ref == "" {
-
us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev)
-
if err != nil {
-
return nil, err
-
}
-
-
defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName)
-
if err != nil {
-
return nil, err
-
}
-
-
ref = defaultBranch.Branch
-
}
-
-
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
-
-
// pass through values from the middleware
-
description, ok := r.Context().Value("repoDescription").(string)
-
addedAt, ok := r.Context().Value("repoAddedAt").(string)
-
spindle, ok := r.Context().Value("repoSpindle").(string)
-
return &ResolvedRepo{
-
Knot: knot,
-
OwnerId: id,
-
RepoName: repoName,
-
RepoAt: parsedRepoAt,
-
Description: description,
-
CreatedAt: addedAt,
-
Ref: ref,
-
CurrentDir: currentDir,
-
Spindle: spindle,
+
Repo: *repo,
+
OwnerId: id,
+
CurrentDir: currentDir,
+
Ref: ref,
rr: rr,
}, nil
···
var p string
if handle != "" && !handle.IsInvalidHandle() {
-
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name)
} else {
-
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name)
}
-
return p
-
}
-
-
func (f *ResolvedRepo) DidSlashRepo() string {
-
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
return p
}
···
// this function is a bit weird since it now returns RepoInfo from an entirely different
// package. we should refactor this or get rid of RepoInfo entirely.
func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo {
+
repoAt := f.RepoAt()
isStarred := false
if user != nil {
-
isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt))
+
isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt)
}
-
starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt)
+
starCount, err := db.GetStarCount(f.rr.execer, repoAt)
if err != nil {
-
log.Println("failed to get star count for ", f.RepoAt)
+
log.Println("failed to get star count for ", repoAt)
}
-
issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt)
+
issueCount, err := db.GetIssueCount(f.rr.execer, repoAt)
if err != nil {
-
log.Println("failed to get issue count for ", f.RepoAt)
+
log.Println("failed to get issue count for ", repoAt)
}
-
pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt)
+
pullCount, err := db.GetPullCount(f.rr.execer, repoAt)
if err != nil {
-
log.Println("failed to get issue count for ", f.RepoAt)
+
log.Println("failed to get issue count for ", repoAt)
}
-
source, err := db.GetRepoSource(f.rr.execer, f.RepoAt)
+
source, err := db.GetRepoSource(f.rr.execer, repoAt)
if errors.Is(err, sql.ErrNoRows) {
source = ""
} else if err != nil {
-
log.Println("failed to get repo source for ", f.RepoAt, err)
+
log.Println("failed to get repo source for ", repoAt, err)
}
var sourceRepo *db.Repo
···
}
knot := f.Knot
-
var disableFork bool
-
us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s: %v", knot, err)
-
} else {
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
-
if err != nil {
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
-
}
-
-
if len(result.Branches) == 0 {
-
disableFork = true
-
}
-
}
repoInfo := repoinfo.RepoInfo{
OwnerDid: f.OwnerDid(),
OwnerHandle: f.OwnerHandle(),
-
Name: f.RepoName,
-
RepoAt: f.RepoAt,
+
Name: f.Name,
+
RepoAt: repoAt,
Description: f.Description,
-
Ref: f.Ref,
IsStarred: isStarred,
Knot: knot,
Spindle: f.Spindle,
···
IssueCount: issueCount,
PullCount: pullCount,
},
-
DisableFork: disableFork,
-
CurrentDir: f.CurrentDir,
+
CurrentDir: f.CurrentDir,
+
Ref: f.Ref,
}
if sourceRepo != nil {
···
// after the ref. for example:
//
// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/
-
func extractPathAfterRef(fullPath, ref string) string {
+
func extractPathAfterRef(fullPath string) string {
fullPath = strings.TrimPrefix(fullPath, "/")
-
ref = url.PathEscape(ref)
+
// match blob/, tree/, or raw/ followed by any ref and then a slash
+
//
+
// captures everything after the final slash
+
pattern := `(?:blob|tree|raw)/[^/]+/(.*)$`
-
prefixes := []string{
-
fmt.Sprintf("blob/%s/", ref),
-
fmt.Sprintf("tree/%s/", ref),
-
fmt.Sprintf("raw/%s/", ref),
-
}
+
re := regexp.MustCompile(pattern)
+
matches := re.FindStringSubmatch(fullPath)
-
for _, prefix := range prefixes {
-
idx := strings.Index(fullPath, prefix)
-
if idx != -1 {
-
return fullPath[idx+len(prefix):]
-
}
+
if len(matches) > 1 {
+
return matches[1]
}
return ""
+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 -22
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"
···
return
}
-
identsToResolve := make([]string, len(members))
-
copy(identsToResolve, members)
-
resolvedIds := s.IdResolver.ResolveIdents(r.Context(), identsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
// organize repos by did
repoMap := make(map[string][]db.Repo)
for _, r := range repos {
···
Spindle: spindle,
Members: members,
Repos: repoMap,
-
DidHandleMap: didHandleMap,
})
}
···
}
// 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{}{}
}
+374 -178
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, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get profile: %w", err)
+
}
+
+
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get repo count: %w", err)
+
}
+
+
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get string count: %w", err)
+
}
+
+
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
}
-
profile, err := db.GetProfile(s.db, ident.DID.String())
+
followStats, err := db.GetFollowerFollowingCount(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 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)
}
-
var didsToResolve []string
-
for _, r := range collaboratingRepos {
-
didsToResolve = append(didsToResolve, r.Did)
-
}
-
for _, byMonth := range timeline.ByMonth {
-
for _, pe := range byMonth.PullEvents.Items {
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
-
}
-
for _, ie := range byMonth.IssueEvents.Items {
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
-
}
-
for _, re := range byMonth.RepoEvents {
-
didsToResolve = append(didsToResolve, re.Repo.Did)
-
if re.Source != nil {
-
didsToResolve = append(didsToResolve, re.Source.Did)
-
}
-
}
-
}
+
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Card: profile,
+
Repos: pinnedRepos,
+
CollaboratingRepos: pinnedCollaboratingRepos,
+
ProfileTimeline: timeline,
+
})
+
}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "reposPage")
-
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
+
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,
-
DidHandleMap: didHandleMap,
-
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{}{}
+
}
+
}
+
+
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,
+
}
}
-
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
+
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,
-
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
-
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) 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) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed {
+
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)
}
···
})
}
-
var didsToResolve []string
-
for _, r := range allRepos {
-
didsToResolve = append(didsToResolve, r.Did)
-
}
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
s.pages.EditPinsFragment(w, pages.EditPinsParams{
LoggedInUser: user,
Profile: profile,
AllRepos: allRepos,
-
DidHandleMap: didHandleMap,
})
}
+21 -8
appview/state/router.go
···
s.pages,
)
+
router.Get("/favicon.svg", s.Favicon)
+
router.Get("/favicon.ico", s.Favicon)
+
+
userRouter := s.UserRouter(&middleware)
+
standardRouter := s.StandardRouter(&middleware)
+
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
pat := chi.URLParam(r, "*")
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
-
s.UserRouter(&middleware).ServeHTTP(w, r)
+
userRouter.ServeHTTP(w, r)
} else {
// Check if the first path element is a valid handle without '@' or a flattened DID
pathParts := strings.SplitN(pat, "/", 2)
···
return
}
}
-
s.StandardRouter(&middleware).ServeHTTP(w, r)
+
standardRouter.ServeHTTP(w, r)
}
})
···
r.Get("/", s.Profile)
r.Get("/feed.atom", s.AtomFeedPage)
+
// redirect /@handle/repo.git -> /@handle/repo
+
r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) {
+
nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git")
+
http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently)
+
})
+
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
r.Use(mw.GoImport())
-
r.Mount("/", s.RepoRouter(mw))
r.Mount("/issues", s.IssuesRouter(mw))
r.Mount("/pulls", s.PullsRouter(mw))
···
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)
}
+197 -70
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) {
···
return nil, fmt.Errorf("failed to create enforcer: %w", err)
}
-
pgs := pages.NewPages(config)
-
res, err := idresolver.RedisResolver(config.Redis.ToURL())
if err != nil {
log.Printf("failed to create redis resolver: %v", err)
res = idresolver.DefaultResolver()
}
+
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
+
w.Header().Set("ETag", `"favicon-svg-v1"`)
+
+
if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` {
+
w.WriteHeader(http.StatusNotModified)
+
return
+
}
+
+
s.pages.Favicon(w)
+
}
+
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
user := s.oauth.GetUser(r)
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
})
}
+
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.")
}
-
var didsToResolve []string
-
for _, ev := range timeline {
-
if ev.Repo != nil {
-
didsToResolve = append(didsToResolve, ev.Repo.Did)
-
if ev.Source != nil {
-
didsToResolve = append(didsToResolve, ev.Source.Did)
-
}
-
}
-
if ev.Follow != nil {
-
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
-
}
-
if ev.Star != nil {
-
didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did)
-
}
-
}
-
-
resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
+
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,
-
DidHandleMap: didHandleMap,
+
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,
})
+
}
-
return
+
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
+
timeline, err := db.MakeTimeline(s.db, 5)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
+
return
+
}
+
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
+
return
+
}
+
+
s.pages.Home(w, pages.TimelineParams{
+
LoggedInUser: nil,
+
Timeline: timeline,
+
Repos: repos,
+
})
}
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
···
for _, k := range pubKeys {
key := strings.TrimRight(k.Key, "\n")
-
w.Write([]byte(fmt.Sprintln(key)))
+
fmt.Fprintln(w, key)
}
}
···
return nil
}
+
func stripGitExt(name string) string {
+
return strings.TrimSuffix(name, ".git")
+
}
+
func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
···
})
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))
+
l.Info("repo exists")
+
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name 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.")
-
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
+
}
+
+
if errs := errors.Join(err1, err2, err3); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
}
-
}()
+
}
+
defer rollback()
-
resp, err := client.NewRepo(user.Did, repoName, defaultBranch)
+
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
}
-
repo.AtUri = atresp.Uri
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))
+
}
+
}
-
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
}
+23 -70
appview/strings/strings.go
···
"log/slog"
"net/http"
"path"
-
"slices"
"strconv"
-
"strings"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
···
r := chi.NewRouter()
r.
+
Get("/", s.timeline)
+
+
r.
With(mw.ResolveIdent()).
Route("/{user}", func(r chi.Router) {
r.Get("/", s.dashboard)
···
return r
}
+
func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "timeline")
+
+
strings, err := db.GetStrings(s.Db, 50)
+
if err != nil {
+
l.Error("failed to fetch string", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
+
s.Pages.StringsTimeline(w, pages.StringTimelineParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
Strings: strings,
+
})
+
}
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
l := s.Logger.With("handler", "contents")
···
strings, err := db.GetStrings(
s.Db,
+
0,
db.FilterEq("did", id.DID),
db.FilterEq("rkey", rkey),
)
···
}
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,
-
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) {
···
// get the string currently being edited
all, err := db.GetStrings(
s.Db,
+
0,
db.FilterEq("did", id.DID),
db.FilterEq("rkey", rkey),
)
···
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 -6
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.Pipeline_Dependency{},
tangled.Pipeline_ManualTriggerData{},
tangled.Pipeline_Pair{},
tangled.Pipeline_PullRequestTriggerData{},
tangled.Pipeline_PushTriggerData{},
tangled.PipelineStatus{},
-
tangled.Pipeline_Step{},
tangled.Pipeline_TriggerMetadata{},
tangled.Pipeline_TriggerRepo{},
tangled.Pipeline_Workflow{},
···
tangled.RepoPullComment{},
tangled.RepoPull_Source{},
tangled.RepoPullStatus{},
+
tangled.RepoPull_Target{},
tangled.Spindle{},
tangled.SpindleMember{},
tangled.String{},
+1 -1
cmd/punchcardPopulate/main.go
···
)
func main() {
-
db, err := sql.Open("sqlite3", "./appview.db")
+
db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1")
if err != nil {
log.Fatal("Failed to open database:", err)
}
+9 -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
```
···
- Don't include unrelated changes in the same commit.
- Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history
before submitting if necessary.
+
+
## code formatting
+
+
We use a variety of tools to format our code, and multiplex them with
+
[`treefmt`](https://treefmt.com): all you need to do to format your changes
+
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
## proposals for bigger changes
+65 -26
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).
-
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.
+
> 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).
+
+
</details>
-
You can now start a lightweight NixOS VM using
-
`nixos-shell` 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 .#vm
-
# or nixos-shell --flake .#vm
+
nix run --impure .#vm
-
# hit Ctrl-a + c + q to exit the VM
+
# type `poweroff` at the shell to exit the 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
-
-
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.
+
### running a spindle
-
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";
+
};
+
};
+
```
+
+140 -41
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.
-
* Dependencies can be specified from
-
[Nixpkgs](https://search.nixos.org) or custom registries.
-
* Environment variables can be set globally or per-step.
+
## Trigger
-
Here's an example that uses all fields:
+
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
-
# build_and_test.yaml
when:
-
- event: ["push", "pull_request"]
+
- event: ["push", "manual"]
branch: ["main", "develop"]
-
- event: ["manual"]
+
- 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
+
+
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: 1
+
submodules: false
+
```
+
+
## Dependencies
+
+
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.
+
+
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
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
+
```
+
+
Now these dependencies are available to use in your workflow!
+
+
## 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"
+
```
-
steps:
-
- name: "Install dependencies"
-
command: "npm install"
-
environment:
-
NODE_ENV: "development"
-
CI: "true"
+
## Steps
+
+
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.**
-
- name: "Run linter"
-
command: "npm run lint"
+
Example:
-
- name: "Run tests"
-
command: "npm test"
+
```yaml
+
steps:
+
- name: "Build backend"
+
command: "go build"
environment:
-
NODE_ENV: "test"
-
JEST_WORKERS: "2"
-
-
- name: "Build application"
+
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
+
+
dependencies:
+
# nixpkgs
+
nixpkgs:
+
- nodejs
+
- go
+
# custom registry
+
git+https://tangled.sh/@example.com/my_pkg:
+
- my_pkg
-
## git push options
+
environment:
+
GOOS: "linux"
+
GOARCH: "arm64"
+
NODE_ENV: "production"
+
MY_ENV_VAR: "MY_ENV_VALUE"
-
These are push options that can be used with the `--push-option (-o)` flag of git push:
+
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
eventconsumer/cursor/sqlite.go
···
}
func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) {
-
db, err := sql.Open("sqlite3", dbPath)
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
+55 -29
flake.nix
···
pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot;
pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped;
pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle;
+
+
treefmt-wrapper = pkgs.treefmt.withConfig {
+
settings.formatter = {
+
alejandra = {
+
command = pkgs.lib.getExe pkgs.alejandra;
+
includes = ["*.nix"];
+
};
+
+
gofmt = {
+
command = pkgs.lib.getExe' pkgs.go "gofmt";
+
options = ["-w"];
+
includes = ["*.go"];
+
};
+
+
# prettier = let
+
# wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} ''
+
# makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js"
+
# '';
+
# in {
+
# command = wrapper;
+
# options = ["-w"];
+
# includes = ["*.html"];
+
# # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120
+
# excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"];
+
# };
+
};
+
};
});
defaultPackage = forAllSystems (system: self.packages.${system}.appview);
-
formatter = forAllSystems (system: nixpkgsFor.${system}.alejandra);
devShells = forAllSystems (system: let
pkgs = nixpkgsFor.${system};
packages' = self.packages.${system};
···
pkgs.redis
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
packages'.lexgen
+
packages'.treefmt-wrapper
];
shellHook = ''
mkdir -p appview/pages/static
···
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
'';
in {
+
fmt = {
+
type = "app";
+
program = pkgs.lib.getExe packages'.treefmt-wrapper;
+
};
watch-appview = {
type = "app";
program = toString (pkgs.writeShellScript "watch-appview" ''
···
program = ''${tailwind-watcher}/bin/run'';
};
vm = let
-
system =
+
guestSystem =
if pkgs.stdenv.hostPlatform.isAarch64
-
then "aarch64"
-
else "x86_64";
-
-
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
-
patches =
-
(old.patches or [])
-
++ [
-
# https://github.com/Mic92/nixos-shell/pull/94
-
(pkgs.fetchpatch {
-
name = "fix-foreign-vm.patch";
-
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
-
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
-
})
-
];
-
});
+
then "aarch64-linux"
+
else "x86_64-linux";
in {
type = "app";
-
program = toString (pkgs.writeShellScript "vm" ''
-
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
-
'');
+
program =
+
(pkgs.writeShellApplication {
+
name = "launch-vm";
+
text = ''
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
+
cd "$rootDir"
+
+
mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs}
+
+
export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data"
+
exec ${pkgs.lib.getExe
+
(import ./nix/vm.nix {
+
inherit nixpkgs self;
+
system = guestSystem;
+
hostSystem = system;
+
}).config.system.build.vm}
+
'';
+
})
+
+ /bin/launch-vm;
};
gomod2nix = {
type = "app";
···
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/*
···
imports = [./nix/modules/spindle.nix];
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
-
};
-
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
-
inherit self nixpkgs;
-
system = "x86_64-linux";
-
};
-
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
-
inherit self nixpkgs;
-
system = "aarch64-linux";
};
};
}
+5 -2
go.mod
···
github.com/go-enry/go-enry/v2 v2.9.2
github.com/go-git/go-git/v5 v5.14.0
github.com/google/uuid v1.6.0
+
github.com/gorilla/feeds v1.2.0
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/hiddeco/sshsig v0.2.0
···
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.13
+
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
golang.org/x/sync v0.16.0
···
github.com/golang/mock v1.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
-
github.com/gorilla/feeds v1.2.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
···
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
+10 -1
go.sum
···
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
···
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 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+
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=
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+73 -8
input.css
···
@font-face {
font-family: "InterVariable";
src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2");
-
font-weight: 400;
+
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "InterVariable";
-
src: url("/static/fonts/InterVariable.woff2") format("woff2");
-
font-weight: 600;
+
src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2");
+
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
+
font-family: "InterVariable";
+
src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2");
+
font-weight: bold;
+
font-style: italic;
+
font-display: swap;
+
}
+
+
@font-face {
font-family: "IBMPlexMono";
src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
font-weight: normal;
+
font-style: normal;
+
font-display: swap;
+
}
+
+
@font-face {
+
font-family: "IBMPlexMono";
+
src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2");
+
font-weight: normal;
+
font-style: italic;
+
font-display: swap;
+
}
+
+
@font-face {
+
font-family: "IBMPlexMono";
+
src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2");
+
font-weight: bold;
+
font-style: normal;
+
font-display: swap;
+
}
+
+
@font-face {
+
font-family: "IBMPlexMono";
+
src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2");
+
font-weight: bold;
font-style: italic;
font-display: swap;
}
···
@supports (font-variation-settings: normal) {
html {
font-feature-settings:
-
"ss01" 1,
"kern" 1,
"liga" 1,
"cv05" 1,
···
}
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;
···
}
code {
-
@apply px-1 font-mono rounded bg-gray-100 dark:bg-gray-700;
+
@apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white;
}
}
···
disabled:before:bg-green-400 dark:disabled:before:bg-green-600;
}
+
.prose hr {
+
@apply my-2;
+
}
+
+
.prose li:has(input) {
+
@apply list-none;
+
}
+
+
.prose ul:has(input) {
+
@apply pl-2;
+
}
+
+
.prose .heading .anchor {
+
@apply no-underline mx-2 opacity-0;
+
}
+
+
.prose .heading:hover .anchor {
+
@apply opacity-70;
+
}
+
+
.prose .heading .anchor:hover {
+
@apply opacity-70;
+
}
+
+
.prose a.footnote-backref {
+
@apply no-underline;
+
}
+
+
.prose li {
+
@apply my-0 py-0;
+
}
+
+
.prose ul, .prose ol {
+
@apply my-1 py-0;
+
}
+
.prose img {
display: inline;
margin: 0;
···
/* PreWrapper */
.chroma {
color: #4c4f69;
-
background-color: #eff1f5;
}
/* Error */
.chroma .err {
···
/* PreWrapper */
.chroma {
color: #cad3f5;
-
background-color: #24273a;
}
/* Error */
.chroma .err {
+6 -4
jetstream/jetstream.go
···
type processor func(context.Context, *models.Event) error
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
-
// empty filter => all dids allowed
-
if len(j.wantedDids) == 0 {
-
return processFunc
-
}
// since this closure references j.WantedDids; it should auto-update
// existing instances of the closure when j.WantedDids is mutated
return func(ctx context.Context, evt *models.Event) error {
+
+
// empty filter => all dids allowed
+
if len(j.wantedDids) == 0 {
+
return processFunc(ctx, evt)
+
}
+
if _, ok := j.wantedDids[evt.Did]; ok {
return processFunc(ctx, evt)
} else {
-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"`
}
+14 -10
knotserver/db/init.go
···
import (
"database/sql"
+
"strings"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Setup(dbPath string) (*DB, error) {
-
db, err := sql.Open("sqlite3", dbPath)
+
// https://github.com/mattn/go-sqlite3#connection-string
+
opts := []string{
+
"_foreign_keys=1",
+
"_journal_mode=WAL",
+
"_synchronous=NORMAL",
+
"_auto_vacuum=incremental",
+
}
+
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
return nil, err
}
-
_, err = db.Exec(`
-
pragma journal_mode = WAL;
-
pragma synchronous = normal;
-
pragma foreign_keys = on;
-
pragma temp_store = memory;
-
pragma mmap_size = 30000000000;
-
pragma page_size = 32768;
-
pragma auto_vacuum = incremental;
-
pragma busy_timeout = 5000;
+
// NOTE: If any other migration is added here, you MUST
+
// copy the pattern in appview: use a single sql.Conn
+
// for every migration.
+
_, err = db.Exec(`
create table if not exists known_dids (
did text primary key
);
+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)
-
}
+8 -10
knotserver/git/fork.go
···
)
func Fork(repoPath, source string) error {
-
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
-
URL: source,
-
SingleBranch: false,
-
})
-
-
if err != nil {
+
cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath)
+
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to bare clone repository: %w", err)
}
-
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
-
if err != nil {
+
configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden")
+
if err := configureCmd.Run(); err != nil {
return fmt.Errorf("failed to configure hidden refs: %w", err)
}
return nil
}
-
func (g *GitRepo) Sync(branch string) error {
+
func (g *GitRepo) Sync() error {
+
branch := g.h.String()
+
fetchOpts := &git.FetchOptions{
RefSpecs: []config.RefSpec{
-
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)),
+
config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master
},
}
+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,
+
},
}
}
+9 -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")
···
// If the appview gave us the repository owner's handle we can attempt to
// construct the correct ssh url.
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
+
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
hostname := d.c.Server.Hostname
if strings.Contains(hostname, ":") {
hostname = strings.Split(hostname, ":")[0]
+
}
+
+
if hostname == "knot1.tangled.sh" {
+
hostname = "tangled.sh"
}
fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
-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)
-
}
-
-
err = h.jc.StartJetstream(ctx, h.processMessages)
-
if err != nil {
-
return nil, fmt.Errorf("failed to start jetstream: %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)
-
}
-
}
-
-
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/{branch}", h.RepoForkSync)
-
r.Get("/sync/{branch}", 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)
-
}
+81 -91
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, did string, record tangled.PublicKey) 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
+
+
var record tangled.PublicKey
+
if err := json.Unmarshal(raw, &record); err != nil {
+
return fmt.Errorf("failed to unmarshal record: %w", err)
+
}
+
pk := db.PublicKey{
Did: did,
PublicKey: record,
···
return nil
}
-
func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) 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
+
+
var record tangled.KnotMember
+
if err := json.Unmarshal(raw, &record); err != nil {
+
return fmt.Errorf("failed to unmarshal record: %w", err)
+
}
if record.Domain != h.c.Server.Hostname {
l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname)
···
}
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, did string, record tangled.RepoPull) error {
+
func (h *Knot) processPull(ctx context.Context, event *models.Event) error {
+
raw := json.RawMessage(event.Commit.Record)
+
did := event.Did
+
+
var record tangled.RepoPull
+
if err := json.Unmarshal(raw, &record); err != nil {
+
return fmt.Errorf("failed to unmarshal record: %w", err)
+
}
+
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.Pipeline
+
var pipeline workflow.RawPipeline
for _, e := range workflowDir {
if !e.IsFile {
continue
···
continue
}
-
wf, err := workflow.FromFile(e.Name, contents)
-
if err != nil {
-
// TODO: log here, respond to client that is pushing
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
-
continue
-
}
-
-
pipeline = append(pipeline, wf)
+
pipeline = append(pipeline, workflow.RawWorkflow{
+
Name: e.Name,
+
Contents: contents,
+
})
}
trigger := tangled.Pipeline_PullRequestTriggerData{
Action: "create",
SourceBranch: record.Source.Branch,
SourceSha: record.Source.Sha,
-
TargetBranch: record.TargetBranch,
+
TargetBranch: record.Target.Branch,
}
compiler := workflow.Compiler{
···
},
}
-
cp := compiler.Compile(pipeline)
+
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
···
return nil
}
-
event := db.Event{
+
ev := db.Event{
Rkey: TID(),
Nsid: tangled.PipelineNSID,
EventJson: string(eventJson),
}
-
return h.db.InsertEvent(event, h.n)
+
return h.db.InsertEvent(ev, h.n)
}
// duplicated from add collaborator
-
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
+
func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error {
+
raw := json.RawMessage(event.Commit.Record)
+
did := event.Did
+
+
var record tangled.RepoCollaborator
+
if err := json.Unmarshal(raw, &record); err != nil {
+
return fmt.Errorf("failed to unmarshal record: %w", err)
+
}
+
repoAt, err := syntax.ParseATURI(record.Repo)
if err != nil {
return err
···
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
// check perms for this user
-
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), 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 {
-
did := event.Did
+
func (h *Knot) processMessages(ctx context.Context, event *models.Event) error {
if event.Kind != models.EventKindCommit {
return nil
}
···
}
}()
-
raw := json.RawMessage(event.Commit.Record)
-
switch event.Commit.Collection {
case tangled.PublicKeyNSID:
-
var record tangled.PublicKey
-
if err := json.Unmarshal(raw, &record); err != nil {
-
return fmt.Errorf("failed to unmarshal record: %w", err)
-
}
-
if err := h.processPublicKey(ctx, did, record); err != nil {
-
return fmt.Errorf("failed to process public key: %w", err)
-
}
-
+
err = h.processPublicKey(ctx, event)
case tangled.KnotMemberNSID:
-
var record tangled.KnotMember
-
if err := json.Unmarshal(raw, &record); err != nil {
-
return fmt.Errorf("failed to unmarshal record: %w", err)
-
}
-
if err := h.processKnotMember(ctx, did, record); err != nil {
-
return fmt.Errorf("failed to process knot member: %w", err)
-
}
-
+
err = h.processKnotMember(ctx, event)
case tangled.RepoPullNSID:
-
var record tangled.RepoPull
-
if err := json.Unmarshal(raw, &record); err != nil {
-
return fmt.Errorf("failed to unmarshal record: %w", err)
-
}
-
if err := h.processPull(ctx, did, record); err != nil {
-
return fmt.Errorf("failed to process knot member: %w", err)
-
}
-
+
err = h.processPull(ctx, event)
case tangled.RepoCollaboratorNSID:
-
var record tangled.RepoCollaborator
-
if err := json.Unmarshal(raw, &record); err != nil {
-
return fmt.Errorf("failed to unmarshal record: %w", err)
-
}
-
if err := h.processCollaborator(ctx, did, record); err != nil {
-
return fmt.Errorf("failed to process knot member: %w", err)
-
}
+
err = h.processCollaborator(ctx, event)
+
}
+
if err != nil {
+
h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err)
}
-
return err
+
return nil
}
+15 -37
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 {
···
return err
}
-
pipelineParseErrors := []string{}
-
-
var pipeline workflow.Pipeline
+
var pipeline workflow.RawPipeline
for _, e := range workflowDir {
if !e.IsFile {
continue
···
continue
}
-
wf, err := workflow.FromFile(e.Name, contents)
-
if err != nil {
-
h.l.Error("failed to parse workflow", "err", err, "path", fpath)
-
pipelineParseErrors = append(pipelineParseErrors, fmt.Sprintf("- at %s: %s\n", fpath, err))
-
continue
-
}
-
-
pipeline = append(pipeline, wf)
+
pipeline = append(pipeline, workflow.RawWorkflow{
+
Name: e.Name,
+
Contents: contents,
+
})
}
trigger := tangled.Pipeline_PushTriggerData{
···
},
}
-
cp := compiler.Compile(pipeline)
+
cp := compiler.Compile(compiler.Parse(pipeline))
eventJson, err := json.Marshal(cp)
if err != nil {
return err
}
+
for _, e := range compiler.Diagnostics.Errors {
+
*clientMsgs = append(*clientMsgs, e.String())
+
}
+
if pushOptions.verboseCi {
-
hasDiagnostics := false
-
if len(pipelineParseErrors) > 0 {
-
hasDiagnostics = true
-
*clientMsgs = append(*clientMsgs, "error: failed to parse workflow(s):")
-
for _, error := range pipelineParseErrors {
-
*clientMsgs = append(*clientMsgs, error)
-
}
+
if compiler.Diagnostics.IsEmpty() {
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
}
-
if len(compiler.Diagnostics.Errors) > 0 {
-
hasDiagnostics = true
-
*clientMsgs = append(*clientMsgs, "error(s) on pipeline:")
-
for _, error := range compiler.Diagnostics.Errors {
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- %s:", error))
-
}
-
}
-
if len(compiler.Diagnostics.Warnings) > 0 {
-
hasDiagnostics = true
-
*clientMsgs = append(*clientMsgs, "warning(s) on pipeline:")
-
for _, warning := range compiler.Diagnostics.Warnings {
-
*clientMsgs = append(*clientMsgs, fmt.Sprintf("- at %s: %s: %s", warning.Path, warning.Type, warning.Reason))
-
}
-
}
-
if !hasDiagnostics {
-
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
+
+
for _, w := range compiler.Diagnostics.Warnings {
+
*clientMsgs = append(*clientMsgs, w.String())
}
}
-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", "RepoForkSync")
-
-
data := struct {
-
Did string `json:"did"`
-
Source string `json:"source"`
-
Name string `json:"name,omitempty"`
-
HiddenRef string `json:"hiddenref"`
-
}{}
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
did := data.Did
-
source := data.Source
-
-
if did == "" || source == "" {
-
l.Error("invalid request body, empty did or name")
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
var name string
-
if data.Name != "" {
-
name = data.Name
-
} else {
-
name = filepath.Base(source)
-
}
-
-
branch := chi.URLParam(r, "branch")
-
branch, _ = url.PathUnescape(branch)
-
-
relativeRepoPath := filepath.Join(did, name)
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
-
gr, err := git.PlainOpen(repoPath)
-
if err != nil {
-
log.Println(err)
-
notFound(w)
-
return
-
}
-
-
forkCommit, err := gr.ResolveRevision(branch)
-
if err != nil {
-
l.Error("error resolving ref revision", "msg", err.Error())
-
writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
-
return
-
}
-
-
sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
-
if err != nil {
-
l.Error("error resolving hidden ref revision", "msg", err.Error())
-
writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
-
return
-
}
-
-
status := types.UpToDate
-
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
-
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
-
if err != nil {
-
log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
-
return
-
}
-
-
if isAncestor {
-
status = types.FastForwardable
-
} else {
-
status = types.Conflict
-
}
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
-
}
-
-
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
ref := chi.URLParam(r, "ref")
-
ref, _ = url.PathUnescape(ref)
-
-
l := h.l.With("handler", "RepoLanguages")
-
-
gr, err := git.Open(repoPath, ref)
-
if err != nil {
-
l.Error("opening repo", "error", err.Error())
-
notFound(w)
-
return
-
}
-
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
-
defer cancel()
-
-
sizes, err := gr.AnalyzeLanguages(ctx)
-
if err != nil {
-
l.Error("failed to analyze languages", "error", err.Error())
-
writeError(w, err.Error(), http.StatusNoContent)
-
return
-
}
-
-
resp := types.RepoLanguageResponse{Languages: sizes}
-
-
writeJSON(w, resp)
-
}
-
-
func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
-
l := h.l.With("handler", "RepoForkSync")
-
-
data := struct {
-
Did string `json:"did"`
-
Source string `json:"source"`
-
Name string `json:"name,omitempty"`
-
}{}
-
-
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
-
writeError(w, "invalid request body", http.StatusBadRequest)
-
return
-
}
-
-
did := data.Did
-
source := data.Source
-
-
if did == "" || source == "" {
-
l.Error("invalid request body, empty did or name")
-
w.WriteHeader(http.StatusBadRequest)
-
return
-
}
-
-
var name string
-
if data.Name != "" {
-
name = data.Name
-
} else {
-
name = filepath.Base(source)
-
}
-
-
branch := chi.URLParam(r, "branch")
-
branch, _ = url.PathUnescape(branch)
-
-
relativeRepoPath := filepath.Join(did, name)
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
-
-
gr, err := git.PlainOpen(repoPath)
-
if err != nil {
-
log.Println(err)
-
notFound(w)
-
return
-
}
-
-
err = gr.Sync(branch)
-
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)
+
}
+49
knotserver/xrpc/list_keys.go
···
+
package xrpc
+
+
import (
+
"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
+
}
+
+
writeJson(w, response)
+
}
+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)
+
}
+22
knotserver/xrpc/owner.go
···
+
package xrpc
+
+
import (
+
"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,
+
}
+
+
writeJson(w, response)
+
}
+81
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 := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
+
+
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, ref)
+
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
+
return
+
}
+
+
repoParts := strings.Split(repo, "/")
+
repoName := repoParts[len(repoParts)-1]
+
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).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
+
}
+
}
+143
knotserver/xrpc/repo_blob.go
···
+
package xrpc
+
+
import (
+
"crypto/sha256"
+
"encoding/base64"
+
"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) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
+
+
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.RefNotFoundError, 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
+
}
+
+
writeJson(w, response)
+
}
+
+
// 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)
+
}
+85
knotserver/xrpc/repo_branch.go
···
+
package xrpc
+
+
import (
+
"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) 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.RepoNotFoundError, http.StatusNoContent)
+
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(time.RFC3339),
+
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(time.RFC3339),
+
}
+
+
writeJson(w, response)
+
}
+56
knotserver/xrpc/repo_branches.go
···
+
package xrpc
+
+
import (
+
"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.RepoNotFoundError, http.StatusNoContent)
+
return
+
}
+
+
branches, _ := gr.Branches()
+
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) {
+
offset = o
+
}
+
}
+
+
end := min(offset+limit, len(branches))
+
+
paginatedBranches := branches[offset:end]
+
+
// Create response using existing types.RepoBranchesResponse
+
response := types.RepoBranchesResponse{
+
Branches: paginatedBranches,
+
}
+
+
writeJson(w, response)
+
}
+82
knotserver/xrpc/repo_compare.go
···
+
package xrpc
+
+
import (
+
"fmt"
+
"net/http"
+
+
"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
+
}
+
+
rev1 := r.URL.Query().Get("rev1")
+
if rev1 == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing rev1 parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rev2 := r.URL.Query().Get("rev2")
+
if rev2 == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing rev2 parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
+
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
+
}
+
+
response := types.RepoFormatPatchResponse{
+
Rev1: commit1.Hash.String(),
+
Rev2: commit2.Hash.String(),
+
FormatPatch: formatPatch,
+
Patch: rawPatch,
+
}
+
+
writeJson(w, response)
+
}
+41
knotserver/xrpc/repo_diff.go
···
+
package xrpc
+
+
import (
+
"net/http"
+
+
"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
+
}
+
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
+
return
+
}
+
+
diff, err := gr.Diff()
+
if err != nil {
+
x.Logger.Error("getting diff", "error", err.Error())
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError)
+
return
+
}
+
+
response := types.RepoCommitResponse{
+
Ref: ref,
+
Diff: diff,
+
}
+
+
writeJson(w, response)
+
}
+39
knotserver/xrpc/repo_get_default_branch.go
···
+
package xrpc
+
+
import (
+
"net/http"
+
"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) 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.PlainOpen(repoPath)
+
+
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: time.UnixMicro(0).Format(time.RFC3339),
+
}
+
+
writeJson(w, response)
+
}
+76
knotserver/xrpc/repo_languages.go
···
+
package xrpc
+
+
import (
+
"context"
+
"math"
+
"net/http"
+
"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) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
ref := r.URL.Query().Get("ref")
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
x.Logger.Error("opening repo", "error", err.Error())
+
writeError(w, xrpcerr.RefNotFoundError, 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
+
}
+
+
writeJson(w, response)
+
}
+81
knotserver/xrpc/repo_log.go
···
+
package xrpc
+
+
import (
+
"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) 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
+
}
+
+
ref := r.URL.Query().Get("ref")
+
+
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
+
}
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.RefNotFoundError, 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
+
+
writeJson(w, response)
+
}
+86
knotserver/xrpc/repo_tags.go
···
+
package xrpc
+
+
import (
+
"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.PlainOpen(repoPath)
+
if err != nil {
+
x.Logger.Error("failed to open", "error", err)
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
+
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,
+
}
+
+
writeJson(w, response)
+
}
+89
knotserver/xrpc/repo_tree.go
···
+
package xrpc
+
+
import (
+
"net/http"
+
"path/filepath"
+
"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) 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
+
}
+
+
ref := r.URL.Query().Get("ref")
+
// ref can be empty (git.Open handles this)
+
+
path := r.URL.Query().Get("path")
+
// path can be empty (defaults to root)
+
+
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.RefNotFoundError, 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(time.RFC3339),
+
}
+
}
+
+
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,
+
}
+
+
writeJson(w, response)
+
}
-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
}
+60
knotserver/xrpc/version.go
···
+
package xrpc
+
+
import (
+
"fmt"
+
"net/http"
+
"runtime/debug"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
)
+
+
// 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,
+
}
+
+
writeJson(w, response)
+
}
+127
knotserver/xrpc/xrpc.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"log/slog"
+
"net/http"
+
"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.SplitN(repo, "/", 2)
+
if len(parts) != 2 {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
+
)
+
}
+
+
did := parts[0]
+
repoName := parts[1]
+
+
// Construct repository path using the same logic as didPath
+
didRepoPath, err := securejoin.SecureJoin(did, repoName)
+
if err != nil {
+
return "", xrpcerr.RepoNotFoundError
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
+
if err != nil {
+
return "", xrpcerr.RepoNotFoundError
+
}
+
+
return repoPath, 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)
+
}
+
+
func writeJson(w http.ResponseWriter, response any) {
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
}
+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"
+
}
+
]
+
}
+
}
+
}
+7 -63
lexicons/pipeline/pipeline.json
···
"type": "object",
"required": [
"name",
-
"dependencies",
-
"steps",
-
"environment",
-
"clone"
+
"engine",
+
"clone",
+
"raw"
],
"properties": {
"name": {
"type": "string"
},
-
"dependencies": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#dependency"
-
}
-
},
-
"steps": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#step"
-
}
-
},
-
"environment": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#pair"
-
}
+
"engine": {
+
"type": "string"
},
"clone": {
"type": "ref",
"ref": "#cloneOpts"
-
}
-
}
-
},
-
"dependency": {
-
"type": "object",
-
"required": [
-
"registry",
-
"packages"
-
],
-
"properties": {
-
"registry": {
+
},
+
"raw": {
"type": "string"
-
},
-
"packages": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
}
}
}
},
···
},
"submodules": {
"type": "boolean"
-
}
-
}
-
},
-
"step": {
-
"type": "object",
-
"required": [
-
"name",
-
"command"
-
],
-
"properties": {
-
"name": {
-
"type": "string"
-
},
-
"command": {
-
"type": "string"
-
},
-
"environment": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#pair"
-
}
}
}
},
-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"
+
}
+
}
+
}
+
}
+
}
+3 -1
log/log.go
···
// NewHandler sets up a new slog.Handler with the service name
// as an attribute
func NewHandler(name string) slog.Handler {
-
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
+
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+
Level: slog.LevelDebug,
+
})
var attrs []slog.Attr
attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+11 -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.13"
-
hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI="
+
version = "v1.7.12"
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
+
[mod."github.com/yuin/goldmark-highlighting/v2"]
+
version = "v2.0.0-20230729083705-37449abec8cc"
+
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
[mod."gitlab.com/yawning/secp256k1-voi"]
version = "v0.0.0-20230925100816-f2616030848b"
hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA="
+32 -29
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 {
···
cfg.package
];
-
system.activationScripts.gitConfig = let
-
setMotd =
-
if cfg.motdFile != null && cfg.motd != null
-
then throw "motdFile and motd cannot be both set"
-
else ''
-
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
-
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
-
'';
-
in ''
-
mkdir -p "${cfg.repo.scanPath}"
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
-
-
mkdir -p "${cfg.stateDir}/.config/git"
-
cat > "${cfg.stateDir}/.config/git/config" << EOF
-
[user]
-
name = Git User
-
email = git@example.com
-
[receive]
-
advertisePushOptions = true
-
EOF
-
${setMotd}
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
-
'';
-
users.users.${cfg.gitUser} = {
isSystemUser = true;
useDefaultShell = true;
···
description = "knot service";
after = ["network.target" "sshd.service"];
wantedBy = ["multi-user.target"];
+
enableStrictShellChecks = true;
+
+
preStart = let
+
setMotd =
+
if cfg.motdFile != null && cfg.motd != null
+
then throw "motdFile and motd cannot be both set"
+
else ''
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
+
'';
+
in ''
+
mkdir -p "${cfg.repo.scanPath}"
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
+
+
mkdir -p "${cfg.stateDir}/.config/git"
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
+
[user]
+
name = Git User
+
email = git@example.com
+
[receive]
+
advertisePushOptions = true
+
EOF
+
${setMotd}
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
+
'';
+
serviceConfig = {
User = cfg.gitUser;
+
PermissionsStartOnly = true;
WorkingDirectory = cfg.stateDir;
Environment = [
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
···
"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";
};
+18 -2
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}"
-
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
-
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
+
"SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
+
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
];
ExecStart = "${cfg.package}/bin/spindle";
Restart = "always";
+1 -1
nix/pkgs/appview-static-files.nix
···
cp -rf ${lucide-src}/*.svg icons/
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
# for whatever reason (produces broken css), so we are doing this instead
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+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;
+
}
+57 -16
nix/vm.nix
···
{
nixpkgs,
system,
+
hostSystem,
self,
}: let
envVar = name: let
···
self.nixosModules.knot
self.nixosModules.spindle
({
+
lib,
config,
pkgs,
...
}: {
-
nixos-shell = {
-
inheritPath = false;
-
mounts = {
-
mountHome = false;
-
mountNixProfile = false;
-
};
-
};
-
virtualisation = {
+
virtualisation.vmVariant.virtualisation = {
+
host.pkgs = import nixpkgs {system = hostSystem;};
+
+
graphics = false;
memorySize = 2048;
diskSize = 10 * 1024;
cores = 2;
···
guest.port = 6555;
}
];
+
sharedDirectories = {
+
# We can't use the 9p mounts directly for most of these
+
# as SQLite is incompatible with them. So instead we
+
# mount the shared directories to a different location
+
# and copy the contents around on service start/stop.
+
knotData = {
+
source = "$TANGLED_VM_DATA_DIR/knot";
+
target = "/mnt/knot-data";
+
};
+
spindleData = {
+
source = "$TANGLED_VM_DATA_DIR/spindle";
+
target = "/mnt/spindle-data";
+
};
+
spindleLogs = {
+
source = "$TANGLED_VM_DATA_DIR/spindle-logs";
+
target = "/var/log/spindle";
+
};
+
};
};
+
# 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];
-
systemd.tmpfiles.rules = let
-
u = config.services.tangled-knot.gitUser;
-
g = config.services.tangled-knot.gitUser;
-
in [
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
-
];
services.tangled-knot = {
enable = true;
motd = "Welcome to the development knot!\n";
server = {
-
secretFile = "/var/lib/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";
};
};
+
};
+
users = {
+
# So we don't have to deal with permission clashing between
+
# blank disk VMs and existing state
+
users.${config.services.tangled-knot.gitUser}.uid = 666;
+
groups.${config.services.tangled-knot.gitUser}.gid = 666;
+
+
# TODO: separate spindle user
+
};
+
systemd.services = let
+
mkDataSyncScripts = source: target: {
+
enableStrictShellChecks = true;
+
+
preStart = lib.mkBefore ''
+
mkdir -p ${target}
+
${lib.getExe pkgs.rsync} -a ${source}/ ${target}
+
'';
+
+
postStop = lib.mkAfter ''
+
${lib.getExe pkgs.rsync} -a ${target}/ ${source}
+
'';
+
+
serviceConfig.PermissionsStartOnly = true;
+
};
+
in {
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
};
})
];
+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
+14 -1
rbac/rbac.go
···
return nil, err
}
-
db, err := sql.Open("sqlite3", path)
+
db, err := sql.Open("sqlite3", path+"?_foreign_keys=1")
if err != nil {
return nil, err
}
···
func (e *Enforcer) RemoveSpindle(spindle string) error {
spindle = intoSpindle(spindle)
_, err := e.E.DeleteDomains(spindle)
+
return err
+
}
+
+
func (e *Enforcer) RemoveKnot(knot string) error {
+
_, err := e.E.DeleteDomains(knot)
return err
}
···
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) {
+1 -1
rbac/rbac_test.go
···
)
func setup(t *testing.T) *rbac.Enforcer {
-
db, err := sql.Open("sqlite3", ":memory:")
+
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1")
assert.NoError(t, err)
a, err := adapter.NewAdapter(db, "sqlite3", "acl")
+6 -4
spindle/config/config.go
···
Dev bool `env:"DEV, default=false"`
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 {
···
Mount string `env:"MOUNT, default=spindle"`
}
-
type Pipelines struct {
+
type NixeryPipelines struct {
Nixery string `env:"NIXERY, default=nixery.tangled.sh"`
WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"`
-
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
}
type Config struct {
-
Server Server `env:",prefix=SPINDLE_SERVER_"`
-
Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"`
+
Server Server `env:",prefix=SPINDLE_SERVER_"`
+
NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"`
}
func Load(ctx context.Context) (*Config, error) {
+14 -10
spindle/db/db.go
···
import (
"database/sql"
+
"strings"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Make(dbPath string) (*DB, error) {
-
db, err := sql.Open("sqlite3", dbPath)
+
// https://github.com/mattn/go-sqlite3#connection-string
+
opts := []string{
+
"_foreign_keys=1",
+
"_journal_mode=WAL",
+
"_synchronous=NORMAL",
+
"_auto_vacuum=incremental",
+
}
+
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
return nil, err
}
-
_, err = db.Exec(`
-
pragma journal_mode = WAL;
-
pragma synchronous = normal;
-
pragma foreign_keys = on;
-
pragma temp_store = memory;
-
pragma mmap_size = 30000000000;
-
pragma page_size = 32768;
-
pragma auto_vacuum = incremental;
-
pragma busy_timeout = 5000;
+
// NOTE: If any other migration is added here, you MUST
+
// copy the pattern in appview: use a single sql.Conn
+
// for every migration.
+
_, err = db.Exec(`
create table if not exists _jetstream (
id integer primary key autoincrement,
last_time_us integer not null
-21
spindle/engine/ansi_stripper.go
···
-
package engine
-
-
import (
-
"io"
-
-
"regexp"
-
)
-
-
// regex to match ANSI escape codes (e.g., color codes, cursor moves)
-
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
-
-
var re = regexp.MustCompile(ansi)
-
-
type ansiStrippingWriter struct {
-
underlying io.Writer
-
}
-
-
func (w *ansiStrippingWriter) Write(p []byte) (int, error) {
-
clean := re.ReplaceAll(p, []byte{})
-
return w.underlying.Write(clean)
-
}
+68 -415
spindle/engine/engine.go
···
"context"
"errors"
"fmt"
-
"io"
"log/slog"
-
"os"
-
"strings"
-
"sync"
-
"time"
securejoin "github.com/cyphar/filepath-securejoin"
-
"github.com/docker/docker/api/types/container"
-
"github.com/docker/docker/api/types/image"
-
"github.com/docker/docker/api/types/mount"
-
"github.com/docker/docker/api/types/network"
-
"github.com/docker/docker/api/types/volume"
-
"github.com/docker/docker/client"
-
"github.com/docker/docker/pkg/stdcopy"
"golang.org/x/sync/errgroup"
-
"tangled.sh/tangled.sh/core/log"
"tangled.sh/tangled.sh/core/notifier"
"tangled.sh/tangled.sh/core/spindle/config"
"tangled.sh/tangled.sh/core/spindle/db"
···
"tangled.sh/tangled.sh/core/spindle/secrets"
)
-
const (
-
workspaceDir = "/tangled/workspace"
+
var (
+
ErrTimedOut = errors.New("timed out")
+
ErrWorkflowFailed = errors.New("workflow failed")
)
-
type cleanupFunc func(context.Context) error
-
-
type Engine struct {
-
docker client.APIClient
-
l *slog.Logger
-
db *db.DB
-
n *notifier.Notifier
-
cfg *config.Config
-
vault secrets.Manager
-
-
cleanupMu sync.Mutex
-
cleanup map[string][]cleanupFunc
-
}
-
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
-
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
-
if err != nil {
-
return nil, err
-
}
-
-
l := log.FromContext(ctx).With("component", "spindle")
-
-
e := &Engine{
-
docker: dcli,
-
l: l,
-
db: db,
-
n: n,
-
cfg: cfg,
-
vault: vault,
-
}
-
-
e.cleanup = make(map[string][]cleanupFunc)
-
-
return e, nil
-
}
-
-
func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
-
e.l.Info("starting all workflows in parallel", "pipeline", pipelineId)
+
func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) {
+
l.Info("starting all workflows in parallel", "pipeline", pipelineId)
// extract secrets
var allSecrets []secrets.UnlockedSecret
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
-
if res, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
+
if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
allSecrets = res
}
}
-
workflowTimeoutStr := e.cfg.Pipelines.WorkflowTimeout
-
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
-
if err != nil {
-
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
-
workflowTimeout = 5 * time.Minute
-
}
-
e.l.Info("using workflow timeout", "timeout", workflowTimeout)
-
eg, ctx := errgroup.WithContext(ctx)
-
for _, w := range pipeline.Workflows {
-
eg.Go(func() error {
-
wid := models.WorkflowId{
-
PipelineId: pipelineId,
-
Name: w.Name,
-
}
-
-
err := e.db.StatusRunning(wid, e.n)
-
if err != nil {
-
return err
-
}
+
for eng, wfs := range pipeline.Workflows {
+
workflowTimeout := eng.WorkflowTimeout()
+
l.Info("using workflow timeout", "timeout", workflowTimeout)
-
err = e.SetupWorkflow(ctx, wid)
-
if err != nil {
-
e.l.Error("setting up worklow", "wid", wid, "err", err)
-
return err
-
}
-
defer e.DestroyWorkflow(ctx, wid)
-
-
reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{})
-
if err != nil {
-
e.l.Error("pipeline image pull failed!", "image", w.Image, "workflowId", wid, "error", err.Error())
+
for _, w := range wfs {
+
eg.Go(func() error {
+
wid := models.WorkflowId{
+
PipelineId: pipelineId,
+
Name: w.Name,
+
}
-
err := e.db.StatusFailed(wid, err.Error(), -1, e.n)
+
err := db.StatusRunning(wid, n)
if err != nil {
return err
}
-
return fmt.Errorf("pulling image: %w", err)
-
}
-
defer reader.Close()
-
io.Copy(os.Stdout, reader)
-
-
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
-
defer cancel()
+
err = eng.SetupWorkflow(ctx, wid, &w)
+
if err != nil {
+
// TODO(winter): Should this always set StatusFailed?
+
// In the original, we only do in a subset of cases.
+
l.Error("setting up worklow", "wid", wid, "err", err)
-
err = e.StartSteps(ctx, wid, w, allSecrets)
-
if err != nil {
-
if errors.Is(err, ErrTimedOut) {
-
dbErr := e.db.StatusTimeout(wid, e.n)
-
if dbErr != nil {
-
return dbErr
+
destroyErr := eng.DestroyWorkflow(ctx, wid)
+
if destroyErr != nil {
+
l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
}
-
} else {
-
dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n)
+
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
if dbErr != nil {
return dbErr
}
+
return err
}
+
defer eng.DestroyWorkflow(ctx, wid)
-
return fmt.Errorf("starting steps image: %w", err)
-
}
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid)
+
if err != nil {
+
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
+
wfLogger = nil
+
} else {
+
defer wfLogger.Close()
+
}
-
err = e.db.StatusSuccess(wid, e.n)
-
if err != nil {
-
return err
-
}
+
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
+
defer cancel()
-
return nil
-
})
-
}
+
for stepIdx, step := range w.Steps {
+
if wfLogger != nil {
+
ctl := wfLogger.ControlWriter(stepIdx, step)
+
ctl.Write([]byte(step.Name()))
+
}
-
if err = eg.Wait(); err != nil {
-
e.l.Error("failed to run one or more workflows", "err", err)
-
} else {
-
e.l.Error("successfully ran full pipeline")
-
}
-
}
+
err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger)
+
if err != nil {
+
if errors.Is(err, ErrTimedOut) {
+
dbErr := db.StatusTimeout(wid, n)
+
if dbErr != nil {
+
return dbErr
+
}
+
} else {
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
+
if dbErr != nil {
+
return dbErr
+
}
+
}
-
// SetupWorkflow sets up a new network for the workflow and volumes for
-
// the workspace and Nix store. These are persisted across steps and are
-
// destroyed at the end of the workflow.
-
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error {
-
e.l.Info("setting up workflow", "workflow", wid)
+
return fmt.Errorf("starting steps image: %w", err)
+
}
+
}
-
_, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{
-
Name: workspaceVolume(wid),
-
Driver: "local",
-
})
-
if err != nil {
-
return err
-
}
-
e.registerCleanup(wid, func(ctx context.Context) error {
-
return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true)
-
})
-
-
_, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{
-
Name: nixVolume(wid),
-
Driver: "local",
-
})
-
if err != nil {
-
return err
-
}
-
e.registerCleanup(wid, func(ctx context.Context) error {
-
return e.docker.VolumeRemove(ctx, nixVolume(wid), true)
-
})
-
-
_, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
-
Driver: "bridge",
-
})
-
if err != nil {
-
return err
-
}
-
e.registerCleanup(wid, func(ctx context.Context) error {
-
return e.docker.NetworkRemove(ctx, networkName(wid))
-
})
+
err = db.StatusSuccess(wid, n)
+
if err != nil {
+
return err
+
}
-
return nil
-
}
-
-
// StartSteps starts all steps sequentially with the same base image.
-
// ONLY marks pipeline as failed if container's exit code is non-zero.
-
// All other errors are bubbled up.
-
// Fixed version of the step execution logic
-
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
-
workflowEnvs := ConstructEnvs(w.Environment)
-
for _, s := range secrets {
-
workflowEnvs.AddEnv(s.Key, s.Value)
-
}
-
-
for stepIdx, step := range w.Steps {
-
select {
-
case <-ctx.Done():
-
return ctx.Err()
-
default:
-
}
-
-
envs := append(EnvVars(nil), workflowEnvs...)
-
for k, v := range step.Environment {
-
envs.AddEnv(k, v)
-
}
-
envs.AddEnv("HOME", workspaceDir)
-
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
-
-
hostConfig := hostConfig(wid)
-
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
-
Image: w.Image,
-
Cmd: []string{"bash", "-c", step.Command},
-
WorkingDir: workspaceDir,
-
Tty: false,
-
Hostname: "spindle",
-
Env: envs.Slice(),
-
}, hostConfig, nil, nil, "")
-
defer e.DestroyStep(ctx, resp.ID)
-
if err != nil {
-
return fmt.Errorf("creating container: %w", err)
-
}
-
-
err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil)
-
if err != nil {
-
return fmt.Errorf("connecting network: %w", err)
-
}
-
-
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
-
if err != nil {
-
return err
-
}
-
e.l.Info("started container", "name", resp.ID, "step", step.Name)
-
-
// start tailing logs in background
-
tailDone := make(chan error, 1)
-
go func() {
-
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
-
}()
-
-
// wait for container completion or timeout
-
waitDone := make(chan struct{})
-
var state *container.State
-
var waitErr error
-
-
go func() {
-
defer close(waitDone)
-
state, waitErr = e.WaitStep(ctx, resp.ID)
-
}()
-
-
select {
-
case <-waitDone:
-
-
// wait for tailing to complete
-
<-tailDone
-
-
case <-ctx.Done():
-
e.l.Warn("step timed out; killing container", "container", resp.ID, "step", step.Name)
-
err = e.DestroyStep(context.Background(), resp.ID)
-
if err != nil {
-
e.l.Error("failed to destroy step", "container", resp.ID, "error", err)
-
}
-
-
// wait for both goroutines to finish
-
<-waitDone
-
<-tailDone
-
-
return ErrTimedOut
-
}
-
-
select {
-
case <-ctx.Done():
-
return ctx.Err()
-
default:
-
}
-
-
if waitErr != nil {
-
return waitErr
-
}
-
-
err = e.DestroyStep(ctx, resp.ID)
-
if err != nil {
-
return err
-
}
-
-
if state.ExitCode != 0 {
-
e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled)
-
if state.OOMKilled {
-
return ErrOOMKilled
-
}
-
return ErrWorkflowFailed
+
return nil
+
})
}
}
-
return nil
-
}
-
-
func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) {
-
wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
-
select {
-
case err := <-errCh:
-
if err != nil {
-
return nil, err
-
}
-
case <-wait:
-
}
-
-
e.l.Info("waited for container", "name", containerID)
-
-
info, err := e.docker.ContainerInspect(ctx, containerID)
-
if err != nil {
-
return nil, err
-
}
-
-
return info.State, nil
-
}
-
-
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
-
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
-
if err != nil {
-
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
-
return err
+
if err := eg.Wait(); err != nil {
+
l.Error("failed to run one or more workflows", "err", err)
+
} else {
+
l.Error("successfully ran full pipeline")
}
-
defer wfLogger.Close()
-
-
ctl := wfLogger.ControlWriter(stepIdx, step)
-
ctl.Write([]byte(step.Name))
-
-
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
-
Follow: true,
-
ShowStdout: true,
-
ShowStderr: true,
-
Details: false,
-
Timestamps: false,
-
})
-
if err != nil {
-
return err
-
}
-
-
_, err = stdcopy.StdCopy(
-
wfLogger.DataWriter("stdout"),
-
wfLogger.DataWriter("stderr"),
-
logs,
-
)
-
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
-
return fmt.Errorf("failed to copy logs: %w", err)
-
}
-
-
return nil
-
}
-
-
func (e *Engine) DestroyStep(ctx context.Context, containerID string) error {
-
err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL
-
if err != nil && !isErrContainerNotFoundOrNotRunning(err) {
-
return err
-
}
-
-
if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{
-
RemoveVolumes: true,
-
RemoveLinks: false,
-
Force: false,
-
}); err != nil && !isErrContainerNotFoundOrNotRunning(err) {
-
return err
-
}
-
-
return nil
-
}
-
-
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
-
e.cleanupMu.Lock()
-
key := wid.String()
-
-
fns := e.cleanup[key]
-
delete(e.cleanup, key)
-
e.cleanupMu.Unlock()
-
-
for _, fn := range fns {
-
if err := fn(ctx); err != nil {
-
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
-
}
-
}
-
return nil
-
}
-
-
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
-
e.cleanupMu.Lock()
-
defer e.cleanupMu.Unlock()
-
-
key := wid.String()
-
e.cleanup[key] = append(e.cleanup[key], fn)
-
}
-
-
func workspaceVolume(wid models.WorkflowId) string {
-
return fmt.Sprintf("workspace-%s", wid)
-
}
-
-
func nixVolume(wid models.WorkflowId) string {
-
return fmt.Sprintf("nix-%s", wid)
-
}
-
-
func networkName(wid models.WorkflowId) string {
-
return fmt.Sprintf("workflow-network-%s", wid)
-
}
-
-
func hostConfig(wid models.WorkflowId) *container.HostConfig {
-
hostConfig := &container.HostConfig{
-
Mounts: []mount.Mount{
-
{
-
Type: mount.TypeVolume,
-
Source: workspaceVolume(wid),
-
Target: workspaceDir,
-
},
-
{
-
Type: mount.TypeVolume,
-
Source: nixVolume(wid),
-
Target: "/nix",
-
},
-
{
-
Type: mount.TypeTmpfs,
-
Target: "/tmp",
-
ReadOnly: false,
-
TmpfsOptions: &mount.TmpfsOptions{
-
Mode: 0o1777, // world-writeable sticky bit
-
Options: [][]string{
-
{"exec"},
-
},
-
},
-
},
-
{
-
Type: mount.TypeVolume,
-
Source: "etc-nix-" + wid.String(),
-
Target: "/etc/nix",
-
},
-
},
-
ReadonlyRootfs: false,
-
CapDrop: []string{"ALL"},
-
CapAdd: []string{"CAP_DAC_OVERRIDE"},
-
SecurityOpt: []string{"no-new-privileges"},
-
ExtraHosts: []string{"host.docker.internal:host-gateway"},
-
}
-
-
return hostConfig
-
}
-
-
// thanks woodpecker
-
func isErrContainerNotFoundOrNotRunning(err error) bool {
-
// Error response from daemon: Cannot kill container: ...: No such container: ...
-
// Error response from daemon: Cannot kill container: ...: Container ... is not running"
-
// Error response from podman daemon: can only kill running containers. ... is in state exited
-
// Error: No such container: ...
-
return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers"))
}
-28
spindle/engine/envs.go
···
-
package engine
-
-
import (
-
"fmt"
-
)
-
-
type EnvVars []string
-
-
// ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value}
-
// representation into a docker-friendly []string{"KEY=value", ...} slice.
-
func ConstructEnvs(envs map[string]string) EnvVars {
-
var dockerEnvs EnvVars
-
for k, v := range envs {
-
ev := fmt.Sprintf("%s=%s", k, v)
-
dockerEnvs = append(dockerEnvs, ev)
-
}
-
return dockerEnvs
-
}
-
-
// Slice returns the EnvVar as a []string slice.
-
func (ev EnvVars) Slice() []string {
-
return ev
-
}
-
-
// AddEnv adds a key=value string to the EnvVar.
-
func (ev *EnvVars) AddEnv(key, value string) {
-
*ev = append(*ev, fmt.Sprintf("%s=%s", key, value))
-
}
-48
spindle/engine/envs_test.go
···
-
package engine
-
-
import (
-
"testing"
-
-
"github.com/stretchr/testify/assert"
-
)
-
-
func TestConstructEnvs(t *testing.T) {
-
tests := []struct {
-
name string
-
in map[string]string
-
want EnvVars
-
}{
-
{
-
name: "empty input",
-
in: make(map[string]string),
-
want: EnvVars{},
-
},
-
{
-
name: "single env var",
-
in: map[string]string{"FOO": "bar"},
-
want: EnvVars{"FOO=bar"},
-
},
-
{
-
name: "multiple env vars",
-
in: map[string]string{"FOO": "bar", "BAZ": "qux"},
-
want: EnvVars{"FOO=bar", "BAZ=qux"},
-
},
-
}
-
for _, tt := range tests {
-
t.Run(tt.name, func(t *testing.T) {
-
got := ConstructEnvs(tt.in)
-
if got == nil {
-
got = EnvVars{}
-
}
-
assert.ElementsMatch(t, tt.want, got)
-
})
-
}
-
}
-
-
func TestAddEnv(t *testing.T) {
-
ev := EnvVars{}
-
ev.AddEnv("FOO", "bar")
-
ev.AddEnv("BAZ", "qux")
-
want := EnvVars{"FOO=bar", "BAZ=qux"}
-
assert.ElementsMatch(t, want, ev)
-
}
-9
spindle/engine/errors.go
···
-
package engine
-
-
import "errors"
-
-
var (
-
ErrOOMKilled = errors.New("oom killed")
-
ErrTimedOut = errors.New("timed out")
-
ErrWorkflowFailed = errors.New("workflow failed")
-
)
-84
spindle/engine/logger.go
···
-
package engine
-
-
import (
-
"encoding/json"
-
"fmt"
-
"io"
-
"os"
-
"path/filepath"
-
"strings"
-
-
"tangled.sh/tangled.sh/core/spindle/models"
-
)
-
-
type WorkflowLogger struct {
-
file *os.File
-
encoder *json.Encoder
-
}
-
-
func NewWorkflowLogger(baseDir string, wid models.WorkflowId) (*WorkflowLogger, error) {
-
path := LogFilePath(baseDir, wid)
-
-
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
-
if err != nil {
-
return nil, fmt.Errorf("creating log file: %w", err)
-
}
-
-
return &WorkflowLogger{
-
file: file,
-
encoder: json.NewEncoder(file),
-
}, nil
-
}
-
-
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
-
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
-
return logFilePath
-
}
-
-
func (l *WorkflowLogger) Close() error {
-
return l.file.Close()
-
}
-
-
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
-
// TODO: emit stream
-
return &dataWriter{
-
logger: l,
-
stream: stream,
-
}
-
}
-
-
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
-
return &controlWriter{
-
logger: l,
-
idx: idx,
-
step: step,
-
}
-
}
-
-
type dataWriter struct {
-
logger *WorkflowLogger
-
stream string
-
}
-
-
func (w *dataWriter) Write(p []byte) (int, error) {
-
line := strings.TrimRight(string(p), "\r\n")
-
entry := models.NewDataLogLine(line, w.stream)
-
if err := w.logger.encoder.Encode(entry); err != nil {
-
return 0, err
-
}
-
return len(p), nil
-
}
-
-
type controlWriter struct {
-
logger *WorkflowLogger
-
idx int
-
step models.Step
-
}
-
-
func (w *controlWriter) Write(_ []byte) (int, error) {
-
entry := models.NewControlLogLine(w.idx, w.step)
-
if err := w.logger.encoder.Encode(entry); err != nil {
-
return 0, err
-
}
-
return len(w.step.Name), nil
-
}
+21
spindle/engines/nixery/ansi_stripper.go
···
+
package nixery
+
+
import (
+
"io"
+
+
"regexp"
+
)
+
+
// regex to match ANSI escape codes (e.g., color codes, cursor moves)
+
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
+
+
var re = regexp.MustCompile(ansi)
+
+
type ansiStrippingWriter struct {
+
underlying io.Writer
+
}
+
+
func (w *ansiStrippingWriter) Write(p []byte) (int, error) {
+
clean := re.ReplaceAll(p, []byte{})
+
return w.underlying.Write(clean)
+
}
+421
spindle/engines/nixery/engine.go
···
+
package nixery
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"io"
+
"log/slog"
+
"os"
+
"path"
+
"runtime"
+
"sync"
+
"time"
+
+
"github.com/docker/docker/api/types/container"
+
"github.com/docker/docker/api/types/image"
+
"github.com/docker/docker/api/types/mount"
+
"github.com/docker/docker/api/types/network"
+
"github.com/docker/docker/client"
+
"github.com/docker/docker/pkg/stdcopy"
+
"gopkg.in/yaml.v3"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/log"
+
"tangled.sh/tangled.sh/core/spindle/config"
+
"tangled.sh/tangled.sh/core/spindle/engine"
+
"tangled.sh/tangled.sh/core/spindle/models"
+
"tangled.sh/tangled.sh/core/spindle/secrets"
+
)
+
+
const (
+
workspaceDir = "/tangled/workspace"
+
homeDir = "/tangled/home"
+
)
+
+
type cleanupFunc func(context.Context) error
+
+
type Engine struct {
+
docker client.APIClient
+
l *slog.Logger
+
cfg *config.Config
+
+
cleanupMu sync.Mutex
+
cleanup map[string][]cleanupFunc
+
}
+
+
type Step struct {
+
name string
+
kind models.StepKind
+
command string
+
environment map[string]string
+
}
+
+
func (s Step) Name() string {
+
return s.name
+
}
+
+
func (s Step) Command() string {
+
return s.command
+
}
+
+
func (s Step) Kind() models.StepKind {
+
return s.kind
+
}
+
+
// setupSteps get added to start of Steps
+
type setupSteps []models.Step
+
+
// addStep adds a step to the beginning of the workflow's steps.
+
func (ss *setupSteps) addStep(step models.Step) {
+
*ss = append(*ss, step)
+
}
+
+
type addlFields struct {
+
image string
+
container string
+
env map[string]string
+
}
+
+
func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
+
swf := &models.Workflow{}
+
addl := addlFields{}
+
+
dwf := &struct {
+
Steps []struct {
+
Command string `yaml:"command"`
+
Name string `yaml:"name"`
+
Environment map[string]string `yaml:"environment"`
+
} `yaml:"steps"`
+
Dependencies map[string][]string `yaml:"dependencies"`
+
Environment map[string]string `yaml:"environment"`
+
}{}
+
err := yaml.Unmarshal([]byte(twf.Raw), &dwf)
+
if err != nil {
+
return nil, err
+
}
+
+
for _, dstep := range dwf.Steps {
+
sstep := Step{}
+
sstep.environment = dstep.Environment
+
sstep.command = dstep.Command
+
sstep.name = dstep.Name
+
sstep.kind = models.StepKindUser
+
swf.Steps = append(swf.Steps, sstep)
+
}
+
swf.Name = twf.Name
+
addl.env = dwf.Environment
+
addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery)
+
+
setup := &setupSteps{}
+
+
setup.addStep(nixConfStep())
+
setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev))
+
// this step could be empty
+
if s := dependencyStep(dwf.Dependencies); s != nil {
+
setup.addStep(*s)
+
}
+
+
// append setup steps in order to the start of workflow steps
+
swf.Steps = append(*setup, swf.Steps...)
+
swf.Data = addl
+
+
return swf, nil
+
}
+
+
func (e *Engine) WorkflowTimeout() time.Duration {
+
workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout
+
workflowTimeout, err := time.ParseDuration(workflowTimeoutStr)
+
if err != nil {
+
e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr)
+
workflowTimeout = 5 * time.Minute
+
}
+
+
return workflowTimeout
+
}
+
+
func workflowImage(deps map[string][]string, nixery string) string {
+
var dependencies string
+
for reg, ds := range deps {
+
if reg == "nixpkgs" {
+
dependencies = path.Join(ds...)
+
}
+
}
+
+
// load defaults from somewhere else
+
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
+
+
if runtime.GOARCH == "arm64" {
+
dependencies = path.Join("arm64", dependencies)
+
}
+
+
return path.Join(nixery, dependencies)
+
}
+
+
func New(ctx context.Context, cfg *config.Config) (*Engine, error) {
+
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
+
if err != nil {
+
return nil, err
+
}
+
+
l := log.FromContext(ctx).With("component", "spindle")
+
+
e := &Engine{
+
docker: dcli,
+
l: l,
+
cfg: cfg,
+
}
+
+
e.cleanup = make(map[string][]cleanupFunc)
+
+
return e, nil
+
}
+
+
func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error {
+
e.l.Info("setting up workflow", "workflow", wid)
+
+
_, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{
+
Driver: "bridge",
+
})
+
if err != nil {
+
return err
+
}
+
e.registerCleanup(wid, func(ctx context.Context) error {
+
return e.docker.NetworkRemove(ctx, networkName(wid))
+
})
+
+
addl := wf.Data.(addlFields)
+
+
reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{})
+
if err != nil {
+
e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error())
+
+
return fmt.Errorf("pulling image: %w", err)
+
}
+
defer reader.Close()
+
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",
+
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{
+
Mounts: []mount.Mount{
+
{
+
Type: mount.TypeTmpfs,
+
Target: "/tmp",
+
ReadOnly: false,
+
TmpfsOptions: &mount.TmpfsOptions{
+
Mode: 0o1777, // world-writeable sticky bit
+
Options: [][]string{
+
{"exec"},
+
},
+
},
+
},
+
},
+
ReadonlyRootfs: false,
+
CapDrop: []string{"ALL"},
+
CapAdd: []string{"CAP_DAC_OVERRIDE"},
+
SecurityOpt: []string{"no-new-privileges"},
+
ExtraHosts: []string{"host.docker.internal:host-gateway"},
+
}, nil, nil, "")
+
if err != nil {
+
return fmt.Errorf("creating container: %w", err)
+
}
+
e.registerCleanup(wid, func(ctx context.Context) error {
+
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
+
if err != nil {
+
return err
+
}
+
+
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
+
RemoveVolumes: true,
+
RemoveLinks: false,
+
Force: false,
+
})
+
})
+
+
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
+
if err != nil {
+
return fmt.Errorf("starting container: %w", err)
+
}
+
+
mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{
+
Cmd: []string{"mkdir", "-p", workspaceDir, homeDir},
+
AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe??
+
AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default")
+
})
+
if err != nil {
+
return err
+
}
+
+
// This actually *starts* the command. Thanks, Docker!
+
execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{})
+
if err != nil {
+
return err
+
}
+
defer execResp.Close()
+
+
// This is apparently best way to wait for the command to complete.
+
_, err = io.ReadAll(execResp.Reader)
+
if err != nil {
+
return err
+
}
+
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
+
if err != nil {
+
return err
+
}
+
+
if execInspectResp.ExitCode != 0 {
+
return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode)
+
} else if execInspectResp.Running {
+
return errors.New("mkdir is somehow still running??")
+
}
+
+
addl.container = resp.ID
+
wf.Data = addl
+
+
return nil
+
}
+
+
func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error {
+
addl := w.Data.(addlFields)
+
workflowEnvs := ConstructEnvs(addl.env)
+
// TODO(winter): should SetupWorkflow also have secret access?
+
// IMO yes, but probably worth thinking on.
+
for _, s := range secrets {
+
workflowEnvs.AddEnv(s.Key, s.Value)
+
}
+
+
step := w.Steps[idx].(Step)
+
+
select {
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
}
+
+
envs := append(EnvVars(nil), workflowEnvs...)
+
for k, v := range step.environment {
+
envs.AddEnv(k, v)
+
}
+
envs.AddEnv("HOME", homeDir)
+
+
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)
+
}
+
+
// start tailing logs in background
+
tailDone := make(chan error, 1)
+
go func() {
+
tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step)
+
}()
+
+
select {
+
case <-tailDone:
+
+
case <-ctx.Done():
+
// cleanup will be handled by DestroyWorkflow, since
+
// Docker doesn't provide an API to kill an exec run
+
// (sure, we could grab the PID and kill it ourselves,
+
// but that's wasted effort)
+
e.l.Warn("step timed out", "step", step.Name)
+
+
<-tailDone
+
+
return engine.ErrTimedOut
+
}
+
+
select {
+
case <-ctx.Done():
+
return ctx.Err()
+
default:
+
}
+
+
execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID)
+
if err != nil {
+
return err
+
}
+
+
if execInspectResp.ExitCode != 0 {
+
inspectResp, err := e.docker.ContainerInspect(ctx, addl.container)
+
if err != nil {
+
return err
+
}
+
+
e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled)
+
+
if inspectResp.State.OOMKilled {
+
return ErrOOMKilled
+
}
+
return engine.ErrWorkflowFailed
+
}
+
+
return nil
+
}
+
+
func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
+
if wfLogger == nil {
+
return nil
+
}
+
+
// This actually *starts* the command. Thanks, Docker!
+
logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{})
+
if err != nil {
+
return err
+
}
+
defer logs.Close()
+
+
_, err = stdcopy.StdCopy(
+
wfLogger.DataWriter("stdout"),
+
wfLogger.DataWriter("stderr"),
+
logs.Reader,
+
)
+
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+
return fmt.Errorf("failed to copy logs: %w", err)
+
}
+
+
return nil
+
}
+
+
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
+
e.cleanupMu.Lock()
+
key := wid.String()
+
+
fns := e.cleanup[key]
+
delete(e.cleanup, key)
+
e.cleanupMu.Unlock()
+
+
for _, fn := range fns {
+
if err := fn(ctx); err != nil {
+
e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err)
+
}
+
}
+
return nil
+
}
+
+
func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) {
+
e.cleanupMu.Lock()
+
defer e.cleanupMu.Unlock()
+
+
key := wid.String()
+
e.cleanup[key] = append(e.cleanup[key], fn)
+
}
+
+
func networkName(wid models.WorkflowId) string {
+
return fmt.Sprintf("workflow-network-%s", wid)
+
}
+28
spindle/engines/nixery/envs.go
···
+
package nixery
+
+
import (
+
"fmt"
+
)
+
+
type EnvVars []string
+
+
// ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value}
+
// representation into a docker-friendly []string{"KEY=value", ...} slice.
+
func ConstructEnvs(envs map[string]string) EnvVars {
+
var dockerEnvs EnvVars
+
for k, v := range envs {
+
ev := fmt.Sprintf("%s=%s", k, v)
+
dockerEnvs = append(dockerEnvs, ev)
+
}
+
return dockerEnvs
+
}
+
+
// Slice returns the EnvVar as a []string slice.
+
func (ev EnvVars) Slice() []string {
+
return ev
+
}
+
+
// AddEnv adds a key=value string to the EnvVar.
+
func (ev *EnvVars) AddEnv(key, value string) {
+
*ev = append(*ev, fmt.Sprintf("%s=%s", key, value))
+
}
+48
spindle/engines/nixery/envs_test.go
···
+
package nixery
+
+
import (
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
)
+
+
func TestConstructEnvs(t *testing.T) {
+
tests := []struct {
+
name string
+
in map[string]string
+
want EnvVars
+
}{
+
{
+
name: "empty input",
+
in: make(map[string]string),
+
want: EnvVars{},
+
},
+
{
+
name: "single env var",
+
in: map[string]string{"FOO": "bar"},
+
want: EnvVars{"FOO=bar"},
+
},
+
{
+
name: "multiple env vars",
+
in: map[string]string{"FOO": "bar", "BAZ": "qux"},
+
want: EnvVars{"FOO=bar", "BAZ=qux"},
+
},
+
}
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
got := ConstructEnvs(tt.in)
+
if got == nil {
+
got = EnvVars{}
+
}
+
assert.ElementsMatch(t, tt.want, got)
+
})
+
}
+
}
+
+
func TestAddEnv(t *testing.T) {
+
ev := EnvVars{}
+
ev.AddEnv("FOO", "bar")
+
ev.AddEnv("BAZ", "qux")
+
want := EnvVars{"FOO=bar", "BAZ=qux"}
+
assert.ElementsMatch(t, want, ev)
+
}
+7
spindle/engines/nixery/errors.go
···
+
package nixery
+
+
import "errors"
+
+
var (
+
ErrOOMKilled = errors.New("oom killed")
+
)
+126
spindle/engines/nixery/setup_steps.go
···
+
package nixery
+
+
import (
+
"fmt"
+
"path"
+
"strings"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/workflow"
+
)
+
+
func nixConfStep() Step {
+
setupCmd := `mkdir -p /etc/nix
+
echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
+
echo 'build-users-group = ' >> /etc/nix/nix.conf`
+
return Step{
+
command: setupCmd,
+
name: "Configure Nix",
+
}
+
}
+
+
// cloneOptsAsSteps processes clone options and adds corresponding steps
+
// to the beginning of the workflow's step list if cloning is not skipped.
+
//
+
// the steps to do here are:
+
// - git init
+
// - git remote add origin <url>
+
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
+
// - git checkout FETCH_HEAD
+
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
+
if twf.Clone.Skip {
+
return Step{}
+
}
+
+
var commands []string
+
+
// initialize git repo in workspace
+
commands = append(commands, "git init")
+
+
// add repo as git remote
+
scheme := "https://"
+
if dev {
+
scheme = "http://"
+
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
+
}
+
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
+
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
+
+
// run git fetch
+
{
+
var fetchArgs []string
+
+
// default clone depth is 1
+
depth := 1
+
if twf.Clone.Depth > 1 {
+
depth = int(twf.Clone.Depth)
+
}
+
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
+
+
// optionally recurse submodules
+
if twf.Clone.Submodules {
+
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
+
}
+
+
// set remote to fetch from
+
fetchArgs = append(fetchArgs, "origin")
+
+
// set revision to checkout
+
switch workflow.TriggerKind(tr.Kind) {
+
case workflow.TriggerKindManual:
+
// TODO: unimplemented
+
case workflow.TriggerKindPush:
+
fetchArgs = append(fetchArgs, tr.Push.NewSha)
+
case workflow.TriggerKindPullRequest:
+
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
+
}
+
+
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
+
}
+
+
// run git checkout
+
commands = append(commands, "git checkout FETCH_HEAD")
+
+
cloneStep := Step{
+
command: strings.Join(commands, "\n"),
+
name: "Clone repository into workspace",
+
}
+
return cloneStep
+
}
+
+
// dependencyStep processes dependencies defined in the workflow.
+
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
+
// all packages and adds a single 'nix profile install' step to the
+
// beginning of the workflow's step list.
+
func dependencyStep(deps map[string][]string) *Step {
+
var customPackages []string
+
+
for registry, packages := range deps {
+
if registry == "nixpkgs" {
+
continue
+
}
+
+
if len(packages) == 0 {
+
customPackages = append(customPackages, registry)
+
}
+
// collect packages from custom registries
+
for _, pkg := range packages {
+
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
+
}
+
}
+
+
if len(customPackages) > 0 {
+
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
+
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
+
installStep := Step{
+
command: cmd,
+
name: "Install custom dependencies",
+
environment: map[string]string{
+
"NIX_NO_COLOR": "1",
+
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
+
},
+
}
+
return &installStep
+
}
+
return nil
+
}
+8 -4
spindle/ingester.go
···
switch e.Commit.Collection {
case tangled.SpindleMemberNSID:
-
s.ingestMember(ctx, e)
+
err = s.ingestMember(ctx, e)
case tangled.RepoNSID:
-
s.ingestRepo(ctx, e)
+
err = s.ingestRepo(ctx, e)
case tangled.RepoCollaboratorNSID:
-
s.ingestCollaborator(ctx, e)
+
err = s.ingestCollaborator(ctx, e)
}
-
return err
+
if err != nil {
+
s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err)
+
}
+
+
return nil
}
}
+17
spindle/models/engine.go
···
+
package models
+
+
import (
+
"context"
+
"time"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/spindle/secrets"
+
)
+
+
type Engine interface {
+
InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error)
+
SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error
+
WorkflowTimeout() time.Duration
+
DestroyWorkflow(ctx context.Context, wid WorkflowId) error
+
RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error
+
}
+82
spindle/models/logger.go
···
+
package models
+
+
import (
+
"encoding/json"
+
"fmt"
+
"io"
+
"os"
+
"path/filepath"
+
"strings"
+
)
+
+
type WorkflowLogger struct {
+
file *os.File
+
encoder *json.Encoder
+
}
+
+
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
+
path := LogFilePath(baseDir, wid)
+
+
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+
if err != nil {
+
return nil, fmt.Errorf("creating log file: %w", err)
+
}
+
+
return &WorkflowLogger{
+
file: file,
+
encoder: json.NewEncoder(file),
+
}, nil
+
}
+
+
func LogFilePath(baseDir string, workflowID WorkflowId) string {
+
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
+
return logFilePath
+
}
+
+
func (l *WorkflowLogger) Close() error {
+
return l.file.Close()
+
}
+
+
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
+
// TODO: emit stream
+
return &dataWriter{
+
logger: l,
+
stream: stream,
+
}
+
}
+
+
func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer {
+
return &controlWriter{
+
logger: l,
+
idx: idx,
+
step: step,
+
}
+
}
+
+
type dataWriter struct {
+
logger *WorkflowLogger
+
stream string
+
}
+
+
func (w *dataWriter) Write(p []byte) (int, error) {
+
line := strings.TrimRight(string(p), "\r\n")
+
entry := NewDataLogLine(line, w.stream)
+
if err := w.logger.encoder.Encode(entry); err != nil {
+
return 0, err
+
}
+
return len(p), nil
+
}
+
+
type controlWriter struct {
+
logger *WorkflowLogger
+
idx int
+
step Step
+
}
+
+
func (w *controlWriter) Write(_ []byte) (int, error) {
+
entry := NewControlLogLine(w.idx, w.step)
+
if err := w.logger.encoder.Encode(entry); err != nil {
+
return 0, err
+
}
+
return len(w.step.Name()), nil
+
}
+3 -3
spindle/models/models.go
···
func NewControlLogLine(idx int, step Step) LogLine {
return LogLine{
Kind: LogKindControl,
-
Content: step.Name,
+
Content: step.Name(),
StepId: idx,
-
StepKind: step.Kind,
-
StepCommand: step.Command,
+
StepKind: step.Kind(),
+
StepCommand: step.Command(),
}
}
+8 -103
spindle/models/pipeline.go
···
package models
-
import (
-
"path"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/spindle/config"
-
)
-
type Pipeline struct {
RepoOwner string
RepoName string
-
Workflows []Workflow
+
Workflows map[Engine][]Workflow
}
-
type Step struct {
-
Command string
-
Name string
-
Environment map[string]string
-
Kind StepKind
+
type Step interface {
+
Name() string
+
Command() string
+
Kind() StepKind
}
type StepKind int
···
)
type Workflow struct {
-
Steps []Step
-
Environment map[string]string
-
Name string
-
Image string
-
}
-
-
// setupSteps get added to start of Steps
-
type setupSteps []Step
-
-
// addStep adds a step to the beginning of the workflow's steps.
-
func (ss *setupSteps) addStep(step Step) {
-
*ss = append(*ss, step)
-
}
-
-
// ToPipeline converts a tangled.Pipeline into a model.Pipeline.
-
// In the process, dependencies are resolved: nixpkgs deps
-
// are constructed atop nixery and set as the Workflow.Image,
-
// and ones from custom registries
-
func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline {
-
workflows := []Workflow{}
-
-
for _, twf := range pl.Workflows {
-
swf := &Workflow{}
-
for _, tstep := range twf.Steps {
-
sstep := Step{}
-
sstep.Environment = stepEnvToMap(tstep.Environment)
-
sstep.Command = tstep.Command
-
sstep.Name = tstep.Name
-
sstep.Kind = StepKindUser
-
swf.Steps = append(swf.Steps, sstep)
-
}
-
swf.Name = twf.Name
-
swf.Environment = workflowEnvToMap(twf.Environment)
-
swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery)
-
-
setup := &setupSteps{}
-
-
setup.addStep(nixConfStep())
-
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata, cfg.Server.Dev))
-
// this step could be empty
-
if s := dependencyStep(*twf); s != nil {
-
setup.addStep(*s)
-
}
-
-
// append setup steps in order to the start of workflow steps
-
swf.Steps = append(*setup, swf.Steps...)
-
-
workflows = append(workflows, *swf)
-
}
-
repoOwner := pl.TriggerMetadata.Repo.Did
-
repoName := pl.TriggerMetadata.Repo.Repo
-
return &Pipeline{
-
RepoOwner: repoOwner,
-
RepoName: repoName,
-
Workflows: workflows,
-
}
-
}
-
-
func workflowEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
-
envMap := map[string]string{}
-
for _, env := range envs {
-
if env != nil {
-
envMap[env.Key] = env.Value
-
}
-
}
-
return envMap
-
}
-
-
func stepEnvToMap(envs []*tangled.Pipeline_Pair) map[string]string {
-
envMap := map[string]string{}
-
for _, env := range envs {
-
if env != nil {
-
envMap[env.Key] = env.Value
-
}
-
}
-
return envMap
-
}
-
-
func workflowImage(deps []*tangled.Pipeline_Dependency, nixery string) string {
-
var dependencies string
-
for _, d := range deps {
-
if d.Registry == "nixpkgs" {
-
dependencies = path.Join(d.Packages...)
-
}
-
}
-
-
// load defaults from somewhere else
-
dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix")
-
-
return path.Join(nixery, dependencies)
+
Steps []Step
+
Name string
+
Data any
}
-128
spindle/models/setup_steps.go
···
-
package models
-
-
import (
-
"fmt"
-
"path"
-
"strings"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/workflow"
-
)
-
-
func nixConfStep() Step {
-
setupCmd := `echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf
-
echo 'build-users-group = ' >> /etc/nix/nix.conf`
-
return Step{
-
Command: setupCmd,
-
Name: "Configure Nix",
-
}
-
}
-
-
// cloneOptsAsSteps processes clone options and adds corresponding steps
-
// to the beginning of the workflow's step list if cloning is not skipped.
-
//
-
// the steps to do here are:
-
// - git init
-
// - git remote add origin <url>
-
// - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha>
-
// - git checkout FETCH_HEAD
-
func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step {
-
if twf.Clone.Skip {
-
return Step{}
-
}
-
-
var commands []string
-
-
// initialize git repo in workspace
-
commands = append(commands, "git init")
-
-
// add repo as git remote
-
scheme := "https://"
-
if dev {
-
scheme = "http://"
-
tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal")
-
}
-
url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo)
-
commands = append(commands, fmt.Sprintf("git remote add origin %s", url))
-
-
// run git fetch
-
{
-
var fetchArgs []string
-
-
// default clone depth is 1
-
depth := 1
-
if twf.Clone.Depth > 1 {
-
depth = int(twf.Clone.Depth)
-
}
-
fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth))
-
-
// optionally recurse submodules
-
if twf.Clone.Submodules {
-
fetchArgs = append(fetchArgs, "--recurse-submodules=yes")
-
}
-
-
// set remote to fetch from
-
fetchArgs = append(fetchArgs, "origin")
-
-
// set revision to checkout
-
switch workflow.TriggerKind(tr.Kind) {
-
case workflow.TriggerKindManual:
-
// TODO: unimplemented
-
case workflow.TriggerKindPush:
-
fetchArgs = append(fetchArgs, tr.Push.NewSha)
-
case workflow.TriggerKindPullRequest:
-
fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha)
-
}
-
-
commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")))
-
}
-
-
// run git checkout
-
commands = append(commands, "git checkout FETCH_HEAD")
-
-
cloneStep := Step{
-
Command: strings.Join(commands, "\n"),
-
Name: "Clone repository into workspace",
-
}
-
return cloneStep
-
}
-
-
// dependencyStep processes dependencies defined in the workflow.
-
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
-
// all packages and adds a single 'nix profile install' step to the
-
// beginning of the workflow's step list.
-
func dependencyStep(twf tangled.Pipeline_Workflow) *Step {
-
var customPackages []string
-
-
for _, d := range twf.Dependencies {
-
registry := d.Registry
-
packages := d.Packages
-
-
if registry == "nixpkgs" {
-
continue
-
}
-
-
if len(packages) == 0 {
-
customPackages = append(customPackages, registry)
-
}
-
// collect packages from custom registries
-
for _, pkg := range packages {
-
customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg))
-
}
-
}
-
-
if len(customPackages) > 0 {
-
installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install"
-
cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " "))
-
installStep := Step{
-
Command: cmd,
-
Name: "Install custom dependencies",
-
Environment: map[string]string{
-
"NIX_NO_COLOR": "1",
-
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
-
},
-
}
-
return &installStep
-
}
-
return nil
-
}
+1 -1
spindle/secrets/sqlite.go
···
}
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
-
db, err := sql.Open("sqlite3", dbPath)
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
+50 -18
spindle/server.go
···
"tangled.sh/tangled.sh/core/spindle/config"
"tangled.sh/tangled.sh/core/spindle/db"
"tangled.sh/tangled.sh/core/spindle/engine"
+
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
"tangled.sh/tangled.sh/core/spindle/models"
"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
···
e *rbac.Enforcer
l *slog.Logger
n *notifier.Notifier
-
eng *engine.Engine
+
engs map[string]models.Engine
jq *queue.Queue
cfg *config.Config
ks *eventconsumer.Consumer
···
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
}
-
eng, err := engine.New(ctx, cfg, d, &n, vault)
+
nixeryEng, err := nixery.New(ctx, cfg)
if err != nil {
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,
···
db: d,
l: logger,
n: &n,
-
eng: eng,
+
engs: map[string]models.Engine{"nixery": nixeryEng},
jq: jq,
cfg: cfg,
res: resolver,
···
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,
-
Engine: s.eng,
-
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()
···
Rkey: msg.Rkey,
}
+
workflows := make(map[models.Engine][]models.Workflow)
+
for _, w := range tpl.Workflows {
if w != nil {
-
err := s.db.StatusPending(models.WorkflowId{
+
if _, ok := s.engs[w.Engine]; !ok {
+
err = s.db.StatusFailed(models.WorkflowId{
+
PipelineId: pipelineId,
+
Name: w.Name,
+
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
+
if err != nil {
+
return err
+
}
+
+
continue
+
}
+
+
eng := s.engs[w.Engine]
+
+
if _, ok := workflows[eng]; !ok {
+
workflows[eng] = []models.Workflow{}
+
}
+
+
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
+
if err != nil {
+
return err
+
}
+
+
workflows[eng] = append(workflows[eng], *ewf)
+
+
err = s.db.StatusPending(models.WorkflowId{
PipelineId: pipelineId,
Name: w.Name,
}, s.n)
···
}
}
-
spl := models.ToPipeline(tpl, *s.cfg)
-
ok := s.jq.Enqueue(queue.Job{
Run: func() error {
-
s.eng.StartWorkflows(ctx, spl, pipelineId)
+
engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
+
RepoOwner: tpl.TriggerMetadata.Repo.Did,
+
RepoName: tpl.TriggerMetadata.Repo.Repo,
+
Workflows: workflows,
+
}, pipelineId)
return nil
},
OnFail: func(jobError error) {
+32 -2
spindle/stream.go
···
"fmt"
"io"
"net/http"
+
"os"
"strconv"
"time"
-
"tangled.sh/tangled.sh/core/spindle/engine"
"tangled.sh/tangled.sh/core/spindle/models"
"github.com/go-chi/chi/v5"
···
}
isFinished := models.StatusKind(status.Status).IsFinish()
-
filePath := engine.LogFilePath(s.cfg.Pipelines.LogDir, wid)
+
filePath := models.LogFilePath(s.cfg.Server.LogDir, wid)
+
+
if status.Status == models.StatusKindFailed.String() && status.Error != nil {
+
if _, err := os.Stat(filePath); os.IsNotExist(err) {
+
msgs := []models.LogLine{
+
{
+
Kind: models.LogKindControl,
+
Content: "",
+
StepId: 0,
+
StepKind: models.StepKindUser,
+
},
+
{
+
Kind: models.LogKindData,
+
Content: *status.Error,
+
},
+
}
+
+
for _, msg := range msgs {
+
b, err := json.Marshal(msg)
+
if err != nil {
+
return err
+
}
+
+
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
+
return fmt.Errorf("failed to write to websocket: %w", err)
+
}
+
}
+
+
return nil
+
}
+
}
config := tail.Config{
Follow: !isFinished,
+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
}
+20 -108
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/rbac"
"tangled.sh/tangled.sh/core/spindle/config"
"tangled.sh/tangled.sh/core/spindle/db"
-
"tangled.sh/tangled.sh/core/spindle/engine"
+
"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
-
Engine *engine.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
-
}
-
-
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),
-
)
+
r.Group(func(r chi.Router) {
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
-
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)
-
}
-
}
-
-
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),
-
)
-
}
+
// service query endpoints (no auth required)
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
-
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)
+1 -9
tailwind.config.js
···
css: {
maxWidth: "none",
pre: {
-
backgroundColor: colors.gray[100],
-
color: colors.black,
-
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
-
},
-
li: {
-
"@apply inline-block w-full my-0 py-0": {},
-
},
-
"ul, ol": {
-
"@apply my-1 py-0": {},
+
"@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {},
},
code: {
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+62 -41
workflow/compile.go
···
package workflow
import (
+
"errors"
"fmt"
"tangled.sh/tangled.sh/core/api/tangled"
)
+
type RawWorkflow struct {
+
Name string
+
Contents []byte
+
}
+
+
type RawPipeline = []RawWorkflow
+
type Compiler struct {
Trigger tangled.Pipeline_TriggerMetadata
Diagnostics Diagnostics
}
type Diagnostics struct {
-
Errors []error
+
Errors []Error
Warnings []Warning
}
+
func (d *Diagnostics) IsEmpty() bool {
+
return len(d.Errors) == 0 && len(d.Warnings) == 0
+
}
+
func (d *Diagnostics) Combine(o Diagnostics) {
d.Errors = append(d.Errors, o.Errors...)
d.Warnings = append(d.Warnings, o.Warnings...)
···
d.Warnings = append(d.Warnings, Warning{path, kind, reason})
}
-
func (d *Diagnostics) AddError(err error) {
-
d.Errors = append(d.Errors, err)
+
func (d *Diagnostics) AddError(path string, err error) {
+
d.Errors = append(d.Errors, Error{path, err})
}
func (d Diagnostics) IsErr() bool {
return len(d.Errors) != 0
}
+
type Error struct {
+
Path string
+
Error error
+
}
+
+
func (e Error) String() string {
+
return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error())
+
}
+
type Warning struct {
Path string
Type WarningKind
Reason string
}
+
func (w Warning) String() string {
+
return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason)
+
}
+
+
var (
+
MissingEngine error = errors.New("missing engine")
+
)
+
type WarningKind string
var (
···
InvalidConfiguration WarningKind = "invalid configuration"
)
+
func (compiler *Compiler) Parse(p RawPipeline) Pipeline {
+
var pp Pipeline
+
+
for _, w := range p {
+
wf, err := FromFile(w.Name, w.Contents)
+
if err != nil {
+
compiler.Diagnostics.AddError(w.Name, err)
+
continue
+
}
+
+
pp = append(pp, wf)
+
}
+
+
return pp
+
}
+
// convert a repositories' workflow files into a fully compiled pipeline that runners accept
func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline {
cp := tangled.Pipeline{
TriggerMetadata: &compiler.Trigger,
}
-
for _, w := range p {
-
cw := compiler.compileWorkflow(w)
+
for _, wf := range p {
+
cw := compiler.compileWorkflow(wf)
-
// empty workflows are not added to the pipeline
-
if len(cw.Steps) == 0 {
+
if cw == nil {
continue
}
-
cp.Workflows = append(cp.Workflows, &cw)
+
cp.Workflows = append(cp.Workflows, cw)
}
return cp
}
-
func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow {
-
cw := tangled.Pipeline_Workflow{}
+
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
+
cw := &tangled.Pipeline_Workflow{}
if !w.Match(compiler.Trigger) {
compiler.Diagnostics.AddWarning(
···
WorkflowSkipped,
fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind),
)
-
return cw
-
}
-
-
if len(w.Steps) == 0 {
-
compiler.Diagnostics.AddWarning(
-
w.Name,
-
WorkflowSkipped,
-
"empty workflow",
-
)
-
return cw
+
return nil
}
// validate clone options
compiler.analyzeCloneOptions(w)
cw.Name = w.Name
-
cw.Dependencies = w.Dependencies.AsRecord()
-
for _, s := range w.Steps {
-
step := tangled.Pipeline_Step{
-
Command: s.Command,
-
Name: s.Name,
-
}
-
for k, v := range s.Environment {
-
e := &tangled.Pipeline_Pair{
-
Key: k,
-
Value: v,
-
}
-
step.Environment = append(step.Environment, e)
-
}
-
cw.Steps = append(cw.Steps, &step)
+
+
if w.Engine == "" {
+
compiler.Diagnostics.AddError(w.Name, MissingEngine)
+
return nil
}
-
for k, v := range w.Environment {
-
e := &tangled.Pipeline_Pair{
-
Key: k,
-
Value: v,
-
}
-
cw.Environment = append(cw.Environment, e)
-
}
+
+
cw.Engine = w.Engine
+
cw.Raw = w.Raw
o := w.CloneOpts.AsRecord()
cw.Clone = &o
+23 -29
workflow/compile_test.go
···
func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) {
wf := Workflow{
-
Name: ".tangled/workflows/test.yml",
-
When: when,
-
Steps: []Step{
-
{Name: "Test", Command: "go test ./..."},
-
},
+
Name: ".tangled/workflows/test.yml",
+
Engine: "nixery",
+
When: when,
CloneOpts: CloneOpts{}, // default true
}
···
assert.False(t, c.Diagnostics.IsErr())
}
-
func TestCompileWorkflow_EmptySteps(t *testing.T) {
-
wf := Workflow{
-
Name: ".tangled/workflows/empty.yml",
-
When: when,
-
Steps: []Step{}, // no steps
-
}
-
-
c := Compiler{Trigger: trigger}
-
cp := c.Compile([]Workflow{wf})
-
-
assert.Len(t, cp.Workflows, 0)
-
assert.Len(t, c.Diagnostics.Warnings, 1)
-
assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type)
-
}
-
func TestCompileWorkflow_TriggerMismatch(t *testing.T) {
wf := Workflow{
-
Name: ".tangled/workflows/mismatch.yml",
+
Name: ".tangled/workflows/mismatch.yml",
+
Engine: "nixery",
When: []Constraint{
{
Event: []string{"push"},
Branch: []string{"master"}, // different branch
},
},
-
Steps: []Step{
-
{Name: "Lint", Command: "golint ./..."},
-
},
}
c := Compiler{Trigger: trigger}
···
func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) {
wf := Workflow{
-
Name: ".tangled/workflows/clone_skip.yml",
-
When: when,
-
Steps: []Step{
-
{Name: "Skip", Command: "echo skip"},
-
},
+
Name: ".tangled/workflows/clone_skip.yml",
+
Engine: "nixery",
+
When: when,
CloneOpts: CloneOpts{
Skip: true,
Depth: 1,
···
assert.Len(t, c.Diagnostics.Warnings, 1)
assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type)
}
+
+
func TestCompileWorkflow_MissingEngine(t *testing.T) {
+
wf := Workflow{
+
Name: ".tangled/workflows/missing_engine.yml",
+
When: when,
+
Engine: "",
+
}
+
+
c := Compiler{Trigger: trigger}
+
cp := c.Compile([]Workflow{wf})
+
+
assert.Len(t, cp.Workflows, 0)
+
assert.Len(t, c.Diagnostics.Errors, 1)
+
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
+
}
+6 -33
workflow/def.go
···
// this is simply a structural representation of the workflow file
Workflow struct {
-
Name string `yaml:"-"` // name of the workflow file
-
When []Constraint `yaml:"when"`
-
Dependencies Dependencies `yaml:"dependencies"`
-
Steps []Step `yaml:"steps"`
-
Environment map[string]string `yaml:"environment"`
-
CloneOpts CloneOpts `yaml:"clone"`
+
Name string `yaml:"-"` // name of the workflow file
+
Engine string `yaml:"engine"`
+
When []Constraint `yaml:"when"`
+
CloneOpts CloneOpts `yaml:"clone"`
+
Raw string `yaml:"-"`
}
Constraint struct {
Event StringList `yaml:"event"`
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
}
-
-
Dependencies map[string][]string
CloneOpts struct {
Skip bool `yaml:"skip"`
Depth int `yaml:"depth"`
IncludeSubmodules bool `yaml:"submodules"`
-
}
-
-
Step struct {
-
Name string `yaml:"name"`
-
Command string `yaml:"command"`
-
Environment map[string]string `yaml:"environment"`
}
StringList []string
···
}
wf.Name = name
+
wf.Raw = string(contents)
return wf, nil
}
···
}
return errors.New("failed to unmarshal StringOrSlice")
-
}
-
-
// conversion utilities to atproto records
-
func (d Dependencies) AsRecord() []*tangled.Pipeline_Dependency {
-
var deps []*tangled.Pipeline_Dependency
-
for registry, packages := range d {
-
deps = append(deps, &tangled.Pipeline_Dependency{
-
Registry: registry,
-
Packages: packages,
-
})
-
}
-
return deps
-
}
-
-
func (s Step) AsRecord() tangled.Pipeline_Step {
-
return tangled.Pipeline_Step{
-
Command: s.Command,
-
Name: s.Name,
-
}
}
func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
···
yamlData := `
when:
- event: ["push", "pull_request"]
-
branch: ["main", "develop"]
-
-
dependencies:
-
nixpkgs:
-
- go
-
- git
-
- curl
-
-
steps:
-
- name: "Test"
-
command: |
-
go test ./...`
+
branch: ["main", "develop"]`
wf, err := FromFile("test.yml", []byte(yamlData))
assert.NoError(t, err, "YAML should unmarshal without error")
···
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event)
-
assert.Len(t, wf.Steps, 1)
-
assert.Equal(t, "Test", wf.Steps[0].Name)
-
assert.Equal(t, "go test ./...", wf.Steps[0].Command)
-
-
pkgs, ok := wf.Dependencies["nixpkgs"]
-
assert.True(t, ok, "`nixpkgs` should be present in dependencies")
-
assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs)
-
assert.False(t, wf.CloneOpts.Skip, "Skip should default to false")
}
-
func TestUnmarshalCustomRegistry(t *testing.T) {
-
yamlData := `
-
when:
-
- event: push
-
branch: main
-
-
dependencies:
-
git+https://tangled.sh/@oppi.li/tbsp:
-
- tbsp
-
git+https://git.peppe.rs/languages/statix:
-
- statix
-
-
steps:
-
- name: "Check"
-
command: |
-
statix check`
-
-
wf, err := FromFile("test.yml", []byte(yamlData))
-
assert.NoError(t, err, "YAML should unmarshal without error")
-
-
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
-
assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch)
-
-
assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"])
-
assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"])
-
}
-
func TestUnmarshalCloneFalse(t *testing.T) {
yamlData := `
when:
···
clone:
skip: true
-
-
dependencies:
-
nixpkgs:
-
- python3
-
-
steps:
-
- name: Notify
-
command: |
-
python3 ./notify.py
`
wf, err := FromFile("test.yml", []byte(yamlData))
···
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
-
-
func TestUnmarshalEnv(t *testing.T) {
-
yamlData := `
-
when:
-
- event: ["pull_request_close"]
-
-
clone:
-
skip: false
-
-
environment:
-
HOME: /home/foo bar/baz
-
CGO_ENABLED: 1
-
-
steps:
-
- name: Something
-
command: echo "hello"
-
environment:
-
FOO: bar
-
BAZ: qux
-
`
-
-
wf, err := FromFile("test.yml", []byte(yamlData))
-
assert.NoError(t, err)
-
-
assert.Len(t, wf.Environment, 2)
-
assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"])
-
assert.Equal(t, "1", wf.Environment["CGO_ENABLED"])
-
assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"])
-
assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"])
-
}
+125
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 RepoNotFoundError = NewXrpcError(
+
WithTag("RepoNotFound"),
+
WithMessage("failed to access repository"),
+
)
+
+
var RefNotFoundError = NewXrpcError(
+
WithTag("RefNotFound"),
+
WithMessage("failed to access ref"),
+
)
+
+
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)
+
}