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

Compare changes

Choose any two refs to compare.

Changed files
+24040 -11358
.air
.tangled
.zed
api
appview
cache
session
config
db
dns
issues
knots
middleware
oauth
pages
markup
repoinfo
templates
errors
fragments
knots
layouts
legal
repo
spindles
strings
timeline
user
posthog
pulls
repo
reporesolver
serververify
settings
signup
spindles
spindleverify
state
strings
validator
xrpcclient
avatar
src
cmd
appview
genjwks
punchcardPopulate
docs
eventconsumer
cursor
guard
hook
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"]
+4
.gitignore
···
.DS_Store
.env
*.rdb
+
.envrc
+
# Created if following hacking.md
+
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 -11
.tangled/workflows/fmt.yml
···
- event: ["push", "pull_request"]
branch: ["master"]
-
dependencies:
-
nixpkgs:
-
- go
-
- alejandra
+
engine: nixery
steps:
-
- name: "nix fmt"
-
command: |
-
alejandra -c nix/**/*.nix flake.nix
-
-
- name: "go fmt"
+
- name: "Check formatting"
command: |
-
gofmt -l .
-
+
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"]
-
}
-
}
-
}
-
}
+1001 -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)
···
t.Subject = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+
func (t *Knot) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{162}); err != nil {
+
return err
+
}
+
+
// t.LexiconTypeID (string) (string)
+
if len("$type") > 1000000 {
+
return xerrors.Errorf("Value in field \"$type\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("$type")); err != nil {
+
return err
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil {
+
return err
+
}
+
+
// t.CreatedAt (string) (string)
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
return err
+
}
+
+
if len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = Knot{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("Knot: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 9)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
// t.CreatedAt (string) (string)
case "createdAt":
···
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:
-
// 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_ManualTriggerData) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
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
+
// t.Engine (string) (string)
+
if len("engine") > 1000000 {
+
return xerrors.Errorf("Value in field \"engine\" was too long")
-
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")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("environment")); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("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 {
+
if _, err := cw.WriteString(string("engine")); err != nil {
return err
-
}
-
for _, v := range t.Environment {
-
if err := v.MarshalCBOR(cw); err != nil {
-
return err
-
}
-
-
// t.Dependencies ([]*tangled.Pipeline_Dependency) (slice)
-
if len("dependencies") > 1000000 {
-
return xerrors.Errorf("Value in field \"dependencies\" was too long")
+
if len(t.Engine) > 1000000 {
+
return xerrors.Errorf("Value in field t.Engine was too long")
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil {
return err
-
if _, err := cw.WriteString(string("dependencies")); 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":
+
// t.Engine (string) (string)
+
case "engine":
-
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)
-
}
-
-
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:
···
return nil
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{164}); 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.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.repo.collaborator"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
+
return err
+
}
+
+
// t.Subject (string) (string)
+
if len("subject") > 1000000 {
+
return xerrors.Errorf("Value in field \"subject\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("subject")); err != nil {
+
return err
+
}
+
+
if len(t.Subject) > 1000000 {
+
return xerrors.Errorf("Value in field t.Subject was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Subject)); 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 *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = RepoCollaborator{}
+
+
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("RepoCollaborator: 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.Repo (string) (string)
+
case "repo":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Repo = string(sval)
+
}
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
+
}
+
// t.Subject (string) (string)
+
case "subject":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Subject = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
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")
···
if _, err := cw.WriteString(string(t.Title)); err != nil {
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)
···
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")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("targetRepo")); 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")
+
// 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("targetBranch"))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
return err
-
if _, err := cw.WriteString(string("targetBranch")); err != nil {
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
return err
-
if len(t.TargetBranch) > 1000000 {
-
return xerrors.Errorf("Value in field t.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(t.TargetBranch))); err != nil {
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
return err
-
if _, err := cw.WriteString(string(t.TargetBranch)); err != nil {
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
return err
return nil
···
n := extra
-
nameBuf := make([]byte, 12)
+
nameBuf := make([]byte, 9)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
t.Title = string(sval)
-
// t.PullId (int64) (int64)
-
case "pullId":
-
{
-
maj, extra, err := cr.ReadHeader()
-
if err != nil {
-
return err
-
}
-
var extraI int64
-
switch maj {
-
case cbg.MajUnsignedInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 positive overflow")
-
}
-
case cbg.MajNegativeInt:
-
extraI = int64(extra)
-
if extraI < 0 {
-
return fmt.Errorf("int64 negative overflow")
-
}
-
extraI = -1 - extraI
-
default:
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
-
}
-
-
t.PullId = int64(extraI)
-
}
// t.Source (tangled.RepoPull_Source) (struct)
case "source":
···
-
// t.CreatedAt (string) (string)
-
case "createdAt":
+
// t.Target (tangled.RepoPull_Target) (struct)
+
case "target":
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
+
b, err := cr.ReadByte()
if err != nil {
return err
-
-
t.CreatedAt = string(sval)
-
}
-
// t.TargetRepo (string) (string)
-
case "targetRepo":
-
-
{
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
-
if err != nil {
-
return err
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.Target = new(RepoPull_Target)
+
if err := t.Target.UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.Target pointer: %w", err)
+
}
-
t.TargetRepo = string(sval)
-
// t.TargetBranch (string) (string)
-
case "targetBranch":
+
// t.CreatedAt (string) (string)
+
case "createdAt":
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
return err
-
t.TargetBranch = string(sval)
+
t.CreatedAt = string(sval)
default:
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 7
-
if t.CommentId == nil {
-
fieldCount--
-
}
-
-
if t.Owner == nil {
-
fieldCount--
-
}
-
-
if t.Repo == nil {
-
fieldCount--
-
}
-
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
if _, err := cw.Write([]byte{164}); err != nil {
return err
···
return err
-
// t.Repo (string) (string)
-
if t.Repo != nil {
-
-
if len("repo") > 1000000 {
-
return xerrors.Errorf("Value in field \"repo\" was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string("repo")); err != nil {
-
return err
-
}
-
-
if t.Repo == nil {
-
if _, err := cw.Write(cbg.CborNull); err != nil {
-
return err
-
}
-
} else {
-
if len(*t.Repo) > 1000000 {
-
return xerrors.Errorf("Value in field t.Repo was too long")
-
}
-
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
-
return err
-
}
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
-
return err
-
}
-
}
-
}
-
// t.LexiconTypeID (string) (string)
if len("$type") > 1000000 {
return xerrors.Errorf("Value in field \"$type\" was too long")
···
if _, err := cw.WriteString(string("sh.tangled.repo.pull.comment")); err != nil {
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)
···
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":
···
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:
+
// 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 *Spindle) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
return nil
+
func (t *String) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{165}); 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.string"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.string")); err != nil {
+
return err
+
}
+
+
// t.Contents (string) (string)
+
if len("contents") > 1000000 {
+
return xerrors.Errorf("Value in field \"contents\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("contents")); err != nil {
+
return err
+
}
+
+
if len(t.Contents) > 1000000 {
+
return xerrors.Errorf("Value in field t.Contents was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Contents)); err != nil {
+
return err
+
}
+
+
// t.Filename (string) (string)
+
if len("filename") > 1000000 {
+
return xerrors.Errorf("Value in field \"filename\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("filename")); err != nil {
+
return err
+
}
+
+
if len(t.Filename) > 1000000 {
+
return xerrors.Errorf("Value in field t.Filename was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Filename)); 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
+
}
+
+
// t.Description (string) (string)
+
if len("description") > 1000000 {
+
return xerrors.Errorf("Value in field \"description\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("description")); err != nil {
+
return err
+
}
+
+
if len(t.Description) > 1000000 {
+
return xerrors.Errorf("Value in field t.Description was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Description)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *String) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = String{}
+
+
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("String: 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.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
+
}
+
// t.Contents (string) (string)
+
case "contents":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Contents = string(sval)
+
}
+
// t.Filename (string) (string)
+
case "filename":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Filename = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
// t.Description (string) (string)
+
case "description":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Description = 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
+
}
+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"`
}
+31
api/tangled/repoaddSecret.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.addSecret
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoAddSecretNSID = "sh.tangled.repo.addSecret"
+
)
+
+
// RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call.
+
type RepoAddSecret_Input struct {
+
Key string `json:"key" cborgen:"key"`
+
Repo string `json:"repo" cborgen:"repo"`
+
Value string `json:"value" cborgen:"value"`
+
}
+
+
// RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret".
+
func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+41
api/tangled/repoarchive.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.archive
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoArchiveNSID = "sh.tangled.repo.archive"
+
)
+
+
// RepoArchive calls the XRPC method "sh.tangled.repo.archive".
+
//
+
// format: Archive format
+
// prefix: Prefix for files in the archive
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if format != "" {
+
params["format"] = format
+
}
+
if prefix != "" {
+
params["prefix"] = prefix
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+80
api/tangled/repoblob.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.blob
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBlobNSID = "sh.tangled.repo.blob"
+
)
+
+
// RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema.
+
type RepoBlob_LastCommit struct {
+
Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Commit hash
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Commit message
+
Message string `json:"message" cborgen:"message"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Commit timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
+
type RepoBlob_Output struct {
+
// content: File content (base64 encoded for binary files)
+
Content string `json:"content" cborgen:"content"`
+
// encoding: Content encoding
+
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
+
// isBinary: Whether the file is binary
+
IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"`
+
LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
+
// mimeType: MIME type of the file
+
MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"`
+
// path: The file path
+
Path string `json:"path" cborgen:"path"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
// size: File size in bytes
+
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
+
}
+
+
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
+
type RepoBlob_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+
//
+
// path: Path to the file within the repository
+
// raw: Return raw file content instead of JSON response
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) {
+
var out RepoBlob_Output
+
+
params := map[string]interface{}{}
+
params["path"] = path
+
if raw {
+
params["raw"] = raw
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+59
api/tangled/repobranch.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.branch
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBranchNSID = "sh.tangled.repo.branch"
+
)
+
+
// RepoBranch_Output is the output of a sh.tangled.repo.branch call.
+
type RepoBranch_Output struct {
+
Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
+
// hash: Latest commit hash on this branch
+
Hash string `json:"hash" cborgen:"hash"`
+
// isDefault: Whether this is the default branch
+
IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"`
+
// message: Latest commit message
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
+
// name: Branch name
+
Name string `json:"name" cborgen:"name"`
+
// shortHash: Short commit hash
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
+
// when: Timestamp of latest commit
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema.
+
type RepoBranch_Signature struct {
+
// email: Author email
+
Email string `json:"email" cborgen:"email"`
+
// name: Author name
+
Name string `json:"name" cborgen:"name"`
+
// when: Author timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoBranch calls the XRPC method "sh.tangled.repo.branch".
+
//
+
// name: Branch name to get information for
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) {
+
var out RepoBranch_Output
+
+
params := map[string]interface{}{}
+
params["name"] = name
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+39
api/tangled/repobranches.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.branches
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoBranchesNSID = "sh.tangled.repo.branches"
+
)
+
+
// RepoBranches calls the XRPC method "sh.tangled.repo.branches".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of branches to return
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+25
api/tangled/repocollaborator.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.collaborator
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
+
} //
+
// RECORDTYPE: RepoCollaborator
+
type RepoCollaborator struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
// repo: repo to add this user to
+
Repo string `json:"repo" cborgen:"repo"`
+
Subject string `json:"subject" cborgen:"subject"`
+
}
+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
+
}
+41
api/tangled/repolistSecrets.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.listSecrets
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoListSecretsNSID = "sh.tangled.repo.listSecrets"
+
)
+
+
// RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call.
+
type RepoListSecrets_Output struct {
+
Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"`
+
}
+
+
// RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema.
+
type RepoListSecrets_Secret struct {
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
CreatedBy string `json:"createdBy" cborgen:"createdBy"`
+
Key string `json:"key" cborgen:"key"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets".
+
func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) {
+
var out RepoListSecrets_Output
+
+
params := map[string]interface{}{}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", 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"`
+
}
+30
api/tangled/reporemoveSecret.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.removeSecret
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret"
+
)
+
+
// RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call.
+
type RepoRemoveSecret_Input struct {
+
Key string `json:"key" cborgen:"key"`
+
Repo string `json:"repo" cborgen:"repo"`
+
}
+
+
// RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret".
+
func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error {
+
if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil {
+
return err
+
}
+
+
return nil
+
}
+39
api/tangled/repotags.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.tags
+
+
import (
+
"bytes"
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoTagsNSID = "sh.tangled.repo.tags"
+
)
+
+
// RepoTags calls the XRPC method "sh.tangled.repo.tags".
+
//
+
// cursor: Pagination cursor
+
// limit: Maximum number of tags to return
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
+
buf := new(bytes.Buffer)
+
+
params := map[string]interface{}{}
+
if cursor != "" {
+
params["cursor"] = cursor
+
}
+
if limit != 0 {
+
params["limit"] = limit
+
}
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil {
+
return nil, err
+
}
+
+
return buf.Bytes(), nil
+
}
+72
api/tangled/repotree.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.repo.tree
+
+
import (
+
"context"
+
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
RepoTreeNSID = "sh.tangled.repo.tree"
+
)
+
+
// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
+
type RepoTree_LastCommit struct {
+
// hash: Commit hash
+
Hash string `json:"hash" cborgen:"hash"`
+
// message: Commit message
+
Message string `json:"message" cborgen:"message"`
+
// when: Commit timestamp
+
When string `json:"when" cborgen:"when"`
+
}
+
+
// RepoTree_Output is the output of a sh.tangled.repo.tree call.
+
type RepoTree_Output struct {
+
// dotdot: Parent directory path
+
Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
+
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
+
// parent: The parent path in the tree
+
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
+
// ref: The git reference used
+
Ref string `json:"ref" cborgen:"ref"`
+
}
+
+
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+
type RepoTree_TreeEntry struct {
+
// is_file: Whether this entry is a file
+
Is_file bool `json:"is_file" cborgen:"is_file"`
+
// is_subtree: Whether this entry is a directory/subtree
+
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
+
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
+
// mode: File mode
+
Mode string `json:"mode" cborgen:"mode"`
+
// name: Relative file or directory name
+
Name string `json:"name" cborgen:"name"`
+
// size: File size in bytes
+
Size int64 `json:"size" cborgen:"size"`
+
}
+
+
// RepoTree calls the XRPC method "sh.tangled.repo.tree".
+
//
+
// path: Path within the repository tree
+
// ref: Git reference (branch, tag, or commit SHA)
+
// repo: Repository identifier in format 'did:plc:.../repoName'
+
func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) {
+
var out RepoTree_Output
+
+
params := map[string]interface{}{}
+
if path != "" {
+
params["path"] = path
+
}
+
params["ref"] = ref
+
params["repo"] = repo
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil {
+
return nil, err
+
}
+
+
return &out, nil
+
}
+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"`
}
+25
api/tangled/tangledstring.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.string
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
StringNSID = "sh.tangled.string"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.string", &String{})
+
} //
+
// RECORDTYPE: String
+
type String struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"`
+
Contents string `json:"contents" cborgen:"contents"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Description string `json:"description" cborgen:"description"`
+
Filename string `json:"filename" cborgen:"filename"`
+
}
+1
appview/cache/session/store.go
···
PkceVerifier string
DpopAuthserverNonce string
DpopPrivateJwk string
+
ReturnUrl string
}
type SessionStore struct {
+24 -5
appview/config/config.go
···
)
type CoreConfig struct {
-
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
-
DbPath string `env:"DB_PATH, default=appview.db"`
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
-
Dev bool `env:"DEV, default=false"`
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
+
DbPath string `env:"DB_PATH, default=appview.db"`
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
+
Dev bool `env:"DEV, default=false"`
+
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
+
+
// 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 {
···
DB int `env:"DB, default=0"`
}
+
type PdsConfig struct {
+
Host string `env:"HOST, default=https://tngl.sh"`
+
AdminSecret string `env:"ADMIN_SECRET"`
+
}
+
+
type Cloudflare struct {
+
ApiToken string `env:"API_TOKEN"`
+
ZoneId string `env:"ZONE_ID"`
+
}
+
func (cfg RedisConfig) ToURL() string {
u := &url.URL{
Scheme: "redis",
···
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
}
func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
···
+
package db
+
+
import (
+
"fmt"
+
"strings"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type Collaborator struct {
+
// identifiers for the record
+
Id int64
+
Did syntax.DID
+
Rkey string
+
+
// content
+
SubjectDid syntax.DID
+
RepoAt syntax.ATURI
+
+
// meta
+
Created time.Time
+
}
+
+
func AddCollaborator(e Execer, c Collaborator) error {
+
_, err := e.Exec(
+
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
+
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
+
)
+
return err
+
}
+
+
func DeleteCollaborator(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()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
query := fmt.Sprintf(`delete from collaborators %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
+
return err
+
}
+
+
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
+
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
var repoAts []string
+
for rows.Next() {
+
var aturi string
+
err := rows.Scan(&aturi)
+
if err != nil {
+
return nil, err
+
}
+
repoAts = append(repoAts, aturi)
+
}
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
if repoAts == nil {
+
return nil, nil
+
}
+
+
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
+
}
+321 -27
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,
···
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-- constraints
-
foreign key (did, instance) references spindles(owner, instance) on delete cascade,
unique (did, instance, subject)
);
···
unique(repo_at, ref, language)
);
+
create table if not exists signups_inflight (
+
id integer primary key autoincrement,
+
email text not null unique,
+
invite_code text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+
);
+
+
create table if not exists strings (
+
-- identifiers
+
did text not null,
+
rkey text not null,
+
+
-- content
+
filename text not null,
+
description text,
+
content text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
edited text,
+
+
primary key (did, rkey)
+
);
+
create table if not exists migrations (
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(conn, "rework-collaborators-table", func(tx *sql.Tx) error {
+
// create new table
+
// - repo_at instead of repo integer
+
// - rkey field
+
// - created field
+
_, err := tx.Exec(`
+
create table collaborators_new (
+
-- identifiers for the record
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text,
+
+
-- content
+
subject_did text not null,
+
repo_at text not null,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
foreign key (repo_at) references repos(at_uri) on delete cascade
+
)
+
`)
+
if err != nil {
+
return err
+
}
+
+
// copy data
+
_, err = tx.Exec(`
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
+
select
+
c.id,
+
r.did,
+
'',
+
c.did,
+
r.at_uri
+
from collaborators c
+
join repos r on c.repo = r.id
+
`)
+
if err != nil {
+
return err
+
}
+
+
// drop old table
+
_, err = tx.Exec(`drop table collaborators`)
+
if err != nil {
+
return err
+
}
+
+
// rename new table
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
+
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 {
key string
arg any
···
kind := rv.Kind()
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
-
if kind == reflect.Slice || kind == reflect.Array {
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
if rv.Len() == 0 {
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
+
// always false
+
return "1 = 0"
}
placeholders := make([]string, rv.Len())
···
func (f filter) Arg() []any {
rv := reflect.ValueOf(f.arg)
kind := rv.Kind()
-
if kind == reflect.Slice || kind == reflect.Array {
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
if rv.Len() == 0 {
-
panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key))
+
return nil
}
out := make([]any, rv.Len())
+16 -2
appview/db/email.go
···
query := `
select email, did
from emails
-
where
-
verified = ?
+
where
+
verified = ?
and email in (` + strings.Join(placeholders, ",") + `)
`
···
`
var count int
err := e.QueryRow(query, did, email).Scan(&count)
+
if err != nil {
+
return false, err
+
}
+
return count > 0, nil
+
}
+
+
func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {
+
query := `
+
select count(*)
+
from emails
+
where email = ?
+
`
+
var count int
+
err := e.QueryRow(query, email).Scan(&count)
if err != nil {
return false, err
}
+145 -42
appview/db/follow.go
···
package db
import (
+
"fmt"
"log"
+
"strings"
"time"
)
···
return err
}
-
func GetFollowerFollowing(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
+
}
}
+459 -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))
+
}
+
+
func (i *Issue) AsRecord() tangled.RepoIssue {
+
return tangled.RepoIssue{
+
Repo: i.RepoAt.String(),
+
Title: i.Title,
+
Body: &i.Body,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
}
+
}
+
+
func (i *Issue) State() string {
+
if i.Open {
+
return "open"
+
}
+
return "closed"
}
-
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
+
type CommentListItem struct {
+
Self *IssueComment
+
Replies []*IssueComment
}
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
-
defer tx.Rollback()
+
func (i *Issue) CommentList() []CommentListItem {
+
// Create a map to quickly find comments by their aturi
+
toplevel := make(map[string]*CommentListItem)
+
var replies []*IssueComment
-
_, err := tx.Exec(`
-
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
-
values (?, 1)
-
`, issue.RepoAt)
-
if err != nil {
-
return err
+
// collect top level comments into the map
+
for _, comment := range i.Comments {
+
if comment.IsTopLevel() {
+
toplevel[comment.AtUri().String()] = &CommentListItem{
+
Self: &comment,
+
}
+
} else {
+
replies = append(replies, &comment)
+
}
+
}
+
+
for _, r := range replies {
+
parentAt := *r.ReplyTo
+
if parent, exists := toplevel[parentAt]; exists {
+
parent.Replies = append(parent.Replies, r)
+
}
+
}
+
+
var listing []CommentListItem
+
for _, v := range toplevel {
+
listing = append(listing, *v)
}
-
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
+
// 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])
+
})
}
-
issue.IssueId = nextId
+
return listing
+
}
-
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)
+
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()
}
-
lastID, err := res.LastInsertId()
-
if err != nil {
-
return err
+
body := ""
+
if record.Body != nil {
+
body = *record.Body
}
-
issue.ID = lastID
-
if err := tx.Commit(); err != nil {
-
return err
+
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
}
+
}
-
return nil
+
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 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
+
func (i *IssueComment) AtUri() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
}
-
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) AsRecord() tangled.RepoIssueComment {
+
return tangled.RepoIssueComment{
+
Body: i.Body,
+
Issue: i.IssueAt,
+
CreatedAt: i.Created.Format(time.RFC3339),
+
ReplyTo: i.ReplyTo,
+
}
}
-
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) IsTopLevel() bool {
+
return i.ReplyTo == nil
}
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
-
var issues []Issue
-
openValue := 0
-
if isOpen {
-
openValue = 1
+
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
+
}
-
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)
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
+
issueMap := make(map[string]*Issue) // at-uri -> issue
+
+
var conditions []string
+
var args []any
+
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
pLower := FilterGte("row_num", page.Offset+1)
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
+
+
args = append(args, pLower.Arg()...)
+
args = append(args, pUpper.Arg()...)
+
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
+
+
query := fmt.Sprintf(
+
`
+
select * from (
+
select
+
id,
+
did,
+
rkey,
+
repo_at,
+
issue_id,
+
title,
+
body,
+
open,
+
created,
+
edited,
+
deleted,
+
row_number() over (order by created desc) as row_num
+
from
+
issues
+
%s
+
) ranked_issues
+
%s
+
`,
+
whereClause,
+
pagination,
+
)
+
+
rows, err := e.Query(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
+
}
+
}
+
+
if deletedAt.Valid {
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
+
issue.Deleted = &t
+
}
}
-
repo.Created = repoCreatedTime
-
issue.Metadata = &IssueMetadata{
-
Repo: &repo,
+
atUri := issue.AtUri().String()
+
issueMap[atUri] = &issue
+
}
+
+
// collect reverse repos
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
+
for _, issue := range issueMap {
+
repoAts = append(repoAts, string(issue.RepoAt))
+
}
+
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
+
if err != nil {
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
+
}
+
+
repoMap := make(map[string]*Repo)
+
for i := range repos {
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
+
}
+
+
for issueAt, i := range issueMap {
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
+
i.Repo = r
+
} else {
+
// do not show up the issue if the repo is deleted
+
// TODO: foreign key where?
+
delete(issueMap, issueAt)
}
+
}
-
issues = append(issues, issue)
+
// 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)
}
-
if err := rows.Err(); err != nil {
-
return nil, 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
}
-62
appview/db/migrations/20250305_113405.sql
···
-
-- Simplified SQLite Database Migration Script for Issues and Comments
-
-
-- Migration for issues table
-
CREATE TABLE issues_new (
-
id integer primary key autoincrement,
-
owner_did text not null,
-
repo_at text not null,
-
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')),
-
issue_at text,
-
unique(repo_at, issue_id),
-
foreign key (repo_at) references repos(at_uri) on delete cascade
-
);
-
-
-- Migrate data to new issues table
-
INSERT INTO issues_new (
-
id, owner_did, repo_at, issue_id,
-
title, body, open, created, issue_at
-
)
-
SELECT
-
id, owner_did, repo_at, issue_id,
-
title, body, open, created, issue_at
-
FROM issues;
-
-
-- Drop old issues table
-
DROP TABLE issues;
-
-
-- Rename new issues table
-
ALTER TABLE issues_new RENAME TO issues;
-
-
-- Migration for comments table
-
CREATE TABLE comments_new (
-
id integer primary key autoincrement,
-
owner_did text not null,
-
issue_id integer not null,
-
repo_at text not null,
-
comment_id integer not null,
-
comment_at text not null,
-
body text not null,
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-
unique(issue_id, comment_id),
-
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
-
);
-
-
-- Migrate data to new comments table
-
INSERT INTO comments_new (
-
id, owner_did, issue_id, repo_at,
-
comment_id, comment_at, body, created
-
)
-
SELECT
-
id, owner_did, issue_id, repo_at,
-
comment_id, comment_at, body, created
-
FROM comments;
-
-
-- Drop old comments table
-
DROP TABLE comments;
-
-
-- Rename new comments table
-
ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
···
-
-- Validation Queries for Database Migration
-
-
-- 1. Verify Issues Table Structure
-
PRAGMA table_info(issues);
-
-
-- 2. Verify Comments Table Structure
-
PRAGMA table_info(comments);
-
-
-- 3. Check Total Row Count Consistency
-
SELECT
-
'Issues Row Count' AS check_type,
-
(SELECT COUNT(*) FROM issues) AS row_count
-
UNION ALL
-
SELECT
-
'Comments Row Count' AS check_type,
-
(SELECT COUNT(*) FROM comments) AS row_count;
-
-
-- 4. Verify Unique Constraint on Issues
-
SELECT
-
repo_at,
-
issue_id,
-
COUNT(*) as duplicate_count
-
FROM issues
-
GROUP BY repo_at, issue_id
-
HAVING duplicate_count > 1;
-
-
-- 5. Verify Foreign Key Integrity for Comments
-
SELECT
-
'Orphaned Comments' AS check_type,
-
COUNT(*) AS orphaned_count
-
FROM comments c
-
LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id
-
WHERE i.id IS NULL;
-
-
-- 6. Check Foreign Key Constraint
-
PRAGMA foreign_key_list(comments);
-
-
-- 7. Sample Data Integrity Check
-
SELECT
-
'Sample Issues' AS check_type,
-
repo_at,
-
issue_id,
-
title,
-
created
-
FROM issues
-
LIMIT 5;
-
-
-- 8. Sample Comments Data Integrity Check
-
SELECT
-
'Sample Comments' AS check_type,
-
repo_at,
-
issue_id,
-
comment_id,
-
body,
-
created
-
FROM comments
-
LIMIT 5;
-
-
-- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness)
-
SELECT
-
issue_id,
-
comment_id,
-
COUNT(*) as duplicate_count
-
FROM comments
-
GROUP BY issue_id, comment_id
-
HAVING duplicate_count > 1;
+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
}
+36 -174
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
}
···
return &repo, nil
}
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
-
_, err := e.Exec(
-
`insert into collaborators (did, repo)
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
-
collaborator, repoOwnerDid, repoName, repoKnot)
-
return err
-
}
-
func UpdateDescription(e Execer, repoAt, newDescription string) error {
_, err := e.Exec(
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
return err
}
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
_, err := e.Exec(
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
return err
}
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
-
rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator)
-
if err != nil {
-
return nil, err
-
}
-
defer rows.Close()
-
-
var repoIds []int
-
for rows.Next() {
-
var id int
-
err := rows.Scan(&id)
-
if err != nil {
-
return nil, err
-
}
-
repoIds = append(repoIds, id)
-
}
-
if err := rows.Err(); err != nil {
-
return nil, err
-
}
-
if repoIds == nil {
-
return nil, nil
-
}
-
-
return GetRepos(e, 0, FilterIn("id", repoIds))
-
}
-
type RepoStats struct {
Language string
StarCount int
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
-
}
+29
appview/db/signup.go
···
+
package db
+
+
import "time"
+
+
type InflightSignup struct {
+
Id int64
+
Email string
+
InviteCode string
+
Created time.Time
+
}
+
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
+
return err
+
}
+
+
func DeleteInflightSignup(e Execer, email string) error {
+
query := `delete from signups_inflight where email = ?`
+
_, err := e.Exec(query, email)
+
return err
+
}
+
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
+
query := `select email from signups_inflight where invite_code = ?`
+
var email string
+
err := e.QueryRow(query, inviteCode).Scan(&email)
+
return email, err
+
}
+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
+
}
+276
appview/db/strings.go
···
+
package db
+
+
import (
+
"bytes"
+
"database/sql"
+
"errors"
+
"fmt"
+
"io"
+
"strings"
+
"time"
+
"unicode/utf8"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
)
+
+
type String struct {
+
Did syntax.DID
+
Rkey string
+
+
Filename string
+
Description string
+
Contents string
+
Created time.Time
+
Edited *time.Time
+
}
+
+
func (s *String) StringAt() syntax.ATURI {
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
+
}
+
+
type StringStats struct {
+
LineCount uint64
+
ByteCount uint64
+
}
+
+
func (s String) Stats() StringStats {
+
lineCount, err := countLines(strings.NewReader(s.Contents))
+
if err != nil {
+
// non-fatal
+
// TODO: log this?
+
}
+
+
return StringStats{
+
LineCount: uint64(lineCount),
+
ByteCount: uint64(len(s.Contents)),
+
}
+
}
+
+
func (s String) Validate() error {
+
var err error
+
+
if utf8.RuneCountInString(s.Filename) > 140 {
+
err = errors.Join(err, fmt.Errorf("filename too long"))
+
}
+
+
if utf8.RuneCountInString(s.Description) > 280 {
+
err = errors.Join(err, fmt.Errorf("description too long"))
+
}
+
+
if len(s.Contents) == 0 {
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
+
}
+
+
return err
+
}
+
+
func (s *String) AsRecord() tangled.String {
+
return tangled.String{
+
Filename: s.Filename,
+
Description: s.Description,
+
Contents: s.Contents,
+
CreatedAt: s.Created.Format(time.RFC3339),
+
}
+
}
+
+
func StringFromRecord(did, rkey string, record tangled.String) String {
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
+
if err != nil {
+
created = time.Now()
+
}
+
return String{
+
Did: syntax.DID(did),
+
Rkey: rkey,
+
Filename: record.Filename,
+
Description: record.Description,
+
Contents: record.Contents,
+
Created: created,
+
}
+
}
+
+
func AddString(e Execer, s String) error {
+
_, err := e.Exec(
+
`insert into strings (
+
did,
+
rkey,
+
filename,
+
description,
+
content,
+
created,
+
edited
+
)
+
values (?, ?, ?, ?, ?, ?, null)
+
on conflict(did, rkey) do update set
+
filename = excluded.filename,
+
description = excluded.description,
+
content = excluded.content,
+
edited = case
+
when
+
strings.content != excluded.content
+
or strings.filename != excluded.filename
+
or strings.description != excluded.description then ?
+
else strings.edited
+
end`,
+
s.Did,
+
s.Rkey,
+
s.Filename,
+
s.Description,
+
s.Contents,
+
s.Created.Format(time.RFC3339),
+
time.Now().Format(time.RFC3339),
+
)
+
return err
+
}
+
+
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
+
var all []String
+
+
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 = fmt.Sprintf(" limit %d ", limit)
+
}
+
+
query := fmt.Sprintf(`select
+
did,
+
rkey,
+
filename,
+
description,
+
content,
+
created,
+
edited
+
from strings
+
%s
+
order by created desc
+
%s`,
+
whereClause,
+
limitClause,
+
)
+
+
rows, err := e.Query(query, args...)
+
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var s String
+
var createdAt string
+
var editedAt sql.NullString
+
+
if err := rows.Scan(
+
&s.Did,
+
&s.Rkey,
+
&s.Filename,
+
&s.Description,
+
&s.Contents,
+
&createdAt,
+
&editedAt,
+
); err != nil {
+
return nil, err
+
}
+
+
s.Created, err = time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
s.Created = time.Now()
+
}
+
+
if editedAt.Valid {
+
e, err := time.Parse(time.RFC3339, editedAt.String)
+
if err != nil {
+
e = time.Now()
+
}
+
s.Edited = &e
+
}
+
+
all = append(all, s)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return all, nil
+
}
+
+
func CountStrings(e Execer, filters ...filter) (int64, error) {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
+
}
+
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
+
}
+
+
repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause)
+
var count int64
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
+
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
+
return 0, err
+
}
+
+
return count, nil
+
}
+
+
func DeleteString(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
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(`delete from strings %s`, whereClause)
+
+
_, err := e.Exec(query, args...)
+
return err
+
}
+
+
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
+
}
+
}
+
}
+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 := GetFollowerFollowing(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,
})
+53
appview/dns/cloudflare.go
···
+
package dns
+
+
import (
+
"context"
+
"fmt"
+
+
"github.com/cloudflare/cloudflare-go"
+
"tangled.sh/tangled.sh/core/appview/config"
+
)
+
+
type Record struct {
+
Type string
+
Name string
+
Content string
+
TTL int
+
Proxied bool
+
}
+
+
type Cloudflare struct {
+
api *cloudflare.API
+
zone string
+
}
+
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
+
apiToken := c.Cloudflare.ApiToken
+
api, err := cloudflare.NewWithAPIToken(apiToken)
+
if err != nil {
+
return nil, err
+
}
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
+
}
+
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
+
Type: record.Type,
+
Name: record.Name,
+
Content: record.Content,
+
TTL: record.TTL,
+
Proxied: &record.Proxied,
+
})
+
if err != nil {
+
return fmt.Errorf("failed to create DNS record: %w", err)
+
}
+
return nil
+
}
+
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
+
if err != nil {
+
return fmt.Errorf("failed to delete DNS record: %w", err)
+
}
+
return nil
+
}
+367 -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
}
···
if err != nil {
return fmt.Errorf("failed to update ACLs: %w", err)
}
+
+
l.Info("added spindle member")
case models.CommitOperationDelete:
rkey := e.Commit.RKey
···
if err = i.Enforcer.E.SavePolicy(); err != nil {
return fmt.Errorf("failed to save ACLs: %w", err)
}
+
+
l.Info("removed spindle member")
}
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)
}
···
i.Enforcer.E.LoadPolicy()
}()
+
// remove spindle members first
+
err = db.RemoveSpindleMember(
+
tx,
+
db.FilterEq("owner", did),
+
db.FilterEq("instance", instance),
+
)
+
if err != nil {
+
return err
+
}
+
err = db.DeleteSpindle(
tx,
db.FilterEq("owner", did),
···
return nil
}
+
+
func (i *Ingester) ingestString(e *models.Event) error {
+
did := e.Did
+
rkey := e.Commit.RKey
+
+
var err error
+
+
l := i.Logger.With("handler", "ingestString", "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 string record, invalid db cast")
+
}
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
+
raw := json.RawMessage(e.Commit.Record)
+
record := tangled.String{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
string := db.StringFromRecord(did, rkey, record)
+
+
if err = string.Validate(); err != nil {
+
l.Error("invalid record", "err", err)
+
return err
+
}
+
+
if err = db.AddString(ddb, string); err != nil {
+
l.Error("failed to add string", "err", err)
+
return err
+
}
+
+
return nil
+
+
case models.CommitOperationDelete:
+
if err := db.DeleteString(
+
ddb,
+
db.FilterEq("did", did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
+
l.Error("failed to delete", "err", err)
+
return fmt.Errorf("failed to delete string record: %w", err)
+
}
+
+
return nil
+
}
+
+
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)
}
+59 -24
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)
}
}
···
}
}
-
func StripLeadingAt(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-
path := req.URL.EscapedPath()
-
if strings.HasPrefix(path, "/@") {
-
req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
-
}
-
next.ServeHTTP(w, req)
-
})
-
}
-
func (mw Middleware) ResolveIdent() middlewareFunc {
excluded := []string{"favicon.ico"}
···
return
}
+
didOrHandle = strings.TrimPrefix(didOrHandle, "@")
+
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
if err != nil {
// invalid did or handle
-
log.Println("failed to resolve did/handle:", err)
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
mw.pages.Error404(w)
return
}
···
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" {
+196 -17
appview/oauth/handler/handler.go
···
package oauth
import (
+
"bytes"
+
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
+
"slices"
"strings"
+
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/posthog/posthog-go"
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
···
"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"
)
const (
···
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)
···
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
+
}
+
self := o.oauth.ClientMetadata()
oauthClient, err := client.NewClient(
···
log.Println("session saved successfully")
go o.addToDefaultKnot(oauthRequest.Did)
+
go o.addToDefaultSpindle(oauthRequest.Did)
if !o.config.Core.Dev {
err = o.posthog.Enqueue(posthog.Capture{
···
}
}
-
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
}
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
-
defaultKnot := "knot1.tangled.sh"
+
var (
+
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
+
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
-
log.Printf("adding %s to default knot", did)
-
err := o.enforcer.AddKnotMember(defaultKnot, did)
+
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
+
spindleMembers, err := db.GetSpindleMembers(
+
o.db,
+
db.FilterEq("instance", "spindle.tangled.sh"),
+
db.FilterEq("subject", did),
+
)
if err != nil {
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
+
return
+
}
+
+
if len(spindleMembers) != 0 {
+
log.Printf("did %s is already a member of the default spindle", did)
return
}
-
err = o.enforcer.E.SavePolicy()
+
+
log.Printf("adding %s to default spindle", did)
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid)
if err != nil {
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
+
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
}
-
secret, err := db.GetRegistrationKey(o.db, defaultKnot)
+
log.Printf("successfully added %s to default spindle", did)
+
}
+
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
+
// use the tangled.sh app password to get an accessJwt
+
// and create an sh.tangled.spindle.member record with that
+
+
allKnots, err := o.enforcer.GetKnotsForUser(did)
if err != nil {
-
log.Println("failed to get registration key for knot1.tangled.sh")
+
log.Printf("failed to get knot members for did %s: %v", did, err)
+
return
+
}
+
+
if slices.Contains(allKnots, defaultKnot) {
+
log.Printf("did %s is already a member of the default knot", did)
return
}
-
signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev)
-
resp, err := signedClient.AddMember(did)
+
+
log.Printf("adding %s to default knot", did)
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid)
if err != nil {
-
log.Println("failed to add user to knot1.tangled.sh: ", err)
+
log.Printf("failed to create session: %s", err)
return
}
-
if resp.StatusCode != http.StatusNoContent {
-
log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
+
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 == "" {
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
+
}
+
+
sessionPayload := map[string]string{
+
"identifier": did,
+
"password": appPassword,
+
}
+
sessionBytes, err := json.Marshal(sessionPayload)
+
if err != nil {
+
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 {
+
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 {
+
return nil, fmt.Errorf("failed to create session: %v", err)
+
}
+
defer sessionResp.Body.Close()
+
+
if sessionResp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
+
}
+
+
var session session
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
+
return nil, fmt.Errorf("failed to decode session response: %v", err)
+
}
+
+
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 {
+
return fmt.Errorf("failed to marshal knot member record: %w", err)
+
}
+
+
payload := map[string]any{
+
"repo": s.Did,
+
"collection": collection,
+
"rkey": tid.TID(),
+
"record": json.RawMessage(recordBytes),
+
}
+
+
payloadBytes, err := json.Marshal(payload)
+
if err != nil {
+
return fmt.Errorf("failed to marshal request payload: %w", err)
+
}
+
+
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
+
if err != nil {
+
return fmt.Errorf("failed to create HTTP request: %w", err)
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
+
+
client := &http.Client{Timeout: 30 * time.Second}
+
resp, err := client.Do(req)
+
if err != nil {
+
return fmt.Errorf("failed to add user to default service: %w", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
+
}
+
+
return nil
}
+88 -2
appview/oauth/oauth.go
···
"net/url"
"time"
+
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
oauth "tangled.sh/icyphox.sh/atproto-oauth"
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
···
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
···
return xrpcClient, nil
}
+
// use this to create a client to communicate with knots or spindles
+
//
+
// this is a higher level abstraction on ServerGetServiceAuth
+
type ServiceClientOpts struct {
+
service string
+
exp int64
+
lxm string
+
dev bool
+
}
+
+
type ServiceClientOpt func(*ServiceClientOpts)
+
+
func WithService(service string) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
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 = time.Now().Unix() + exp
+
}
+
}
+
+
func WithLxm(lxm string) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.lxm = lxm
+
}
+
}
+
+
func WithDev(dev bool) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.dev = dev
+
}
+
}
+
+
func (s *ServiceClientOpts) Audience() string {
+
return fmt.Sprintf("did:web:%s", s.service)
+
}
+
+
func (s *ServiceClientOpts) Host() string {
+
scheme := "https://"
+
if s.dev {
+
scheme = "http://"
+
}
+
+
return scheme + s.service
+
}
+
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
+
opts := ServiceClientOpts{}
+
for _, o := range os {
+
o(&opts)
+
}
+
+
authorizedClient, err := o.AuthorizedClient(r)
+
if err != nil {
+
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
+
}
+
+
return &indigo_xrpc.Client{
+
Auth: &indigo_xrpc.AuthInfo{
+
AccessJwt: resp.Token,
+
},
+
Host: opts.Host(),
+
Client: &http.Client{
+
Timeout: time.Second * 5,
+
},
+
}, nil
+
}
+
type ClientMetadata struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
···
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)
+
}
+45 -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
···
},
"cssContentHash": CssContentHash,
"fileTree": filetree.FileTree,
+
"pathEscape": func(s string) string {
+
return url.PathEscape(s)
+
},
"pathUnescape": func(s string) string {
u, _ := url.PathUnescape(s)
return u
···
},
"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
},
}
}
+2 -2
appview/pages/markup/camo.go
···
"github.com/yuin/goldmark/ast"
)
-
func generateCamoURL(baseURL, secret, imageURL string) string {
+
func GenerateCamoURL(baseURL, secret, imageURL string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(imageURL))
signature := hex.EncodeToString(h.Sum(nil))
···
}
if rctx.CamoUrl != "" && rctx.CamoSecret != "" {
-
return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
+
return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)
}
return dst
+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
+
}
+532 -256
appview/pages/pages.go
···
"html/template"
"io"
"io/fs"
-
"log"
+
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
+
"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/db"
···
"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"
···
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
+
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
-
"github.com/microcosm-cc/bluemonday"
)
//go:embed templates/* static
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)
+
func (p *Pages) pathToName(s string) string {
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
+
}
+
+
// reverse of pathToName
+
func (p *Pages) nameToPath(s string) string {
+
return "templates/" + s + ".html"
+
}
+
+
func (p *Pages) fragmentPaths() ([]string, error) {
var fragmentPaths []string
-
-
// Use embedded FS for initial loading
-
// First, collect all fragment paths
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
+
}
+
+
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))
}
-
// 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
-
})
+
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)
-
-
// 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
-
})
+
result, err := p.rawParse(stack...)
if err != nil {
-
return fmt.Errorf("walking disk template dir for fragments: %w", err)
+
return nil, err
}
-
// Find the template path on disk
-
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
-
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
-
return fmt.Errorf("template not found on disk: %s", name)
+
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...)
+
}
-
// Create a new template
-
tmpl := template.New(name).Funcs(p.funcMap())
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/repobase",
+
top,
+
}
+
return p.parse(stack...)
+
}
-
// Parse layouts
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
-
layouts, err := filepath.Glob(layoutGlob)
-
if err != nil {
-
return fmt.Errorf("finding layout templates: %w", err)
+
func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
+
stack := []string{
+
"layouts/base",
+
"layouts/profilebase",
+
top,
}
-
-
// Create paths for parsing
-
allFiles := append(layouts, fragmentPaths...)
-
allFiles = append(allFiles, templatePath)
+
return p.parse(stack...)
+
}
-
// Parse all templates
-
tmpl, err = tmpl.ParseFiles(allFiles...)
+
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
+
tpl, err := p.parse(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.Execute(w, 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) execute(name string, w io.Writer, params any) error {
+
tpl, err := p.parseBase(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) executeRepo(name string, w io.Writer, params any) error {
+
tpl, err := p.parseRepoBase(name)
+
if err != nil {
+
return err
}
+
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
}
-
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.executeOrReload(name, w, "layouts/base", params)
-
}
+
func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
+
tpl, err := p.parseProfileBase(name)
+
if err != nil {
+
return err
+
}
-
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 {
return p.executePlain("user/login", w, params)
}
+
func (p *Pages) Signup(w io.Writer) error {
+
return p.executePlain("user/signup", w, nil)
+
}
+
+
func (p *Pages) CompleteSignup(w io.Writer) error {
+
return p.executePlain("user/completeSignup", w, nil)
+
}
+
+
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
+
}
+
+
func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
+
return p.execute("user/settings/profile", w, params)
}
-
type SettingsParams struct {
+
type UserKeysSettingsParams struct {
LoggedInUser *oauth.User
PubKeys []db.PublicKey
+
Tabs []map[string]any
+
Tab string
+
}
+
+
func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
+
return p.execute("user/settings/keys", w, params)
+
}
+
+
type UserEmailsSettingsParams struct {
+
LoggedInUser *oauth.User
Emails []db.Email
+
Tabs []map[string]any
+
Tab string
}
-
func (p *Pages) Settings(w io.Writer, params SettingsParams) error {
-
return p.execute("settings", w, params)
+
func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
+
return p.execute("user/settings/emails", w, params)
+
}
+
+
type UpgradeBannerParams struct {
+
Registrations []db.Registration
+
Spindles []db.Spindle
+
}
+
+
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
+
return p.executePlain("banner", w, params)
}
type KnotsParams struct {
···
type 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
-
-
DidHandleMap map[string]string
+
Card *ProfileCard
+
Active string
}
-
type ProfileCard struct {
-
UserDid string
-
UserHandle string
-
FollowStatus db.FollowStatus
-
AvatarUri string
-
Followers int
-
Following int
+
func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/overview", w, params)
+
}
-
Profile *db.Profile
+
type ProfileReposParams struct {
+
LoggedInUser *oauth.User
+
Repos []db.Repo
+
Card *ProfileCard
+
Active string
}
-
func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error {
-
return p.execute("user/profile", w, params)
+
func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
+
params.Active = "repos"
+
return p.executeProfile("user/repos", w, params)
}
-
type ReposPageParams struct {
+
type ProfileStarredParams struct {
LoggedInUser *oauth.User
Repos []db.Repo
-
Card ProfileCard
+
Card *ProfileCard
+
Active string
+
}
+
+
func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
+
params.Active = "starred"
+
return p.executeProfile("user/starred", w, params)
+
}
+
+
type ProfileStringsParams struct {
+
LoggedInUser *oauth.User
+
Strings []db.String
+
Card *ProfileCard
+
Active string
+
}
+
+
func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
+
params.Active = "strings"
+
return p.executeProfile("user/strings", w, params)
+
}
+
+
type FollowCard struct {
+
UserDid string
+
FollowStatus db.FollowStatus
+
FollowersCount int64
+
FollowingCount int64
+
Profile *db.Profile
+
}
+
+
type ProfileFollowersParams struct {
+
LoggedInUser *oauth.User
+
Followers []FollowCard
+
Card *ProfileCard
+
Active string
+
}
+
+
func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
+
params.Active = "overview"
+
return p.executeProfile("user/followers", w, params)
+
}
-
DidHandleMap map[string]string
+
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 {
···
return p.executePlain("user/fragments/editPins", w, params)
}
-
type RepoActionsFragmentParams struct {
+
type RepoStarFragmentParams struct {
IsStarred bool
RepoAt syntax.ATURI
Stats db.RepoStats
}
-
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
-
return p.executePlain("repo/fragments/repoActions", w, params)
+
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
+
return p.executePlain("repo/fragments/repoStar", w, params)
}
type RepoDescriptionParams struct {
···
}
type RepoIndexParams struct {
-
LoggedInUser *oauth.User
-
RepoInfo repoinfo.RepoInfo
-
Active string
-
TagMap map[string][]string
-
CommitsTrunc []*object.Commit
-
TagsTrunc []*types.TagReference
-
BranchesTrunc []types.Branch
-
ForkInfo *types.ForkInfo
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
TagMap map[string][]string
+
CommitsTrunc []*object.Commit
+
TagsTrunc []*types.TagReference
+
BranchesTrunc []types.Branch
+
// ForkInfo *types.ForkInfo
HTMLReadme template.HTML
Raw bool
EmailToDidOrHandle map[string]string
VerifiedCommits commitverify.VerifiedCommits
Languages []types.RepoLanguageDetails
Pipelines map[string]db.Pipeline
+
NeedsKnotUpgrade bool
types.RepoIndexResponse
}
···
params.Active = "overview"
if params.IsEmpty {
return p.executeRepo("repo/empty", w, params)
+
}
+
+
if params.NeedsKnotUpgrade {
+
return p.executeRepo("repo/needsUpgrade", w, params)
}
p.rctx.RepoInfo = params.RepoInfo
+
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.RenderMarkdown(params.Readme)
params.Raw = false
-
params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString))
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.HTMLReadme = template.HTML(sanitized)
default:
-
htmlString = string(params.Readme)
params.Raw = true
-
params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString))
}
}
···
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 {
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Active string
+
Unsupported bool
+
IsImage bool
+
IsVideo bool
+
ContentSrc string
BreadCrumbs [][]string
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),
-
)
-
-
lexer := lexers.Get(filepath.Base(params.Path))
-
if lexer == nil {
-
lexer = lexers.Fallback
-
}
+
c := params.Contents
+
formatter := chromahtml.New(
+
chromahtml.InlineCode(false),
+
chromahtml.WithLineNumbers(true),
+
chromahtml.WithLinkableLineNumbers(true, "L"),
+
chromahtml.Standalone(false),
+
chromahtml.WithClasses(true),
+
)
-
iterator, err := lexer.Tokenise(nil, c)
-
if err != nil {
-
return fmt.Errorf("chroma tokenize: %w", err)
-
}
+
lexer := lexers.Get(filepath.Base(params.Path))
+
if lexer == nil {
+
lexer = lexers.Fallback
+
}
-
var code bytes.Buffer
-
err = formatter.Format(&code, style, iterator)
-
if err != nil {
-
return fmt.Errorf("chroma format: %w", err)
-
}
+
iterator, err := lexer.Tokenise(nil, c)
+
if err != nil {
+
return fmt.Errorf("chroma tokenize: %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)
}
···
Branches []types.Branch
Spindles []string
CurrentSpindle string
+
Secrets []*tangled.RepoListSecrets_Secret
+
// TODO: use repoinfo.roles
IsCollaboratorInviteAllowed bool
}
···
return p.executeRepo("repo/settings", w, params)
}
+
type RepoGeneralSettingsParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
Tabs []map[string]any
+
Tab string
+
Branches []types.Branch
+
}
+
+
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
+
params.Active = "settings"
+
return p.executeRepo("repo/settings/general", w, params)
+
}
+
+
type RepoAccessSettingsParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
Tabs []map[string]any
+
Tab string
+
Collaborators []Collaborator
+
}
+
+
func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
+
params.Active = "settings"
+
return p.executeRepo("repo/settings/access", w, params)
+
}
+
+
type RepoPipelineSettingsParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Active string
+
Tabs []map[string]any
+
Tab string
+
Spindles []string
+
CurrentSpindle string
+
Secrets []map[string]any
+
}
+
+
func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
+
params.Active = "settings"
+
return p.executeRepo("repo/settings/pipelines", w, params)
+
}
+
type RepoIssuesParams struct {
LoggedInUser *oauth.User
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
+
}
-
State string
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
+
params.Active = "issues"
+
return p.executeRepo("repo/issues/issue", w, params)
+
}
+
+
type EditIssueParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Action string
+
}
+
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
+
params.Action = "edit"
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
}
type ThreadReactionFragmentParams struct {
···
return p.executePlain("repo/fragments/reaction", w, params)
}
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
-
params.Active = "issues"
-
if params.Issue.Open {
-
params.State = "open"
-
} else {
-
params.State = "closed"
-
}
-
return p.execute("repo/issues/issue", w, params)
-
}
-
type RepoNewIssueParams struct {
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue // existing issue if any -- passed when editing
Active string
+
Action string
}
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
params.Active = "issues"
+
params.Action = "create"
return p.executeRepo("repo/issues/new", w, params)
}
···
LoggedInUser *oauth.User
RepoInfo repoinfo.RepoInfo
Issue *db.Issue
-
Comment *db.Comment
+
Comment *db.IssueComment
}
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}
-
type SingleIssueCommentParams struct {
+
type ReplyIssueCommentPlaceholderParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.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
-
DidHandleMap map[string]string
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) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
+
return p.executePlain("repo/issues/fragments/replyComment", w, params)
+
}
+
+
type IssueCommentBodyParams struct {
+
LoggedInUser *oauth.User
+
RepoInfo repoinfo.RepoInfo
+
Issue *db.Issue
+
Comment *db.IssueComment
+
}
+
+
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
}
type RepoNewPullParams struct {
···
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
}
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
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.executeRepo("repo/pipelines/workflow", w, params)
+
type PutStringParams struct {
+
LoggedInUser *oauth.User
+
Action string
+
+
// this is supplied in the case of editing an existing string
+
String db.String
+
}
+
+
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
+
return p.execute("strings/put", w, params)
+
}
+
+
type StringsDashboardParams struct {
+
LoggedInUser *oauth.User
+
Card ProfileCard
+
Strings []db.String
+
}
+
+
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
+
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
+
RenderToggle bool
+
RenderedContents template.HTML
+
String db.String
+
Stats db.StringStats
+
Owner identity.Identity
+
}
+
+
func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
+
var style *chroma.Style = styles.Get("catpuccin-latte")
+
+
if params.ShowRendered {
+
switch markup.GetFormat(params.String.Filename) {
+
case markup.FormatMarkdown:
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
+
htmlString := p.rctx.RenderMarkdown(params.String.Contents)
+
sanitized := p.rctx.SanitizeDefault(htmlString)
+
params.RenderedContents = template.HTML(sanitized)
+
}
+
}
+
+
c := params.String.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.String.Filename))
+
if lexer == nil {
+
lexer = lexers.Fallback
+
}
+
+
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.String.Contents = code.String()
+
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 }}
+
+
+7 -8
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"
>
···
<div
id="add-member-{{ .Id }}"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
+
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 "addKnotMemberPopover" . }} {{ end }}
</div>
{{ end }}
{{ 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 }}
+39 -47
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="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
-
<div class="px-1">
-
{{ block "topbarLayout" . }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
-
</header>
-
</div>
-
{{ end }}
-
</div>
+
<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-2 md:col-span-8 lg:col-start-3" style="z-index: 20;">
-
<div class="px-1 flex flex-col min-h-screen gap-4">
-
{{ block "contentLayout" . }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentLeft" . }} {{ end }}
+
{{ 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-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4">
+
{{ block "contentLayout" . }}
<main class="col-span-1 md:col-span-8">
{{ block "content" . }}{{ end }}
</main>
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentRight" . }} {{ end }}
-
</div>
-
</div>
-
{{ end }}
-
-
{{ block "contentAfterLayout" . }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterLeft" . }} {{ end }}
-
</div>
+
{{ end }}
+
+
{{ block "contentAfterLayout" . }}
<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>
-
</div>
-
{{ end }}
-
</div>
-
-
<div class="px-1 mt-16">
-
{{ block "footerLayout" . }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<footer class="col-span-1 md:col-start-3 md:col-span-8">
-
{{ template "layouts/footer" . }}
-
</footer>
+
{{ end }}
</div>
-
{{ end }}
-
</div>
+
{{ end }}
+
{{ block "footerLayout" . }}
+
<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>
</html>
{{ end }}
-7
appview/pages/templates/layouts/footer.html
···
-
{{ define "layouts/footer" }}
-
<div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm">
-
<div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm">
-
<span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a>
-
</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 }}
+
+23 -10
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>
···
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
</div>
-
{{ template "repo/fragments/repoActions" .RepoInfo }}
+
<div class="flex items-center gap-2 z-auto">
+
<a
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
+
>
+
{{ i "rss" "size-4" }}
+
</a>
+
{{ template "repo/fragments/repoStar" .RepoInfo }}
+
<a
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
+
hx-boost="true"
+
href="/{{ .RepoInfo.FullName }}/fork"
+
>
+
{{ i "git-fork" "w-4 h-4" }}
+
fork
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
</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 }}
-60
appview/pages/templates/layouts/topbar.html
···
-
{{ define "layouts/topbar" }}
-
<nav class="space-x-4 mb-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 class="hidden md:flex gap-4 items-center">
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
-
{{ i "message-circle" "size-4" }} discord
-
</a>
-
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
-
{{ i "hash" "size-4" }} irc
-
</a>
-
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
-
{{ i "code" "size-4" }} source
-
</a>
-
</div>
-
<div id="right-items" class="flex items-center gap-4">
-
{{ with .LoggedInUser }}
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
-
{{ i "plus" "w-4 h-4" }}
-
</a>
-
{{ block "dropDown" . }} {{ end }}
-
{{ else }}
-
<a href="/login">login</a>
-
{{ end }}
-
</div>
-
</div>
-
</nav>
-
{{ end }}
-
-
{{ define "dropDown" }}
-
<details class="relative inline-block text-left">
-
<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="/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>
-
{{ end }}
+11
appview/pages/templates/legal/privacy.html
···
+
{{ 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">
+
{{ .Content }}
+
</div>
+
</div>
+
</div>
+
{{ end }}
+11
appview/pages/templates/legal/terms.html
···
+
{{ define "title" }}terms of service{{ 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">
+
{{ .Content }}
+
</div>
+
</div>
+
</div>
+
{{ end }}
+19 -6
appview/pages/templates/repo/blob.html
···
{{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }}
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
-
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
-
+
{{ end }}
{{ define "repoContent" }}
···
<a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
{{ if .RenderToggle }}
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
-
<a
-
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
+
<a
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
hx-boost="true"
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
{{ end }}
</div>
</div>
</div>
-
{{ if .IsBinary }}
+
{{ if and .IsBinary .Unsupported }}
<p class="text-center text-gray-400 dark:text-gray-500">
-
This is a binary file and will not be displayed.
+
Previews are not supported for this file type.
</p>
+
{{ else if .IsBinary }}
+
<div class="text-center">
+
{{ if .IsImage }}
+
<img src="{{ .ContentSrc }}"
+
alt="{{ .Path }}"
+
class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
+
{{ else if .IsVideo }}
+
<video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
+
<source src="{{ .ContentSrc }}">
+
Your browser does not support the video tag.
+
</video>
+
{{ end }}
+
</div>
{{ else }}
<div class="overflow-auto relative">
{{ if .ShowRendered }}
+21 -15
appview/pages/templates/repo/commit.html
···
{{end}}
{{ define "topbarLayout" }}
-
<header style="z-index: 20;">
-
{{ template "layouts/topbar" . }}
+
<header class="px-1 col-span-full" style="z-index: 20;">
+
{{ template "layouts/fragments/topbar" . }}
</header>
{{ end }}
-
{{ define "contentLayout" }}
-
{{ block "content" . }}{{ end }}
-
{{ end }}
+
{{ define "mainLayout" }}
+
<div class="px-1 col-span-full flex flex-col gap-4">
+
{{ block "contentLayout" . }}
+
{{ block "content" . }}{{ end }}
+
{{ end }}
-
{{ define "contentAfterLayout" }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterLeft" . }} {{ end }}
+
{{ block "contentAfterLayout" . }}
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
+
{{ block "contentAfterLeft" . }} {{ end }}
+
</div>
+
<main class="col-span-1 md:col-span-10">
+
{{ block "contentAfter" . }}{{ end }}
+
</main>
</div>
-
<main class="col-span-1 md:col-span-10">
-
{{ block "contentAfter" . }}{{ end }}
-
</main>
+
{{ end }}
</div>
{{ end }}
-
{{ define "footerLayout" }}
-
{{ template "layouts/footer" . }}
+
{{ define "footerLayout" }}
+
<footer class="px-1 col-span-full mt-12">
+
{{ template "layouts/fragments/footer" . }}
+
</footer>
{{ end }}
{{ define "contentAfter" }}
···
<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 mt-4">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/diffChangedFiles" .Diff }}
</div>
{{end}}
+22 -14
appview/pages/templates/repo/compare/compare.html
···
{{ end }}
{{ define "topbarLayout" }}
-
{{ template "layouts/topbar" . }}
+
<header class="px-1 col-span-full" style="z-index: 20;">
+
{{ template "layouts/fragments/topbar" . }}
+
</header>
{{ end }}
-
{{ define "contentLayout" }}
-
{{ block "content" . }}{{ end }}
-
{{ end }}
+
{{ define "mainLayout" }}
+
<div class="px-1 col-span-full flex flex-col gap-4">
+
{{ block "contentLayout" . }}
+
{{ block "content" . }}{{ end }}
+
{{ end }}
-
{{ define "contentAfterLayout" }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterLeft" . }} {{ end }}
+
{{ block "contentAfterLayout" . }}
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
+
{{ block "contentAfterLeft" . }} {{ end }}
+
</div>
+
<main class="col-span-1 md:col-span-10">
+
{{ block "contentAfter" . }}{{ end }}
+
</main>
</div>
-
<main class="col-span-1 md:col-span-10">
-
{{ block "contentAfter" . }}{{ end }}
-
</main>
+
{{ end }}
</div>
{{ end }}
-
{{ define "footerLayout" }}
-
{{ template "layouts/footer" . }}
+
{{ define "footerLayout" }}
+
<footer class="px-1 col-span-full mt-12">
+
{{ template "layouts/fragments/footer" . }}
+
</footer>
{{ end }}
{{ define "contentAfter" }}
···
<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 mt-4">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/diffChangedFiles" .Diff }}
</div>
{{end}}
+5 -7
appview/pages/templates/repo/empty.html
···
<div class="py-6 w-fit flex flex-col gap-4">
<p>This is an empty repository. To get started:</p>
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
-
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
-
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
-
<p><span class="{{$bullet}}">3</span>Push!</p>
+
+
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
+
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
+
<p><span class="{{$bullet}}">4</span>Push!</p>
</div>
</div>
{{ else }}
···
{{ 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 }}
+1 -1
appview/pages/templates/repo/fragments/diffChangedFiles.html
···
{{ define "repo/fragments/diffChangedFiles" }}
{{ $stat := .Stat }}
{{ $fileTree := fileTree .ChangedFiles }}
-
<section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm">
<div class="diff-stat">
<div class="flex gap-2 items-center">
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+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 md:min-h-screen 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 }}
-48
appview/pages/templates/repo/fragments/repoActions.html
···
-
{{ define "repo/fragments/repoActions" }}
-
<div class="flex items-center gap-2 z-auto">
-
<button
-
id="starBtn"
-
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
-
{{ if .IsStarred }}
-
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ else }}
-
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
-
{{ end }}
-
-
hx-trigger="click"
-
hx-target="#starBtn"
-
hx-swap="outerHTML"
-
hx-disabled-elt="#starBtn"
-
>
-
{{ if .IsStarred }}
-
{{ i "star" "w-4 h-4 fill-current" }}
-
{{ else }}
-
{{ i "star" "w-4 h-4" }}
-
{{ end }}
-
<span class="text-sm">
-
{{ .Stats.StarCount }}
-
</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
{{ if .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="/{{ .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 }}
-
</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 }}
+26
appview/pages/templates/repo/fragments/repoStar.html
···
+
{{ define "repo/fragments/repoStar" }}
+
<button
+
id="starBtn"
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
+
{{ if .IsStarred }}
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
+
{{ else }}
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
+
{{ end }}
+
+
hx-trigger="click"
+
hx-target="this"
+
hx-swap="outerHTML"
+
hx-disabled-elt="#starBtn"
+
>
+
{{ if .IsStarred }}
+
{{ i "star" "w-4 h-4 fill-current" }}
+
{{ else }}
+
{{ i "star" "w-4 h-4" }}
+
{{ end }}
+
<span class="text-sm">
+
{{ .Stats.StarCount }}
+
</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
{{ 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 }}
+
+
+126 -139
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 }}
···
{{ define "commitLog" }}
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
<div class="flex justify-between items-center">
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
-
<div class="flex gap-2 items-center font-bold">
-
{{ i "logs" "w-4 h-4" }} commits
-
</div>
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
-
view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }}
-
</span>
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
+
{{ i "logs" "w-4 h-4" }} commits
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span>
</a>
</div>
<div class="flex flex-col gap-6">
···
</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 }}
···
{{ define "branchList" }}
{{ if gt (len .BranchesTrunc) 0 }}
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
-
<div class="flex gap-2 items-center font-bold">
-
{{ i "git-branch" "w-4 h-4" }} branches
-
</div>
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
-
view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }}
-
</span>
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
+
{{ i "git-branch" "w-4 h-4" }} branches
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span>
</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>
···
{{ if gt (len .TagsTrunc) 0 }}
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
-
<div class="flex gap-2 items-center font-bold">
-
{{ i "tags" "w-4 h-4" }} tags
-
</div>
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
-
view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }}
-
</span>
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
+
{{ i "tags" "w-4 h-4" }} tags
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span>
</a>
</div>
<div class="flex flex-col gap-1">
···
{{ end }}
{{ define "repoAfter" }}
-
{{- if .HTMLReadme -}}
-
<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">
-
{{- .HTMLReadme -}}
-
</pre>
-
{{- else -}}
-
{{ .HTMLReadme }}
-
{{- end -}}</article>
-
</section>
+
{{- if or .HTMLReadme .Readme -}}
+
<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 -47
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 text-sm">
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
-
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<span class="before:content-['ยท']"></span>
-
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
-
author
-
</span>
-
{{ end }}
-
-
<span class="before:content-['ยท']"></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
-
id="{{ .CommentId }}">
-
{{ template "repo/fragments/time" .Created }}
-
</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>
-
<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 }}
-60
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 }}
-
-
<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>
-
-
<!-- show user "hats" -->
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
-
{{ if $isIssueAuthor }}
-
<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">
-
author
-
</span>
-
{{ end }}
-
-
{{ $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 }}
+2 -2
appview/pages/templates/repo/log.html
···
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Date</div>
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
</div>
{{ range $index, $commit := .Commits }}
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
···
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
{{ end }}
</div>
-
<div class="align-top text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
+
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
</div>
{{ end }}
</div>
+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" }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
···
{{ define "sidebar" }}
{{ $active := .Workflow }}
+
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
+
{{ with .Pipeline }}
{{ $id := .Id }}
<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">
{{ range $name, $all := .Statuses }}
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
<div
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
{{ $lastStatus := $all.Latest }}
{{ $kind := $lastStatus.Status.String }}
+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>
+7 -9
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>
-
<div class="flex-shrink-0 flex items-center">
+
<div class="flex-shrink-0 flex items-center gap-2">
{{ $latestRound := .LastRoundNumber }}
{{ $lastSubmission := index .Submissions $latestRound }}
{{ $commentCount := len $lastSubmission.Comments }}
{{ if and $pipeline $pipeline.Id }}
-
<div class="inline-flex items-center gap-2">
-
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
-
</div>
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
{{ end }}
<span>
-
<div class="inline-flex items-center gap-2">
+
<div class="inline-flex items-center gap-1">
{{ i "message-square" "w-3 h-3 md:hidden" }}
{{ $commentCount }}
<span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span>
</div>
</span>
-
<span class="mx-2 before:content-['ยท'] before:select-none"></span>
+
<span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span>
<span>
<span class="hidden md:inline">round</span>
<span class="font-mono">#{{ $latestRound }}</span>
+22 -14
appview/pages/templates/repo/pulls/interdiff.html
···
{{ end }}
{{ define "topbarLayout" }}
-
{{ template "layouts/topbar" . }}
+
<header class="px-1 col-span-full" style="z-index: 20;">
+
{{ template "layouts/fragments/topbar" . }}
+
</header>
{{ end }}
-
{{ define "contentLayout" }}
-
{{ block "content" . }}{{ end }}
-
{{ end }}
+
{{ define "mainLayout" }}
+
<div class="px-1 col-span-full flex flex-col gap-4">
+
{{ block "contentLayout" . }}
+
{{ block "content" . }}{{ end }}
+
{{ end }}
-
{{ define "contentAfterLayout" }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterLeft" . }} {{ end }}
+
{{ block "contentAfterLayout" . }}
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
+
{{ block "contentAfterLeft" . }} {{ end }}
+
</div>
+
<main class="col-span-1 md:col-span-10">
+
{{ block "contentAfter" . }}{{ end }}
+
</main>
</div>
-
<main class="col-span-1 md:col-span-10">
-
{{ block "contentAfter" . }}{{ end }}
-
</main>
+
{{ end }}
</div>
{{ end }}
-
{{ define "footerLayout" }}
-
{{ template "layouts/footer" . }}
+
{{ define "footerLayout" }}
+
<footer class="px-1 col-span-full mt-12">
+
{{ 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 mt-4">
+
<div class="sticky top-0 flex-grow max-h-screen overflow-y-auto">
{{ template "repo/fragments/interdiffFiles" .Interdiff }}
</div>
{{end}}
+22 -14
appview/pages/templates/repo/pulls/patch.html
···
{{ end }}
{{ define "topbarLayout" }}
-
{{ template "layouts/topbar" . }}
+
<header class="px-1 col-span-full" style="z-index: 20;">
+
{{ template "layouts/fragments/topbar" . }}
+
</header>
{{ end }}
-
{{ define "contentLayout" }}
-
{{ block "content" . }}{{ end }}
-
{{ end }}
+
{{ define "mainLayout" }}
+
<div class="px-1 col-span-full flex flex-col gap-4">
+
{{ block "contentLayout" . }}
+
{{ block "content" . }}{{ end }}
+
{{ end }}
-
{{ define "contentAfterLayout" }}
-
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
-
<div class="col-span-1 md:col-span-2">
-
{{ block "contentAfterLeft" . }} {{ end }}
+
{{ block "contentAfterLayout" . }}
+
<div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4">
+
<div class="flex flex-col gap-4 col-span-1 md:col-span-2">
+
{{ block "contentAfterLeft" . }} {{ end }}
+
</div>
+
<main class="col-span-1 md:col-span-10">
+
{{ block "contentAfter" . }}{{ end }}
+
</main>
</div>
-
<main class="col-span-1 md:col-span-10">
-
{{ block "contentAfter" . }}{{ end }}
-
</main>
+
{{ end }}
</div>
{{ end }}
-
{{ define "footerLayout" }}
-
{{ template "layouts/footer" . }}
+
{{ define "footerLayout" }}
+
<footer class="px-1 col-span-full mt-12">
+
{{ template "layouts/fragments/footer" . }}
+
</footer>
{{ end }}
{{ define "contentAfter" }}
···
<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 mt-4">
+
<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>
+45 -55
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>
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
+
<div 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" }}
···
</span>
<span class="ml-1">
-
{{ template "user/fragments/picHandleLink" $owner }}
+
{{ template "user/fragments/picHandleLink" .OwnerDid }}
</span>
<span class="before:content-['ยท']">
{{ template "repo/fragments/time" .Created }}
</span>
+
+
{{ $latestRound := .LastRoundNumber }}
+
{{ $lastSubmission := index .Submissions $latestRound }}
+
<span class="before:content-['ยท']">
-
targeting
-
<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">
-
{{ .TargetBranch }}
-
</span>
-
</span>
-
{{ if not .IsPatchBased }}
-
from
-
<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 .IsForkBased }}
-
{{ if .PullSource.Repo }}
-
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>:
-
{{- else -}}
-
<span class="italic">[deleted fork]</span>
-
{{- end -}}
-
{{- end -}}
-
{{- .PullSource.Branch -}}
+
{{ $commentCount := len $lastSubmission.Comments }}
+
{{ $s := "s" }}
+
{{ if eq $commentCount 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
+
{{ len $lastSubmission.Comments}} comment{{$s}}
</span>
-
{{ end }}
-
<span class="before:content-['ยท']">
-
{{ $latestRound := .LastRoundNumber }}
-
{{ $lastSubmission := index .Submissions $latestRound }}
-
round
-
<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">
-
#{{ .LastRoundNumber }}
-
</span>
-
{{ $commentCount := len $lastSubmission.Comments }}
-
{{ $s := "s" }}
-
{{ if eq $commentCount 1 }}
-
{{ $s = "" }}
-
{{ end }}
-
{{ if eq $commentCount 0 }}
-
awaiting comments
-
{{ else }}
-
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
-
{{ end }}
+
<span class="before:content-['ยท']">
+
round
+
<span class="font-mono">
+
#{{ .LastRoundNumber }}
+
</span>
</span>
-
</p>
+
+
{{ $pipeline := index $.Pipelines .LatestSha }}
+
{{ if and $pipeline $pipeline.Id }}
+
<span class="before:content-['ยท']"></span>
+
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
+
{{ end }}
+
</div>
</div>
{{ if .StackId }}
{{ $otherPulls := index $.Stacks .StackId }}
-
<details class="bg-white dark:bg-gray-800 group">
-
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
-
{{ $s := "s" }}
-
{{ if eq (len $otherPulls) 1 }}
-
{{ $s = "" }}
-
{{ end }}
-
<div class="group-open:hidden flex items-center gap-2">
-
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
-
</div>
-
<div class="hidden group-open:flex items-center gap-2">
-
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
-
</div>
-
</summary>
-
{{ block "pullList" (list $otherPulls $) }} {{ end }}
-
</details>
+
{{ if gt (len $otherPulls) 0 }}
+
<details class="bg-white dark:bg-gray-800 group">
+
<summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
{{ $s := "s" }}
+
{{ if eq (len $otherPulls) 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<div class="group-open:hidden flex items-center gap-2">
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack
+
</div>
+
<div class="hidden group-open:flex items-center gap-2">
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack
+
</div>
+
</summary>
+
{{ block "pullList" (list $otherPulls $) }} {{ end }}
+
</details>
+
{{ end }}
{{ end }}
</div>
{{ end }}
···
{{ $root := index . 1 }}
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
{{ range $pull := $list }}
+
{{ $pipeline := index $root.Pipelines $pull.LatestSha }}
<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 0) }}
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
</div>
</div>
</a>
+110
appview/pages/templates/repo/settings/access.html
···
+
{{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "repoContent" }}
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
+
<div class="col-span-1">
+
{{ template "repo/settings/fragments/sidebar" . }}
+
</div>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
+
{{ template "collaboratorSettings" . }}
+
</div>
+
</section>
+
{{ end }}
+
+
{{ define "collaboratorSettings" }}
+
<div class="grid grid-cols-1 gap-4 items-center">
+
<div class="col-span-1">
+
<h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows.
+
</p>
+
</div>
+
{{ template "collaboratorsGrid" . }}
+
</div>
+
{{ end }}
+
+
{{ define "collaboratorsGrid" }}
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
+
{{ template "addCollaboratorButton" . }}
+
{{ end }}
+
{{ range .Collaborators }}
+
<div class="border border-gray-200 dark:border-gray-700 rounded p-4">
+
<div class="flex items-center gap-3">
+
<img
+
src="{{ fullAvatar .Handle }}"
+
alt="{{ .Handle }}"
+
class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/>
+
+
<div class="flex-1 min-w-0">
+
<a href="/{{ .Handle }}" class="block truncate">
+
{{ didOrHandle .Did .Handle }}
+
</a>
+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p>
+
</div>
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "addCollaboratorButton" }}
+
<button
+
class="btn block rounded p-4"
+
popovertarget="add-collaborator-modal"
+
popovertargetaction="toggle">
+
<div class="flex items-center gap-3">
+
<div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
+
{{ i "user-plus" "size-4" }}
+
</div>
+
+
<div class="text-left flex-1 min-w-0 block truncate">
+
Add collaborator
+
</div>
+
</div>
+
</button>
+
<div
+
id="add-collaborator-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 "addCollaboratorModal" . }}
+
</div>
+
{{ end }}
+
+
{{ define "addCollaboratorModal" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
class="flex flex-col gap-2"
+
>
+
<label for="add-collaborator" class="uppercase p-0">
+
ADD COLLABORATOR
+
</label>
+
<p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p>
+
<input
+
type="text"
+
id="add-collaborator"
+
name="collaborator"
+
required
+
placeholder="@foo.bsky.social"
+
/>
+
<div class="flex gap-2 pt-2">
+
<button
+
type="button"
+
popovertarget="add-collaborator-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 "user-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="add-collaborator-error" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
···
+
{{ define "repo/settings/fragments/secretListing" }}
+
{{ $root := index . 0 }}
+
{{ $secret := index . 1 }}
+
<div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2">
+
<div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
+
<span class="font-mono">
+
{{ $secret.Key }}
+
</span>
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
+
<span>added by</span>
+
<span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span>
+
<span class="before:content-['ยท'] before:select-none"></span>
+
<span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</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 secret"
+
hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets"
+
hx-swap="none"
+
hx-vals='{"key": "{{ $secret.Key }}"}'
+
hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?"
+
>
+
{{ 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/repo/settings/fragments/sidebar.html
···
+
{{ define "repo/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="/{{ $.RepoInfo.FullName }}/settings?tab={{.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 }}
+70
appview/pages/templates/repo/settings/general.html
···
+
{{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "repoContent" }}
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
+
<div class="col-span-1">
+
{{ template "repo/settings/fragments/sidebar" . }}
+
</div>
+
<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 }}
+
+
{{ define "branchSettings" }}
+
<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">Default Branch</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
The default branch is considered the โ€œbaseโ€ branch in your repository,
+
against which all pull requests and code commits are automatically made,
+
unless you specify a different branch.
+
</p>
+
</div>
+
<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
+
</option>
+
{{ range .Branches }}
+
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
+
{{ .Name }}
+
</option>
+
{{ end }}
+
</select>
+
<button class="btn flex gap-2 items-center" type="submit">
+
{{ i "check" "size-4" }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</form>
+
</div>
+
{{ end }}
+
+
{{ define "deleteRepo" }}
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
+
<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 text-red-500 dark:text-red-400 font-bold">Delete Repository</h2>
+
<p class="text-red-500 dark:text-red-400 ">
+
Deleting a repository is irreversible and permanent. Be certain before deleting a repository.
+
</p>
+
</div>
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
+
<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" }}
+
delete
+
<span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline">
+
{{ i "loader-circle" "w-4 h-4" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+145
appview/pages/templates/repo/settings/pipelines.html
···
+
{{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "repoContent" }}
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2">
+
<div class="col-span-1">
+
{{ template "repo/settings/fragments/sidebar" . }}
+
</div>
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
+
{{ template "spindleSettings" . }}
+
{{ if $.CurrentSpindle }}
+
{{ template "secretSettings" . }}
+
{{ end }}
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
+
</div>
+
</section>
+
{{ end }}
+
+
{{ define "spindleSettings" }}
+
<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">Spindle</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Choose a spindle to execute your workflows on. Only repository owners
+
can configure spindles. Spindles can be selfhosted,
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
+
click to learn more.
+
</a>
+
</p>
+
</div>
+
{{ if not $.RepoInfo.Roles.IsOwner }}
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
+
{{ or $.CurrentSpindle "No spindle configured" }}
+
</div>
+
{{ else }}
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
+
<select
+
id="spindle"
+
name="spindle"
+
required
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
+
{{ if not $.CurrentSpindle }}
+
Choose a spindle
+
{{ else }}
+
Disable pipelines
+
{{ end }}
+
</option>
+
{{ range $.Spindles }}
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
+
{{ . }}
+
</option>
+
{{ end }}
+
</select>
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
+
{{ i "check" "size-4" }}
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</form>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "secretSettings" }}
+
<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">SECRETS</h2>
+
<p class="text-gray-500 dark:text-gray-400">
+
Secrets are accessible in workflow runs via environment variables. Anyone
+
with collaborator access to this repository can add and use secrets in
+
workflow runs.
+
</p>
+
</div>
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
+
{{ template "addSecretButton" . }}
+
</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 .Secrets }}
+
{{ template "repo/settings/fragments/secretListing" (list $ .) }}
+
{{ else }}
+
<div class="flex items-center justify-center p-2 text-gray-500">
+
no secrets added yet
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "addSecretButton" }}
+
<button
+
class="btn flex items-center gap-2"
+
popovertarget="add-secret-modal"
+
popovertargetaction="toggle">
+
{{ i "plus" "size-4" }}
+
add secret
+
</button>
+
<div
+
id="add-secret-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 "addSecretModal" . }}
+
</div>
+
{{ end}}
+
+
{{ define "addSecretModal" }}
+
<form
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
+
hx-indicator="#spinner"
+
hx-swap="none"
+
class="flex flex-col gap-2"
+
>
+
<p class="uppercase p-0">ADD SECRET</p>
+
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
+
<input
+
type="text"
+
id="secret-key"
+
name="key"
+
required
+
placeholder="SECRET_NAME"
+
/>
+
<textarea
+
type="text"
+
id="secret-value"
+
name="value"
+
required
+
placeholder="secret value"></textarea>
+
<div class="flex gap-2 pt-2">
+
<button
+
type="button"
+
popovertarget="add-secret-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="add-secret-error" class="text-red-500 dark:text-red-400"></div>
+
</form>
+
{{ end }}
-138
appview/pages/templates/repo/settings.html
···
-
{{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }}
-
{{ define "repoContent" }}
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Collaborators
-
</header>
-
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
-
{{ range .Collaborators }}
-
<div id="collaborator" class="mb-2">
-
<a
-
href="/{{ didOrHandle .Did .Handle }}"
-
class="no-underline hover:underline text-black dark:text-white"
-
>
-
{{ didOrHandle .Did .Handle }}
-
</a>
-
<div>
-
<span class="text-sm text-gray-500 dark:text-gray-400">
-
{{ .Role }}
-
</span>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
-
class="group"
-
>
-
<label for="collaborator" class="dark:text-white">
-
add collaborator
-
</label>
-
<input
-
type="text"
-
id="collaborator"
-
name="collaborator"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="enter did or handle"
-
>
-
<button
-
class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700"
-
type="text"
-
>
-
<span>add</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</form>
-
{{ end }}
-
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default"
-
class="mt-6 group"
-
>
-
<label for="branch">default branch</label>
-
<div class="flex gap-2 items-center">
-
<select id="branch" name="branch" required class="p-1 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
-
</option>
-
{{ range .Branches }}
-
<option
-
value="{{ .Name }}"
-
class="py-1"
-
{{ if .IsDefault }}
-
selected
-
{{ end }}
-
>
-
{{ .Name }}
-
</option>
-
{{ end }}
-
</select>
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
-
<span>save</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</form>
-
-
{{ if .RepoInfo.Roles.IsOwner }}
-
<form
-
hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle"
-
class="mt-6 group"
-
>
-
<label for="spindle">spindle</label>
-
<div class="flex gap-2 items-center">
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
-
<option
-
value=""
-
selected
-
>
-
None
-
</option>
-
{{ range .Spindles }}
-
<option
-
value="{{ . }}"
-
class="py-1"
-
{{ if eq . $.CurrentSpindle }}
-
selected
-
{{ end }}
-
>
-
{{ . }}
-
</option>
-
{{ end }}
-
</select>
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
-
<span>save</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</form>
-
{{ end }}
-
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
-
<form
-
hx-confirm="Are you sure you want to delete this repository?"
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
-
class="mt-6"
-
hx-indicator="#delete-repo-spinner"
-
>
-
<label for="branch">delete repository</label>
-
<button class="btn my-2 flex items-center" type="text">
-
<span>delete</span>
-
<span id="delete-repo-spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<span>
-
Deleting a repository is irreversible and permanent.
-
</span>
-
</form>
-
{{ end }}
-
-
{{ end }}
+8 -2
appview/pages/templates/repo/tags.html
···
{{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }}
{{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }}
-
{{ if or (gt (len $artifacts) 0) $isPushAllowed }}
<h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2>
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700">
{{ range $artifact := $artifacts }}
{{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }}
{{ template "repo/fragments/artifact" $args }}
{{ end }}
+
<div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
+
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
+
{{ i "archive" "w-4 h-4" }}
+
<a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline">
+
Source code (.tar.gz)
+
</a>
+
</div>
+
</div>
{{ if $isPushAllowed }}
{{ block "uploadArtifact" (list $root $tag) }} {{ end }}
{{ end }}
</div>
-
{{ end }}
{{ end }}
{{ define "uploadArtifact" }}
+5 -4
appview/pages/templates/repo/tree.html
···
{{ range .Files }}
<div class="grid grid-cols-12 gap-4 items-center py-1">
-
<div class="col-span-6 md:col-span-3">
+
<div class="col-span-8 md:col-span-4">
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .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="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
+3 -3
appview/pages/templates/spindles/fragments/addMemberModal.html
···
<div
id="add-member-{{ .Instance }}"
popover
-
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
-
{{ block "addMemberPopover" . }} {{ end }}
+
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 "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" }}
+57
appview/pages/templates/strings/dashboard.html
···
+
{{ define "title" }}strings by {{ 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">
+
{{ template "user/fragments/profileCard" .Card }}
+
</div>
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
+
{{ block "allStrings" . }}{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "allStrings" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Strings }}
+
{{ template "singleString" (list $ .) }}
+
{{ 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 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/{{ 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 }}
+90
appview/pages/templates/strings/fragments/form.html
···
+
{{ define "strings/fragments/form" }}
+
<form
+
{{ if eq .Action "new" }}
+
hx-post="/strings/new"
+
{{ else }}
+
hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit"
+
{{ end }}
+
hx-indicator="#new-button"
+
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"
+
hx-swap="none">
+
<div class="flex flex-col md:flex-row md:items-center gap-2">
+
<input
+
type="text"
+
id="filename"
+
name="filename"
+
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"
+
>
+
<input
+
type="text"
+
id="description"
+
name="description"
+
value="{{ .String.Description }}"
+
placeholder="Description ..."
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
+
>
+
</div>
+
<textarea
+
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 font-mono"
+
rows="20"
+
spellcheck="false"
+
placeholder="Paste your string here!"
+
required>{{ .String.Contents }}</textarea>
+
<div class="flex justify-between items-center">
+
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
+
<span id="line-count">0 lines</span>
+
<span class="select-none px-1 [&:before]:content-['ยท']"></span>
+
<span id="byte-count">0 bytes</span>
+
</div>
+
<div id="actions" class="flex gap-2 items-center">
+
{{ if eq .Action "edit" }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 "
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}">
+
{{ i "x" "size-4" }}
+
<span class="hidden md:inline">cancel</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
{{ end }}
+
<button
+
type="submit"
+
id="new-button"
+
class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
+
>
+
<span class="inline-flex items-center gap-2">
+
{{ i "arrow-up" "w-4 h-4" }}
+
publish
+
</span>
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
+
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
+
</span>
+
</button>
+
</div>
+
</div>
+
<script>
+
(function() {
+
const textarea = document.getElementById('content-textarea');
+
const lineCount = document.getElementById('line-count');
+
const byteCount = document.getElementById('byte-count');
+
function updateStats() {
+
const content = textarea.value;
+
const lines = content === '' ? 0 : content.split('\n').length;
+
const bytes = new TextEncoder().encode(content).length;
+
lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`;
+
byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`;
+
}
+
textarea.addEventListener('input', updateStats);
+
textarea.addEventListener('paste', () => {
+
setTimeout(updateStats, 0);
+
});
+
updateStats();
+
})();
+
</script>
+
<div id="error" class="error dark:text-red-400"></div>
+
</form>
+
{{ end }}
+13
appview/pages/templates/strings/put.html
···
+
{{ define "title" }}publish a new string{{ end }}
+
+
{{ define "content" }}
+
<div class="px-6 py-2 mb-4">
+
{{ if eq .Action "new" }}
+
<p class="text-xl font-bold dark:text-white">Create a new string</p>
+
<p class="">Store and share code snippets with ease.</p>
+
{{ else }}
+
<p class="text-xl font-bold dark:text-white">Edit string</p>
+
{{ end }}
+
</div>
+
{{ template "strings/fragments/form" . }}
+
{{ end }}
+84
appview/pages/templates/strings/string.html
···
+
{{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }}
+
+
{{ define "extrameta" }}
+
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
+
<meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" />
+
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
+
<meta property="og:description" content="{{ .String.Description }}" />
+
{{ 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">
+
<div class="text-lg flex items-center justify-between">
+
<div>
+
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
+
<span class="select-none">/</span>
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
+
</div>
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
+
<div class="flex gap-2 text-base">
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
+
hx-boost="true"
+
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
+
{{ i "pencil" "size-4" }}
+
<span class="hidden md:inline">edit</span>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</a>
+
<button
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2"
+
title="Delete string"
+
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
+
hx-swap="none"
+
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>
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
+
</button>
+
</div>
+
{{ end }}
+
</div>
+
<span>
+
{{ with .String.Description }}
+
{{ . }}
+
{{ end }}
+
</span>
+
</section>
+
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
+
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
+
<span>
+
{{ .String.Filename }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>
+
{{ with .String.Edited }}
+
edited {{ template "repo/fragments/shortTimeAgo" . }}
+
{{ else }}
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
+
{{ end }}
+
</span>
+
</span>
+
<div>
+
<span>{{ .Stats.LineCount }} lines</span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<span>{{ byteFmt .Stats.ByteCount }}</span>
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a>
+
{{ if .RenderToggle }}
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+
<a href="?code={{ .ShowRendered }}" hx-boost="true">
+
view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}
+
</a>
+
{{ end }}
+
</div>
+
</div>
+
<div class="overflow-x-auto overflow-y-hidden relative">
+
{{ if .ShowRendered }}
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
+
{{ else }}
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
+
{{ end }}
+
</div>
+
</section>
+
{{ end }}
+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="/login" class="no-underline hover:no-underline ">
-
<button class="btn 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 }}
+102
appview/pages/templates/user/completeSignup.html
···
+
{{ define "user/completeSignup" }}
+
<!doctype 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
+
property="og:title"
+
content="complete signup ยท tangled"
+
/>
+
<meta
+
property="og:url"
+
content="https://tangled.sh/complete-signup"
+
/>
+
<meta
+
property="og:description"
+
content="complete your signup for tangled"
+
/>
+
<script src="/static/htmx.min.js"></script>
+
<link
+
rel="stylesheet"
+
href="/static/tw.css?{{ cssContentHash }}"
+
type="text/css"
+
/>
+
<title>complete signup &middot; tangled</title>
+
</head>
+
<body class="flex items-center justify-center min-h-screen">
+
<main class="max-w-md px-6 -mt-4">
+
<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 flex flex-col gap-4"
+
hx-post="/signup/complete"
+
hx-swap="none"
+
hx-disabled-elt="#complete-signup-button"
+
>
+
<div class="flex flex-col">
+
<label for="code">verification code</label>
+
<input
+
type="text"
+
id="code"
+
name="code"
+
tabindex="1"
+
required
+
placeholder="tngl-sh-foo-bar"
+
/>
+
<span class="text-sm text-gray-500 mt-1">
+
Enter the code sent to your email.
+
</span>
+
</div>
+
+
<div class="flex flex-col">
+
<label for="username">username</label>
+
<input
+
type="text"
+
id="username"
+
name="username"
+
tabindex="2"
+
required
+
placeholder="jason"
+
/>
+
<span class="text-sm text-gray-500 mt-1">
+
Your complete handle will be of the form <code>user.tngl.sh</code>.
+
</span>
+
</div>
+
+
<div class="flex flex-col">
+
<label for="password">password</label>
+
<input
+
type="password"
+
id="password"
+
name="password"
+
tabindex="3"
+
required
+
/>
+
<span class="text-sm text-gray-500 mt-1">
+
Choose a strong password for your account.
+
</span>
+
</div>
+
+
<button
+
class="btn-create w-full my-2 mt-6 text-base"
+
type="submit"
+
id="complete-signup-button"
+
tabindex="4"
+
>
+
<span>complete signup</span>
+
</button>
+
</form>
+
<p id="signup-error" class="error w-full"></p>
+
<p id="signup-msg" class="dark:text-white w-full"></p>
+
</main>
+
</body>
+
</html>
+
{{ end }}
+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 }}
+22 -20
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">
-
{{ if .AvatarUri }}
<div class="w-3/4 aspect-square relative">
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
</div>
-
{{ end }}
</div>
<div class="col-span-2">
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
-
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
-
{{ didOrHandle .UserDid .UserHandle }}
-
</p>
+
<div class="flex items-center flex-row flex-nowrap gap-2">
+
<p title="{{ $userIdent }}"
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
+
{{ $userIdent }}
+
</p>
+
<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 }}
+39 -34
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">
-
{{- if $fullName -}}
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a>
-
{{- else -}}
-
<a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a>
-
{{- end -}}
+
<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="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
+
{{- else -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
+
{{- end -}}
+
</div>
+
{{ with .Description }}
+
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
+
{{ . | description }}
</div>
-
{{ with .Description }}
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
-
{{ . }}
-
</div>
-
{{ end }}
+
{{ end }}
-
{{ if .RepoStats }}
-
{{ block "repoStats" .RepoStats }} {{ end }}
-
{{ end }}
+
{{ if .RepoStats }}
+
{{ block "repoStats" .RepoStats }}{{ end }}
+
{{ end }}
</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-color: {{ langColor . }};"></div>
-
<span>{{ . }}</span>
-
</div>
+
<div class="flex gap-2 items-center text-sm">
+
{{ template "repo/fragments/languageBall" . }}
+
<span>{{ . }}</span>
+
</div>
{{ end }}
{{ with .StarCount }}
-
<div class="flex gap-1 items-center text-sm">
-
{{ i "star" "w-3 h-3 fill-current" }}
-
<span>{{ . }}</span>
-
</div>
+
<div class="flex gap-1 items-center text-sm">
+
{{ i "star" "w-3 h-3 fill-current" }}
+
<span>{{ . }}</span>
+
</div>
{{ end }}
{{ with .IssueCount.Open }}
-
<div class="flex gap-1 items-center text-sm">
-
{{ i "circle-dot" "w-3 h-3" }}
-
<span>{{ . }}</span>
-
</div>
+
<div class="flex gap-1 items-center text-sm">
+
{{ i "circle-dot" "w-3 h-3" }}
+
<span>{{ . }}</span>
+
</div>
{{ end }}
{{ with .PullCount.Open }}
-
<div class="flex gap-1 items-center text-sm">
-
{{ i "git-pull-request" "w-3 h-3" }}
-
<span>{{ . }}</span>
-
</div>
+
<div class="flex gap-1 items-center text-sm">
+
{{ i "git-pull-request" "w-3 h-3" }}
+
<span>{{ . }}</span>
+
</div>
{{ end }}
</div>
{{ end }}
-
-
+15 -35
appview/pages/templates/user/login.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
-
property="og:title"
-
content="login ยท tangled"
-
/>
-
<meta
-
property="og:url"
-
content="https://tangled.sh/login"
-
/>
-
<meta
-
property="og:description"
-
content="login to tangled"
-
/>
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<meta property="og:title" content="login ยท tangled" />
+
<meta property="og:url" content="https://tangled.sh/login" />
+
<meta property="og:description" content="login to for tangled" />
<script src="/static/htmx.min.js"></script>
-
<link
-
rel="stylesheet"
-
href="/static/tw.css?{{ cssContentHash }}"
-
type="text/css"
-
/>
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>login &middot; tangled</title>
</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.
···
name="handle"
tabindex="1"
required
+
placeholder="akshay.tngl.sh"
/>
<span class="text-sm text-gray-500 mt-1">
-
Use your
-
<a href="https://bsky.app">Bluesky</a> handle to log
-
in. You will then be redirected to your PDS to
-
complete authentication.
+
Use your <a href="https://atproto.com">ATProto</a>
+
handle to log in. If you're unsure, this is likely
+
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"
+
class="btn w-full my-2 mt-6 text-base "
type="submit"
id="login-button"
tabindex="3"
···
</button>
</form>
<p class="text-sm text-gray-500">
-
Join our <a href="https://chat.tangled.sh">Discord</a> or
-
IRC channel:
-
<a href="https://web.libera.chat/#tangled"
-
><code>#tangled</code> on Libera Chat</a
-
>.
+
Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now!
</p>
+
<p id="login-msg" class="error w-full"></p>
</main>
</body>
+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 }}
+55
appview/pages/templates/user/signup.html
···
+
{{ define "user/signup" }}
+
<!doctype 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 property="og:title" content="signup ยท tangled" />
+
<meta property="og:url" content="https://tangled.sh/signup" />
+
<meta property="og:description" content="sign up for tangled" />
+
<script src="/static/htmx.min.js"></script>
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
+
<title>sign up &middot; tangled</title>
+
</head>
+
<body class="flex items-center justify-center min-h-screen">
+
<main class="max-w-md px-6 -mt-4">
+
<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"
+
hx-post="/signup"
+
hx-swap="none"
+
hx-disabled-elt="#signup-button"
+
>
+
<div class="flex flex-col mt-2">
+
<label for="email">email</label>
+
<input
+
type="email"
+
id="email"
+
name="email"
+
tabindex="4"
+
required
+
placeholder="jason@bourne.co"
+
/>
+
</div>
+
<span class="text-sm text-gray-500 mt-1">
+
You will receive an email with an invite code. Enter your
+
invite code, desired username, and password in the next
+
page to complete your registration.
+
</span>
+
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
+
<span>join now</span>
+
</button>
+
</form>
+
<p class="text-sm text-gray-500">
+
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
+
</p>
+
+
<p id="signup-msg" class="error w-full"></p>
+
</main>
+
</body>
+
</html>
+
{{ end }}
+
+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(),
+407 -267
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",
+
+
// 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,
}
-
case 400:
-
return types.MergeCheckResponse{
-
Error: "failed to check merge status: does this knot support PRs?",
-
}
+
}
+
+
result := types.MergeCheckResponse{
+
IsConflicted: resp.Is_conflicted,
+
Conflicts: conflicts,
}
-
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",
-
}
+
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
···
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 {
···
// we want to group all stacked PRs into just one list
stacks := make(map[string]db.Stack)
+
var shas []string
n := 0
for _, p := range pulls {
+
// store the sha for later
+
shas = append(shas, p.LatestSha())
// this PR is stacked
if p.StackId != "" {
// we have already seen this PR stack
···
}
pulls = pulls[:n]
-
identsToResolve := make([]string, len(pulls))
-
for i, pull := range pulls {
-
identsToResolve[i] = pull.OwnerDid
+
repoInfo := f.RepoInfo(user)
+
ps, err := db.GetPipelineStatuses(
+
s.db,
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
+
db.FilterEq("repo_name", repoInfo.Name),
+
db.FilterEq("knot", repoInfo.Knot),
+
db.FilterIn("sha", shas),
+
)
+
if err != nil {
+
log.Printf("failed to fetch pipeline statuses: %s", err)
+
// non-fatal
}
-
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()
-
}
+
m := make(map[string]db.Pipeline)
+
for _, p := range ps {
+
m[p.Sha] = p
}
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)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
s.pages.Error503(w)
-
return
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
+
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
+
}
+
+
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
+
// }
-
caps, err := us.Capabilities()
-
if err != nil {
-
log.Println("error fetching knot caps", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
-
return
+
// TODO: make capabilities an xrpc call
+
caps := struct {
+
PullRequests struct {
+
FormatPatch bool
+
BranchSubmissions bool
+
ForkSubmissions bool
+
PatchSubmissions bool
+
}
+
}{
+
PullRequests: struct {
+
FormatPatch bool
+
BranchSubmissions bool
+
ForkSubmissions bool
+
PatchSubmissions bool
+
}{
+
FormatPatch: true,
+
BranchSubmissions: true,
+
ForkSubmissions: true,
+
PatchSubmissions: true,
+
},
}
+
+
// caps, err := us.Capabilities()
+
// if err != nil {
+
// log.Println("error fetching knot caps", f.Knot, err)
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
// return
+
// }
if !caps.PullRequests.FormatPatch {
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
···
sourceBranch string,
isStacked bool,
) {
-
// Generate a patch using /compare
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
}
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.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
}
···
}
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
-
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
+
repoString := strings.SplitN(forkRepo, "/", 2)
+
forkOwnerDid := repoString[0]
+
repoName := repoString[1]
+
fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName)
if errors.Is(err, sql.ErrNoRows) {
s.pages.Notice(w, "pull", "No such fork.")
return
···
return
}
-
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
-
if err != nil {
-
log.Println("failed to fetch registration key:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create signed client:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
-
-
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client:", err)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
-
return
-
}
+
client, err := s.oauth.ServiceClient(
+
r,
+
oauth.WithService(fork.Knot),
+
oauth.WithLxm(tangled.RepoHiddenRefNSID),
+
oauth.WithDev(s.config.Core.Dev),
+
)
-
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
-
if err != nil {
-
log.Println("failed to create hidden ref:", err, resp.StatusCode)
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
resp, err := tangled.RepoHiddenRef(
+
r.Context(),
+
client,
+
&tangled.RepoHiddenRef_Input{
+
ForkRef: sourceBranch,
+
RemoteRef: targetBranch,
+
Repo: fork.RepoAt().String(),
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
s.pages.Notice(w, "pull", err.Error())
return
}
-
switch resp.StatusCode {
-
case 404:
-
case 400:
-
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
+
if !resp.Success {
+
errorMsg := "Failed to create pull request"
+
if resp.Error != nil {
+
errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error)
+
}
+
s.pages.Notice(w, "pull", errorMsg)
return
}
···
// hiddenRef: hidden/feature-1/main (on repo-fork)
// targetBranch: main (on repo-1)
// sourceBranch: feature-1 (on repo-fork)
-
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
+
forkScheme := "http"
+
if !s.config.Core.Dev {
+
forkScheme = "https"
+
}
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot)
+
forkXrpcc := &indigoxrpc.Client{
+
Host: forkHost,
+
}
+
+
forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name)
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
log.Println("failed to compare across branches", err)
s.pages.Notice(w, "pull", err.Error())
return
}
+
var comparison types.RepoFormatPatchResponse
+
if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
+
log.Println("failed to decode XRPC compare response for fork", err)
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
+
return
+
}
+
sourceRev := comparison.Rev2
patch := comparison.Patch
···
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
+
}
+
+
// Decode target branches
+
var targetBranches types.RepoBranchesResponse
+
if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil {
+
log.Println("failed to decode target branches XRPC response", err)
+
s.pages.Error503(w)
return
-
sourceBranches := sourceResult.Branches
-
sort.Slice(sourceBranches, func(i int, j int) bool {
-
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
+
sort.Slice(sourceBranches.Branches, func(i int, j int) bool {
+
return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When)
})
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
RepoInfo: f.RepoInfo(user),
-
SourceBranches: sourceBranches,
-
TargetBranches: targetResult.Branches,
+
SourceBranches: sourceBranches.Branches,
+
TargetBranches: targetBranches.Branches,
})
···
return
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
-
if err != nil {
-
log.Printf("failed to create client for %s: %s", f.Knot, err)
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
-
return
+
scheme := "http"
+
if !s.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.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))
+
}
+200 -102
appview/repo/index.go
···
package repo
import (
-
"encoding/json"
+
"errors"
"fmt"
"log"
"net/http"
"slices"
"sort"
"strings"
+
"sync"
+
"time"
+
"context"
+
"encoding/json"
+
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/go-git/go-git/v5/plumbing"
+
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/commitverify"
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
"tangled.sh/tangled.sh/core/appview/reporesolver"
-
"tangled.sh/tangled.sh/core/knotclient"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
"tangled.sh/tangled.sh/core/types"
"github.com/go-chi/chi/v5"
···
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
ref := chi.URLParam(r, "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 {
-
rp.pages.Error503(w)
-
log.Println("failed to reach knotserver", err)
-
return
+
user := rp.oauth.GetUser(r)
+
repoInfo := f.RepoInfo(user)
+
+
// Build index response from multiple XRPC calls
+
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
+
log.Println("failed to call XRPC repo.index", err)
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
+
LoggedInUser: user,
+
NeedsKnotUpgrade: true,
+
RepoInfo: repoInfo,
+
})
+
return
+
} else {
+
rp.pages.Error503(w)
+
log.Println("failed to build index response", err)
+
return
+
}
}
tagMap := make(map[string][]string)
···
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc)
if err != nil {
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)
-
forkInfo := types.ForkInfo{
-
IsFork: repoInfo.Source != nil,
-
Status: types.UpToDate,
-
}
-
-
if !forkInfo.IsFork {
-
forkInfo.IsFork = false
-
return &forkInfo, nil
-
}
-
-
us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev)
+
// first get branches to determine the ref if not specified
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
return nil, err
}
-
result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
var branchesResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
return nil, err
}
-
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
-
return branch.Name == f.Ref
-
}) {
-
forkInfo.Status = types.MissingBranch
-
return &forkInfo, nil
+
// if no ref specified, use default branch or first available
+
if ref == "" && len(branchesResp.Branches) > 0 {
+
for _, branch := range branchesResp.Branches {
+
if branch.IsDefault {
+
ref = branch.Name
+
break
+
}
+
}
+
if ref == "" {
+
ref = branchesResp.Branches[0].Name
+
}
}
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
-
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
-
log.Printf("failed to update tracking branch: %s", err)
-
return nil, err
+
// check if repo is empty
+
if len(branchesResp.Branches) == 0 {
+
return &types.RepoIndexResponse{
+
IsEmpty: true,
+
Branches: branchesResp.Branches,
+
}, nil
}
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
+
// now run the remaining queries in parallel
+
var wg sync.WaitGroup
+
var errs error
+
+
var (
+
tagsResp types.RepoTagsResponse
+
treeResp *tangled.RepoTree_Output
+
logResp types.RepoLogResponse
+
readmeContent string
+
readmeFileName string
+
)
+
+
// tags
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
+
if err != nil {
+
errs = errors.Join(errs, err)
+
return
+
}
+
+
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
+
errs = errors.Join(errs, err)
+
}
+
}()
+
+
// tree/files
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
+
if err != nil {
+
errs = errors.Join(errs, err)
+
return
+
}
+
treeResp = resp
+
}()
+
+
// commits
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
+
if err != nil {
+
errs = errors.Join(errs, err)
+
return
+
}
+
+
if err := json.Unmarshal(logBytes, &logResp); err != nil {
+
errs = errors.Join(errs, err)
+
}
+
}()
+
+
// readme content
+
wg.Add(1)
+
go func() {
+
defer wg.Done()
+
for _, filename := range markup.ReadmeFilenames {
+
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
+
if err != nil {
+
continue
+
}
-
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 blobResp == nil {
+
continue
+
}
+
+
readmeContent = blobResp.Content
+
readmeFileName = filename
+
break
+
}
+
}()
+
+
wg.Wait()
+
+
if errs != nil {
+
return nil, errs
+
}
+
+
var files []types.NiceTree
+
if treeResp != nil && treeResp.Files != nil {
+
for _, file := range treeResp.Files {
+
niceFile := types.NiceTree{
+
IsFile: file.Is_file,
+
IsSubtree: file.Is_subtree,
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
+
}
+
if file.Last_commit != nil {
+
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
+
niceFile.LastCommit = &types.LastCommitInfo{
+
Hash: plumbing.NewHash(file.Last_commit.Hash),
+
Message: file.Last_commit.Message,
+
When: when,
+
}
+
}
+
files = append(files, niceFile)
+
}
}
-
if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
-
log.Printf("failed to decode fork status: %s", err)
-
return nil, err
+
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,
}
-
forkInfo.Status = status.Status
-
return &forkInfo, nil
+
return result, nil
}
+953 -337
appview/repo/repo.go
···
"fmt"
"io"
"log"
+
"log/slog"
"net/http"
"net/url"
+
"path/filepath"
"slices"
"strconv"
"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"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/bluesky-social/indigo/atproto/syntax"
)
type Repo struct {
···
db *db.DB
enforcer *rbac.Enforcer
notifier notify.Notifier
+
logger *slog.Logger
+
serviceAuth *serviceauth.ServiceAuth
}
func New(
···
config *config.Config,
notifier notify.Notifier,
enforcer *rbac.Enforcer,
+
logger *slog.Logger,
) *Repo {
return &Repo{oauth: oauth,
repoResolver: repoResolver,
···
db: db,
notifier: notifier,
enforcer: enforcer,
+
logger: logger,
}
}
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
+
refParam := chi.URLParam(r, "ref")
+
f, err := rp.repoResolver.Resolve(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo)
+
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
+
return
+
}
+
+
// Set headers for file download
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam)
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
w.Header().Set("Content-Type", "application/gzip")
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
+
+
// Write the archive data directly
+
w.Write(archiveBytes)
+
}
+
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
ref := chi.URLParam(r, "ref")
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
limit := int64(60)
+
cursor := ""
+
if page > 1 {
+
// Convert page number to cursor (offset)
+
offset := (page - 1) * int(limit)
+
cursor = strconv.Itoa(offset)
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.log", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
repolog, err := us.Log(f.OwnerDid(), f.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)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
}
tagMap := make(map[string][]string)
-
for _, tag := range tagResult.Tags {
-
hash := tag.Hash
-
if tag.Tag != nil {
-
hash = tag.Tag.Target.String()
+
if tagBytes != nil {
+
var tagResp types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
+
for _, tag := range tagResp.Tags {
+
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
+
}
}
-
tagMap[hash] = append(tagMap[hash], tag.Name)
}
-
branchResult, err := us.Branches(f.OwnerDid(), f.RepoName)
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
}
-
for _, branch := range branchResult.Branches {
-
hash := branch.Hash
-
tagMap[hash] = append(tagMap[hash], branch.Name)
+
if branchBytes != nil {
+
var branchResp types.RepoBranchesResponse
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
+
for _, branch := range branchResp.Branches {
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
+
}
+
}
}
user := rp.oauth.GetUser(r)
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
if err != nil {
log.Println("failed to fetch email to did mapping", err)
}
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
if err != nil {
log.Println(err)
}
···
repoInfo := f.RepoInfo(user)
var shas []string
-
for _, c := range repolog.Commits {
+
for _, c := range xrpcResp.Commits {
shas = append(shas, c.Hash.String())
}
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
···
LoggedInUser: user,
TagMap: tagMap,
RepoInfo: repoInfo,
-
RepoLogResponse: *repolog,
+
RepoLogResponse: xrpcResp,
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
VerifiedCommits: vc,
Pipelines: pipelines,
···
return
}
-
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"
-
}
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)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
if err != nil {
-
log.Printf("Error reading response body: %v", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
var result types.RepoCommitResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
+
log.Println("failed to decode XRPC response", err)
+
rp.pages.Error503(w)
return
}
···
ref := chi.URLParam(r, "ref")
treePath := chi.URLParam(r, "*")
-
protocol := "http"
+
+
// if the tree path has a trailing slash, let's strip it
+
// so we don't 404
+
treePath = strings.TrimSuffix(treePath, "/")
+
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
}
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
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
}
-
var result types.RepoTreeResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
return
+
result := types.RepoTreeResponse{
+
Ref: xrpcResp.Ref,
+
Files: files,
+
}
+
+
if xrpcResp.Parent != nil {
+
result.Parent = *xrpcResp.Parent
+
}
+
if xrpcResp.Dotdot != nil {
+
result.DotDot = *xrpcResp.Dotdot
}
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
···
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(), ref)})
if treePath != "" {
for idx, elem := range strings.Split(treePath, "/") {
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
···
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
result, err := us.Tags(f.OwnerDid(), f.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)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
result, err := us.Branches(f.OwnerDid(), f.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")
filePath := chi.URLParam(r, "*")
-
protocol := "http"
+
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
}
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.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)
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
if err != nil {
-
log.Printf("Error reading response body: %v", err)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Error404(w)
return
}
-
var result types.RepoBlobResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
return
-
}
+
// Use XRPC response directly instead of converting to internal types
var breadcrumbs [][]string
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
if filePath != "" {
for idx, elem := range strings.Split(filePath, "/") {
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], 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 unsupported bool
+
var isImage bool
+
var isVideo bool
+
var contentSrc string
+
+
if resp.IsBinary != nil && *resp.IsBinary {
+
ext := strings.ToLower(filepath.Ext(resp.Path))
+
switch ext {
+
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
+
isImage = true
+
case ".mp4", ".webm", ".ogg", ".mov", ".avi":
+
isVideo = true
+
default:
+
unsupported = true
+
}
+
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
+
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
+
+
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,
+
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,
})
}
···
f, err := rp.repoResolver.Resolve(r)
if err != nil {
log.Println("failed to get repo and knot", err)
+
w.WriteHeader(http.StatusBadRequest)
return
}
ref := chi.URLParam(r, "ref")
filePath := chi.URLParam(r, "*")
-
protocol := "http"
+
scheme := "http"
if !rp.config.Core.Dev {
-
protocol = "https"
+
scheme = "https"
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
+
scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath))
+
+
req, err := http.NewRequest("GET", blobURL, nil)
+
if err != nil {
+
log.Println("failed to create request", err)
+
return
}
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
+
+
// 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()
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
+
// forward 304 not modified
+
if resp.StatusCode == http.StatusNotModified {
+
w.WriteHeader(http.StatusNotModified)
return
}
-
var result types.RepoBlobResponse
-
err = json.Unmarshal(body, &result)
+
if resp.StatusCode != http.StatusOK {
+
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
+
w.WriteHeader(resp.StatusCode)
+
_, _ = io.Copy(w, resp.Body)
+
return
+
}
+
+
contentType := resp.Header.Get("Content-Type")
+
body, err := io.ReadAll(resp.Body)
if err != nil {
-
log.Println("failed to parse response:", err)
+
log.Printf("error reading response body from knotserver: %v", err)
+
w.WriteHeader(http.StatusInternalServerError)
return
}
-
if result.IsBinary {
-
w.Header().Set("Content-Type", "application/octet-stream")
+
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 {
+
w.WriteHeader(http.StatusUnsupportedMediaType)
+
w.Write([]byte("unsupported content type"))
return
}
+
}
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
-
w.Write([]byte(result.Contents))
+
// 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)
+
l := rp.logger.With("handler", "EditSpindle")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
+
errorId := "operation-error"
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
rp.pages.Notice(w, errorId, msg)
+
}
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
w.WriteHeader(http.StatusBadRequest)
+
fail("Failed to resolve repo. Try again later", err)
return
}
-
repoAt := f.RepoAt
+
repoAt := f.RepoAt()
rkey := repoAt.RecordKey().String()
if rkey == "" {
-
log.Println("invalid aturi for repo", err)
-
w.WriteHeader(http.StatusInternalServerError)
+
fail("Failed to resolve repo. Try again later", err)
return
}
-
-
user := rp.oauth.GetUser(r)
newSpindle := r.FormValue("spindle")
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
log.Println("failed to get client")
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
+
fail("Failed to authorize. Try again later.", err)
return
}
-
// ensure that this is a valid spindle for this user
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
-
if err != nil {
-
log.Println("failed to get valid spindles")
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
-
return
+
if !removingSpindle {
+
// ensure that this is a valid spindle for this user
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
+
if err != nil {
+
fail("Failed to find spindles. Try again later.", err)
+
return
+
}
+
+
if !slices.Contains(validSpindles, newSpindle) {
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
+
return
+
}
}
-
if !slices.Contains(validSpindles, newSpindle) {
-
log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
-
return
+
spindlePtr := &newSpindle
+
if removingSpindle {
+
spindlePtr = nil
}
// optimistic update
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
if err != nil {
-
log.Println("failed to perform update-spindle query", err)
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
+
fail("Failed to update spindle. Try again later.", err)
return
}
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
if err != nil {
-
// failed to get record
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
+
fail("Failed to update spindle, no record found on PDS.", err)
return
}
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
···
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: &newSpindle,
+
Spindle: spindlePtr,
},
},
})
if err != nil {
-
log.Println("failed to perform update-spindle query", err)
-
// failed to get record
-
rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.")
+
fail("Failed to update spindle, unable to save to PDS.", err)
return
}
-
// add this spindle to spindle stream
-
rp.spindlestream.AddSource(
-
context.Background(),
-
eventconsumer.NewSpindleSource(newSpindle),
-
)
+
if !removingSpindle {
+
// add this spindle to spindle stream
+
rp.spindlestream.AddSource(
+
context.Background(),
+
eventconsumer.NewSpindleSource(newSpindle),
+
)
+
}
-
w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
+
rp.pages.HxRefresh(w)
}
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
l := rp.logger.With("handler", "AddCollaborator")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
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
}
+
errorId := "add-collaborator-error"
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
rp.pages.Notice(w, errorId, msg)
+
}
+
collaborator := r.FormValue("collaborator")
if collaborator == "" {
-
http.Error(w, "malformed form", http.StatusBadRequest)
+
fail("Invalid form.", nil)
return
}
+
// remove a single leading `@`, to make @handle work with ResolveIdent
+
collaborator = strings.TrimPrefix(collaborator, "@")
+
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
if err != nil {
-
w.Write([]byte("failed to resolve collaborator did to a handle"))
+
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
return
}
-
log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
-
// TODO: create an atproto record for this
-
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
-
if err != nil {
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
if collaboratorIdent.DID.String() == user.Did {
+
fail("You seem to be adding yourself as a collaborator.", nil)
return
}
+
l = l.With("collaborator", collaboratorIdent.Handle)
+
l = l.With("knot", f.Knot)
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
+
// announce this relation into the firehose, store into owners' pds
+
client, err := rp.oauth.AuthorizedClient(r)
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
+
fail("Failed to write to PDS.", err)
return
}
-
ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
+
// emit a record
+
currentUser := rp.oauth.GetUser(r)
+
rkey := tid.TID()
+
createdAt := time.Now()
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoCollaboratorNSID,
+
Repo: currentUser.Did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoCollaborator{
+
Subject: collaboratorIdent.DID.String(),
+
Repo: string(f.RepoAt()),
+
CreatedAt: createdAt.Format(time.RFC3339),
+
}},
+
})
+
// invalid record
if err != nil {
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
+
fail("Failed to write record to PDS.", err)
return
}
-
if ksResp.StatusCode != http.StatusNoContent {
-
w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
-
return
-
}
+
aturi := resp.Uri
+
l = l.With("at-uri", aturi)
+
l.Info("wrote record to PDS")
tx, err := rp.db.BeginTx(r.Context(), nil)
if err != nil {
-
log.Println("failed to start tx")
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
+
fail("Failed to add collaborator.", err)
return
}
-
defer func() {
-
tx.Rollback()
-
err = rp.enforcer.E.LoadPolicy()
-
if err != nil {
-
log.Println("failed to rollback policies")
+
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rp.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, client)
+
+
// 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()
err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
if err != nil {
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
+
fail("Failed to add collaborator permissions.", err)
return
}
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
+
err = db.AddCollaborator(rp.db, db.Collaborator{
+
Did: syntax.DID(currentUser.Did),
+
Rkey: rkey,
+
SubjectDid: collaboratorIdent.DID,
+
RepoAt: f.RepoAt(),
+
Created: createdAt,
+
})
if err != nil {
-
w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
+
fail("Failed to add collaborator.", err)
return
}
err = tx.Commit()
if err != nil {
-
log.Println("failed to commit changes", err)
-
http.Error(w, err.Error(), http.StatusInternalServerError)
+
fail("Failed to add collaborator.", err)
return
}
err = rp.enforcer.E.SavePolicy()
if err != nil {
-
log.Println("failed to update ACLs", err)
-
http.Error(w, err.Error(), http.StatusInternalServerError)
+
fail("Failed to update collaborator permissions.", err)
return
}
-
w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
+
// 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.")
+
rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.")
return
}
-
log.Println("removed repo record ", f.RepoAt.String())
+
log.Println("removed repo record ", f.RepoAt().String())
-
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Knot),
+
oauth.WithLxm(tangled.RepoDeleteNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
if err != nil {
-
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
+
log.Println("failed to connect to knot server:", err)
return
}
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
-
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)
+
log.Println("failed to connect to knot server:", err)
+
rp.pages.Notice(w, noticeId, "Failed to connect to knot server.")
return
}
-
ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
-
if err != nil {
-
log.Println("failed to create client to ", f.Knot)
+
xe := tangled.RepoSetDefaultBranch(
+
r.Context(),
+
client,
+
&tangled.RepoSetDefaultBranch_Input{
+
Repo: f.RepoAt().String(),
+
DefaultBranch: branch,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
+
log.Println("xrpc failed", "err", xe)
+
rp.pages.Notice(w, noticeId, err.Error())
return
}
-
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
+
rp.pages.HxRefresh(w)
+
}
+
+
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
+
user := rp.oauth.GetUser(r)
+
l := rp.logger.With("handler", "Secrets")
+
l = l.With("handle", user.Handle)
+
l = l.With("did", user.Did)
+
+
f, err := rp.repoResolver.Resolve(r)
if err != nil {
-
log.Printf("failed to make request to %s: %s", f.Knot, err)
+
log.Println("failed to get repo and knot", err)
return
}
-
if ksResp.StatusCode != http.StatusNoContent {
-
rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
+
if f.Spindle == "" {
+
log.Println("empty spindle cannot add/rm secret", err)
return
}
-
w.Write(fmt.Append(nil, "default branch set to: ", branch))
-
}
+
lxm := tangled.RepoAddSecretNSID
+
if r.Method == http.MethodDelete {
+
lxm = tangled.RepoRemoveSecretNSID
+
}
-
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
-
f, err := rp.repoResolver.Resolve(r)
+
spindleClient, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(f.Spindle),
+
oauth.WithLxm(lxm),
+
oauth.WithExp(60),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
if err != nil {
-
log.Println("failed to get repo and knot", err)
+
log.Println("failed to create spindle client", err)
+
return
+
}
+
+
key := r.FormValue("key")
+
if key == "" {
+
w.WriteHeader(http.StatusBadRequest)
return
}
switch r.Method {
-
case http.MethodGet:
-
// for now, this is just pubkeys
-
user := rp.oauth.GetUser(r)
-
repoCollaborators, err := f.Collaborators(r.Context())
-
if err != nil {
-
log.Println("failed to get collaborators", err)
-
}
+
case http.MethodPut:
+
errorId := "add-secret-error"
-
isCollaboratorInviteAllowed := false
-
if user != nil {
-
ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
-
if err == nil && ok {
-
isCollaboratorInviteAllowed = true
-
}
+
value := r.FormValue("value")
+
if value == "" {
+
w.WriteHeader(http.StatusBadRequest)
+
return
}
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
err = tangled.RepoAddSecret(
+
r.Context(),
+
spindleClient,
+
&tangled.RepoAddSecret_Input{
+
Repo: f.RepoAt().String(),
+
Key: key,
+
Value: value,
+
},
+
)
if err != nil {
-
log.Println("failed to create unsigned client", err)
+
l.Error("Failed to add secret.", "err", err)
+
rp.pages.Notice(w, errorId, "Failed to add secret.")
return
}
-
result, err := us.Branches(f.OwnerDid(), f.RepoName)
+
case http.MethodDelete:
+
errorId := "operation-error"
+
+
err = tangled.RepoRemoveSecret(
+
r.Context(),
+
spindleClient,
+
&tangled.RepoRemoveSecret_Input{
+
Repo: f.RepoAt().String(),
+
Key: key,
+
},
+
)
if err != nil {
-
log.Println("failed to reach knotserver", err)
+
l.Error("Failed to delete secret.", "err", err)
+
rp.pages.Notice(w, errorId, "Failed to delete secret.")
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)
+
rp.pages.HxRefresh(w)
+
}
+
+
type tab = map[string]any
+
+
var (
+
// would be great to have ordered maps right about now
+
settingsTabs []tab = []tab{
+
{"Name": "general", "Icon": "sliders-horizontal"},
+
{"Name": "access", "Icon": "users"},
+
{"Name": "pipelines", "Icon": "layers-2"},
+
}
+
)
+
+
func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
+
tabVal := r.URL.Query().Get("tab")
+
if tabVal == "" {
+
tabVal = "general"
+
}
+
+
switch tabVal {
+
case "general":
+
rp.generalSettings(w, r)
+
+
case "access":
+
rp.accessSettings(w, r)
+
+
case "pipelines":
+
rp.pipelineSettings(w, r)
+
}
+
}
+
+
func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
+
f, err := rp.repoResolver.Resolve(r)
+
user := rp.oauth.GetUser(r)
+
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
+
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
return
}
+
rp.pages.Error503(w)
+
return
+
}
-
rp.pages.RepoSettings(w, pages.RepoSettingsParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(user),
-
Collaborators: repoCollaborators,
-
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
-
Branches: result.Branches,
-
Spindles: spindles,
-
CurrentSpindle: f.Spindle,
+
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.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Branches: result.Branches,
+
Tabs: settingsTabs,
+
Tab: "general",
+
})
+
}
+
+
func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) {
+
f, err := rp.repoResolver.Resolve(r)
+
user := rp.oauth.GetUser(r)
+
+
repoCollaborators, err := f.Collaborators(r.Context())
+
if err != nil {
+
log.Println("failed to get collaborators", err)
+
}
+
+
rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Tabs: settingsTabs,
+
Tab: "access",
+
Collaborators: repoCollaborators,
+
})
+
}
+
+
func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) {
+
f, err := rp.repoResolver.Resolve(r)
+
user := rp.oauth.GetUser(r)
+
+
// all spindles that the repo owner is a member of
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
+
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.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 {
+
log.Println("failed to fetch secrets", err)
+
} else {
+
secrets = resp.Secrets
+
}
+
}
+
+
slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int {
+
return strings.Compare(a.Key, b.Key)
+
})
+
+
var dids []string
+
for _, s := range secrets {
+
dids = append(dids, s.CreatedBy)
+
}
+
resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids)
+
+
// convert to a more manageable form
+
var niceSecret []map[string]any
+
for id, s := range secrets {
+
when, _ := time.Parse(time.RFC3339, s.CreatedAt)
+
niceSecret = append(niceSecret, map[string]any{
+
"Id": id,
+
"Key": s.Key,
+
"CreatedAt": when,
+
"CreatedBy": resolvedIdents[id].Handle.String(),
})
}
+
+
rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(user),
+
Tabs: settingsTabs,
+
Tab: "pipelines",
+
Spindles: spindles,
+
CurrentSpindle: f.Spindle,
+
Secrets: niceSecret,
+
})
}
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
+
ref := chi.URLParam(r, "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)
-
repo.AtUri = atresp.Uri
+
aturi := atresp.Uri
+
l = l.With("aturi", aturi)
+
l.Info("wrote to PDS")
+
+
tx, err := rp.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
l.Info("txn failed", "err", err)
+
rp.pages.Notice(w, "repo", "Failed to save repository information.")
+
return
+
}
+
+
// The rollback function reverts a few things on failure:
+
// - the pending txn
+
// - the ACLs
+
// - the atproto record created
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := rp.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
+
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
if errs := errors.Join(err1, err2, err3); errs != nil {
+
l.Error("failed to rollback changes", "errs", errs)
+
return
+
}
+
}
+
defer rollback()
+
+
client, err := rp.oauth.ServiceClient(
+
r,
+
oauth.WithService(targetKnot),
+
oauth.WithLxm(tangled.RepoCreateNSID),
+
oauth.WithDev(rp.config.Core.Dev),
+
)
+
if err != nil {
+
l.Error("could not create service client", "err", err)
+
rp.pages.Notice(w, "repo", "Failed to connect to knot server.")
+
return
+
}
+
+
err = tangled.RepoCreate(
+
r.Context(),
+
client,
+
&tangled.RepoCreate_Input{
+
Rkey: rkey,
+
Source: &forkSourceUrl,
+
},
+
)
+
if err := xrpcclient.HandleXrpcErr(err); err != nil {
+
rp.pages.Notice(w, "repo", err.Error())
+
return
+
}
+
err = db.AddRepo(tx, repo)
if err != nil {
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) {
···
return
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
rp.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
-
result, err := us.Branches(f.OwnerDid(), f.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)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to reach knotserver", err)
+
return
+
}
+
+
var tags types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
+
log.Println("failed to decode XRPC tags response", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
···
return
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
+
scheme := "http"
+
if !rp.config.Core.Dev {
+
scheme = "https"
+
}
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
+
xrpcc := &indigoxrpc.Client{
+
Host: host,
+
}
+
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
+
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
rp.pages.Error503(w)
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
return
-
branches, err := us.Branches(f.OwnerDid(), f.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
-
tags, err := us.Tags(f.OwnerDid(), f.RepoName)
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to reach knotserver", err)
return
-
formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
+
var tags types.RepoTagsResponse
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
+
log.Println("failed to decode XRPC tags response", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
if err != nil {
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
+
rp.pages.Error503(w)
+
return
+
}
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
-
log.Println("failed to compare", err)
return
+
+
var formatPatch types.RepoFormatPatchResponse
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
+
log.Println("failed to decode XRPC compare response", err)
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
+
return
+
}
+
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
repoinfo := f.RepoInfo(user)
+7
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)
···
})
r.Get("/blob/{ref}/*", rp.RepoBlob)
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
+
+
// intentionally doesn't use /* as this isn't
+
// a file path
+
r.Get("/archive/{ref}", rp.DownloadArchive)
r.Route("/fork", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(rp.oauth))
···
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
r.Put("/branches/default", rp.SetDefaultBranch)
+
r.Put("/secrets", rp.Secrets)
+
r.Delete("/secrets", rp.Secrets)
})
})
+41 -107
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
-
}
-
func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) {
repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
if err != nil {
···
for _, item := range repoCollaborators {
// currently only two roles: owner and member
var role string
-
if item[3] == "repo:owner" {
+
switch item[3] {
+
case "repo:owner":
role = "owner"
-
} else if item[3] == "repo:collaborator" {
+
case "repo:collaborator":
role = "collaborator"
-
} else {
+
default:
continue
}
···
// 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
}
}
+104
appview/signup/requests.go
···
+
package signup
+
+
// We have this extra code here for now since the xrpcclient package
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
+
+
import (
+
"bytes"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net/http"
+
"net/url"
+
)
+
+
// makePdsRequest is a helper method to make requests to the PDS service
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
+
jsonData, err := json.Marshal(body)
+
if err != nil {
+
return nil, err
+
}
+
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
+
if err != nil {
+
return nil, err
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
+
if useAuth {
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
+
}
+
+
return http.DefaultClient.Do(req)
+
}
+
+
// handlePdsError processes error responses from the PDS service
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
+
var errorResp struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
respBody, _ := io.ReadAll(resp.Body)
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
+
}
+
+
// Fallback if we couldn't parse the error
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
+
}
+
+
func (s *Signup) inviteCodeRequest() (string, error) {
+
body := map[string]any{"useCount": 1}
+
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
+
if err != nil {
+
return "", err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", s.handlePdsError(resp, "create invite code")
+
}
+
+
var result map[string]string
+
json.NewDecoder(resp.Body).Decode(&result)
+
return result["code"], nil
+
}
+
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
+
parsedURL, err := url.Parse(s.config.Pds.Host)
+
if err != nil {
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
+
}
+
+
pdsDomain := parsedURL.Hostname()
+
+
body := map[string]string{
+
"email": email,
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
+
"password": password,
+
"inviteCode": code,
+
}
+
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
+
if err != nil {
+
return "", err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", s.handlePdsError(resp, "create account")
+
}
+
+
var result struct {
+
DID string `json:"did"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
+
}
+
+
return result.DID, nil
+
}
+256
appview/signup/signup.go
···
+
package signup
+
+
import (
+
"bufio"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"os"
+
"strings"
+
+
"github.com/go-chi/chi/v5"
+
"github.com/posthog/posthog-go"
+
"tangled.sh/tangled.sh/core/appview/config"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/dns"
+
"tangled.sh/tangled.sh/core/appview/email"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
+
"tangled.sh/tangled.sh/core/idresolver"
+
)
+
+
type Signup struct {
+
config *config.Config
+
db *db.DB
+
cf *dns.Cloudflare
+
posthog posthog.Client
+
xrpc *xrpcclient.Client
+
idResolver *idresolver.Resolver
+
pages *pages.Pages
+
l *slog.Logger
+
disallowedNicknames map[string]bool
+
}
+
+
func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
+
var cf *dns.Cloudflare
+
if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
+
var err error
+
cf, err = dns.NewCloudflare(cfg)
+
if err != nil {
+
l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
+
}
+
}
+
+
disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
+
+
return &Signup{
+
config: cfg,
+
db: database,
+
posthog: pc,
+
idResolver: idResolver,
+
cf: cf,
+
pages: pages,
+
l: l,
+
disallowedNicknames: disallowedNicknames,
+
}
+
}
+
+
func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
+
disallowed := make(map[string]bool)
+
+
if filepath == "" {
+
logger.Debug("no disallowed nicknames file configured")
+
return disallowed
+
}
+
+
file, err := os.Open(filepath)
+
if err != nil {
+
logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
+
return disallowed
+
}
+
defer file.Close()
+
+
scanner := bufio.NewScanner(file)
+
lineNum := 0
+
for scanner.Scan() {
+
lineNum++
+
line := strings.TrimSpace(scanner.Text())
+
if line == "" || strings.HasPrefix(line, "#") {
+
continue // skip empty lines and comments
+
}
+
+
nickname := strings.ToLower(line)
+
if userutil.IsValidSubdomain(nickname) {
+
disallowed[nickname] = true
+
} else {
+
logger.Warn("invalid nickname format in disallowed nicknames file",
+
"file", filepath, "line", lineNum, "nickname", nickname)
+
}
+
}
+
+
if err := scanner.Err(); err != nil {
+
logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
+
}
+
+
logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
+
return disallowed
+
}
+
+
// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
+
func (s *Signup) isNicknameAllowed(nickname string) bool {
+
return !s.disallowedNicknames[strings.ToLower(nickname)]
+
}
+
+
func (s *Signup) Router() http.Handler {
+
r := chi.NewRouter()
+
r.Get("/", s.signup)
+
r.Post("/", s.signup)
+
r.Get("/complete", s.complete)
+
r.Post("/complete", s.complete)
+
+
return r
+
}
+
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.Signup(w)
+
case http.MethodPost:
+
if s.cf == nil {
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
+
}
+
emailId := r.FormValue("email")
+
+
noticeId := "signup-msg"
+
if !email.IsValidEmail(emailId) {
+
s.pages.Notice(w, noticeId, "Invalid email address.")
+
return
+
}
+
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
+
if err != nil {
+
s.l.Error("failed to check email existence", "error", err)
+
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
+
return
+
}
+
if exists {
+
s.pages.Notice(w, noticeId, "Email already exists.")
+
return
+
}
+
+
code, err := s.inviteCodeRequest()
+
if err != nil {
+
s.l.Error("failed to create invite code", "error", err)
+
s.pages.Notice(w, noticeId, "Failed to create invite code.")
+
return
+
}
+
+
em := email.Email{
+
APIKey: s.config.Resend.ApiKey,
+
From: s.config.Resend.SentFrom,
+
To: emailId,
+
Subject: "Verify your Tangled account",
+
Text: `Copy and paste this code below to verify your account on Tangled.
+
` + code,
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
+
<p><code>` + code + `</code></p>`,
+
}
+
+
err = email.SendEmail(em)
+
if err != nil {
+
s.l.Error("failed to send email", "error", err)
+
s.pages.Notice(w, noticeId, "Failed to send email.")
+
return
+
}
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
+
Email: emailId,
+
InviteCode: code,
+
})
+
if err != nil {
+
s.l.Error("failed to add inflight signup", "error", err)
+
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
s.pages.HxRedirect(w, "/signup/complete")
+
}
+
}
+
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.CompleteSignup(w)
+
case http.MethodPost:
+
username := r.FormValue("username")
+
password := r.FormValue("password")
+
code := r.FormValue("code")
+
+
if !userutil.IsValidSubdomain(username) {
+
s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ€“63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")
+
return
+
}
+
+
if !s.isNicknameAllowed(username) {
+
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
+
return
+
}
+
+
email, err := db.GetEmailForCode(s.db, code)
+
if err != nil {
+
s.l.Error("failed to get email for code", "error", err)
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
did, err := s.createAccountRequest(username, password, email, code)
+
if err != nil {
+
s.l.Error("failed to create account", "error", err)
+
s.pages.Notice(w, "signup-error", err.Error())
+
return
+
}
+
+
if s.cf == nil {
+
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
+
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
+
return
+
}
+
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
+
Type: "TXT",
+
Name: "_atproto." + username,
+
Content: fmt.Sprintf(`"did=%s"`, did),
+
TTL: 6400,
+
Proxied: false,
+
})
+
if err != nil {
+
s.l.Error("failed to create DNS record", "error", err)
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
+
return
+
}
+
+
err = db.AddEmail(s.db, db.Email{
+
Did: did,
+
Address: email,
+
Verified: true,
+
Primary: true,
+
})
+
if err != nil {
+
s.l.Error("failed to add email", "error", err)
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
+
<a class="underline text-black dark:text-white" href="/login">login</a>
+
with <code>%s.tngl.sh</code>.`, username))
+
+
go func() {
+
err := db.DeleteInflightSignup(s.db, email)
+
if err != nil {
+
s.l.Error("failed to delete inflight signup", "error", err)
+
}
+
}()
+
return
+
}
+
}
+26 -26
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)
···
s.Enforcer.E.LoadPolicy()
}()
+
// remove spindle members first
+
err = db.RemoveSpindleMember(
+
tx,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("instance", instance),
+
)
+
if err != nil {
+
l.Error("failed to remove spindle members", "err", err)
+
fail()
+
return
+
}
+
err = db.DeleteSpindle(
tx,
db.FilterEq("owner", user.Did),
···
}
// 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) {
···
if string(spindles[0].Owner) != user.Did {
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
return
}
member := r.FormValue("member")
if member == "" {
l.Error("empty member")
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
return
}
l = l.With("member", member)
···
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
l.Error("failed to resolve member identity to handle", "err", err)
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
+
s.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")
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
return
}
-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{}{}
}
+418 -126
appview/state/profile.go
···
package state
import (
-
"crypto/hmac"
-
"crypto/sha256"
-
"encoding/hex"
+
"context"
"fmt"
"log"
"net/http"
···
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
+
"github.com/gorilla/feeds"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
···
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
tabVal := r.URL.Query().Get("tab")
switch tabVal {
-
case "":
-
s.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)
}
-
profile, err := db.GetProfile(s.db, ident.DID.String())
+
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
if err != nil {
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
return nil, fmt.Errorf("failed to get repo count: %w", err)
+
}
+
+
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get string count: %w", err)
+
}
+
+
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
+
if err != nil {
+
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
+
}
+
+
followStats, err := db.GetFollowerFollowingCount(s.db, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get follower stats: %w", err)
+
}
+
+
loggedInUser := s.oauth.GetUser(r)
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
+
}
+
+
now := time.Now()
+
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
+
punchcard, err := db.MakePunchcard(
+
s.db,
+
db.FilterEq("did", did),
+
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
+
db.FilterLte("date", now.Format(time.DateOnly)),
+
)
+
if err != nil {
+
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
}
+
return &pages.ProfileCard{
+
UserDid: did,
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
Stats: pages.ProfileStats{
+
RepoCount: repoCount,
+
StringCount: stringCount,
+
StarredCount: starredCount,
+
FollowersCount: followStats.Followers,
+
FollowingCount: followStats.Following,
+
},
+
Punchcard: punchcard,
+
}, nil
+
}
+
+
func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "profileHomePage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
+
repos, err := db.GetRepos(
s.db,
0,
-
db.FilterEq("did", ident.DID.String()),
+
db.FilterEq("did", profile.UserDid),
)
if err != nil {
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
+
l.Error("failed to fetch repos", "err", err)
}
// filter out ones that are pinned
pinnedRepos := []db.Repo{}
for i, r := range repos {
// if this is a pinned repo, add it
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
pinnedRepos = append(pinnedRepos, r)
}
// if there are no saved pins, add the first 4 repos
-
if profile.IsPinnedReposEmpty() && i < 4 {
+
if profile.Profile.IsPinnedReposEmpty() && i < 4 {
pinnedRepos = append(pinnedRepos, r)
}
}
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
if err != nil {
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
+
l.Error("failed to fetch collaborating repos", "err", err)
}
pinnedCollaboratingRepos := []db.Repo{}
for _, r := range collaboratingRepos {
// if this is a pinned repo, add it
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
}
}
-
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
+
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
if err != nil {
-
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
+
l.Error("failed to create timeline", "err", err)
}
-
var didsToResolve []string
-
for _, r := range collaboratingRepos {
-
didsToResolve = append(didsToResolve, r.Did)
+
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Card: profile,
+
Repos: pinnedRepos,
+
CollaboratingRepos: pinnedCollaboratingRepos,
+
ProfileTimeline: timeline,
+
})
+
}
+
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
+
l := s.logger.With("handler", "reposPage")
+
+
profile, err := s.profile(r)
+
if err != nil {
+
l.Error("failed to build profile card", "err", err)
+
s.pages.Error500(w)
+
return
}
-
for _, byMonth := range timeline.ByMonth {
-
for _, pe := range byMonth.PullEvents.Items {
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
-
}
-
for _, ie := range byMonth.IssueEvents.Items {
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
+
+
repos, err := db.GetRepos(
+
s.db,
+
0,
+
db.FilterEq("did", profile.UserDid),
+
)
+
if err != nil {
+
l.Error("failed to get repos", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
+
err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Repos: repos,
+
Card: profile,
+
})
+
}
+
+
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)
+
+
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
+
if err != nil {
+
l.Error("failed to get stars", "err", err)
+
s.pages.Error500(w)
+
return
+
}
+
var repoAts []string
+
for _, s := range stars {
+
repoAts = append(repoAts, string(s.RepoAt))
+
}
+
+
repos, err := db.GetRepos(
+
s.db,
+
0,
+
db.FilterIn("at_uri", repoAts),
+
)
+
if err != nil {
+
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)
+
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 {
+
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
}
-
for _, re := range byMonth.RepoEvents {
-
didsToResolve = append(didsToResolve, re.Repo.Did)
-
if re.Source != nil {
-
didsToResolve = append(didsToResolve, re.Source.Did)
-
}
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
+
for _, follow := range following {
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
}
}
-
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())
+
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 {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
+
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.GetFollowerFollowing(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
}
-
loggedInUser := s.oauth.GetUser(r)
-
followStatus := db.IsNotFollowing
-
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
-
}
+
s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Followers: followPage.Follows,
+
Card: followPage.Card,
+
})
+
}
-
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", ident.DID.String()),
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
-
db.FilterLte("date", now.Format(time.DateOnly)),
-
)
+
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 {
-
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
+
s.pages.Notice(w, "all-following", "Failed to load following")
+
return
}
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
-
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(),
-
AvatarUri: profileAvatarUri,
-
Profile: profile,
-
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
-
},
-
Punchcard: punchcard,
-
ProfileTimeline: timeline,
+
s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
+
LoggedInUser: s.oauth.GetUser(r),
+
Following: followPage.Follows,
+
Card: followPage.Card,
})
}
-
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
+
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
}
-
profile, err := db.GetProfile(s.db, ident.DID.String())
+
feed, err := s.getProfileFeed(r.Context(), &ident)
if err != nil {
-
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
s.pages.Error500(w)
+
return
}
-
repos, err := db.GetRepos(
-
s.db,
-
0,
-
db.FilterEq("did", ident.DID.String()),
-
)
-
if err != nil {
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
+
if feed == nil {
+
return
}
-
loggedInUser := s.oauth.GetUser(r)
-
followStatus := db.IsNotFollowing
-
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
+
atom, err := feed.ToAtom()
+
if err != nil {
+
s.pages.Error500(w)
+
return
}
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
+
w.Header().Set("content-type", "application/atom+xml")
+
w.Write([]byte(atom))
+
}
+
+
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 {
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
return nil, err
}
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
+
author := &feeds.Author{
+
Name: fmt.Sprintf("@%s", id.Handle),
+
}
-
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(),
-
AvatarUri: profileAvatarUri,
-
Profile: profile,
-
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
-
},
+
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 {
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
+
return nil, err
+
}
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
+
return nil, err
+
}
+
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
}
-
func (s *State) GetAvatarUri(handle string) string {
-
secret := s.config.Avatar.SharedSecret
-
h := hmac.New(sha256.New, []byte(secret))
-
h.Write([]byte(handle))
-
signature := hex.EncodeToString(h.Sum(nil))
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
+
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,
})
}
+54 -12
appview/state/router.go
···
"tangled.sh/tangled.sh/core/appview/pulls"
"tangled.sh/tangled.sh/core/appview/repo"
"tangled.sh/tangled.sh/core/appview/settings"
+
"tangled.sh/tangled.sh/core/appview/signup"
"tangled.sh/tangled.sh/core/appview/spindles"
"tangled.sh/tangled.sh/core/appview/state/userutil"
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
"tangled.sh/tangled.sh/core/log"
)
···
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)
}
})
···
func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
-
// strip @ from user
-
r.Use(middleware.StripLeadingAt)
-
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
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("/knots", s.KnotsRouter(mw))
+
r.Mount("/strings", s.StringsRouter(mw))
+
r.Mount("/knots", s.KnotsRouter())
r.Mount("/spindles", s.SpindlesRouter())
+
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
r.Get("/keys/{user}", s.Keys)
+
r.Get("/terms", s.TermsOfService)
+
r.Get("/privacy", s.PrivacyPolicy)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
s.pages.Error404(w)
···
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 {
+
logger := log.New("strings")
+
+
strs := &avstrings.Strings{
+
Db: s.db,
+
OAuth: s.oauth,
+
Pages: s.pages,
+
Config: s.config,
+
Enforcer: s.enforcer,
+
IdResolver: s.idResolver,
+
Knotstream: s.knotstream,
+
Logger: logger,
+
}
+
+
return strs.Router(mw)
}
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)
}
···
}
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer)
+
logger := log.New("repo")
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
return repo.Router(mw)
}
···
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
return pipes.Router(mw)
}
+
+
func (s *State) SignupRouter() http.Handler {
+
logger := log.New("signup")
+
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
+
return sig.Router()
+
}
+2 -2
appview/state/star.go
···
s.notifier.NewStar(r.Context(), star)
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
IsStarred: true,
RepoAt: subjectUri,
Stats: db.RepoStats{
···
s.notifier.DeleteStar(r.Context(), star)
-
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
+
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
IsStarred: false,
RepoAt: subjectUri,
Stats: db.RepoStats{
+214 -72
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/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
posthog_service "tangled.sh/tangled.sh/core/appview/posthog"
+
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.ActorProfileNSID,
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 {
···
var notifiers []notify.Notifier
if !config.Core.Dev {
-
notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog))
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
}
notifier := notify.NewMergedNotifier(notifiers...)
···
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{
+
LoggedInUser: user,
+
})
+
}
+
+
func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
+
LoggedInUser: user,
+
})
+
}
+
+
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,
})
+
}
-
return
+
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
+
user := s.oauth.GetUser(r)
+
l := s.logger.With("handler", "UpgradeBanner")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
+
regs, err := db.GetRegistrations(
+
s.db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("needs_upgrade", 1),
+
)
+
if err != nil {
+
l.Error("non-fatal: failed to get registrations", "err", err)
+
}
+
+
spindles, err := db.GetSpindles(
+
s.db,
+
db.FilterEq("owner", user.Did),
+
db.FilterEq("needs_upgrade", 1),
+
)
+
if err != nil {
+
l.Error("non-fatal: failed to get spindles", "err", err)
+
}
+
+
if regs == nil && spindles == nil {
+
return
+
}
+
+
s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{
+
Registrations: regs,
+
Spindles: spindles,
+
})
+
}
+
+
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
+
timeline, err := db.MakeTimeline(s.db, 5)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
+
return
+
}
+
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
+
return
+
}
+
+
s.pages.Home(w, pages.TimelineParams{
+
LoggedInUser: nil,
+
Timeline: timeline,
+
Repos: repos,
+
})
}
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
···
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))
-
return
-
}
-
-
secret, err := db.GetRegistrationKey(s.db, domain)
-
if err != nil {
-
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain))
-
return
-
}
-
-
client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev)
-
if err != nil {
-
s.pages.Notice(w, "repo", "Failed to connect to knot server.")
+
l.Info("repo exists")
+
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
return
}
+
// create atproto record for this repo
rkey := tid.TID()
repo := &db.Repo{
Did: user.Did,
···
xrpcClient, err := s.oauth.AuthorizedClient(r)
if err != nil {
+
l.Info("PDS write failed", "err", err)
s.pages.Notice(w, "repo", "Failed to write record to PDS.")
return
}
···
}},
})
if err != nil {
-
log.Printf("failed to create record: %s", err)
+
l.Info("PDS write failed", "err", err)
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
return
}
-
log.Println("created repo record: ", atresp.Uri)
+
+
aturi := atresp.Uri
+
l = l.With("aturi", aturi)
+
l.Info("wrote to PDS")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
-
log.Println(err)
+
l.Info("txn failed", "err", err)
s.pages.Notice(w, "repo", "Failed to save repository information.")
return
}
-
defer func() {
-
tx.Rollback()
-
err = s.enforcer.E.LoadPolicy()
-
if err != nil {
-
log.Println("failed to rollback policies")
+
+
// The rollback function reverts a few things on failure:
+
// - the pending txn
+
// - the ACLs
+
// - the atproto record created
+
rollback := func() {
+
err1 := tx.Rollback()
+
err2 := s.enforcer.E.LoadPolicy()
+
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
+
+
// ignore txn complete errors, this is okay
+
if errors.Is(err1, sql.ErrTxDone) {
+
err1 = nil
+
}
+
+
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
}
+6
appview/state/userutil/userutil.go
···
func IsDid(s string) bool {
return didRegex.MatchString(s)
}
+
+
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+
+
func IsValidSubdomain(name string) bool {
+
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
+
}
+407
appview/strings/strings.go
···
+
package strings
+
+
import (
+
"fmt"
+
"log/slog"
+
"net/http"
+
"path"
+
"strconv"
+
"time"
+
+
"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/middleware"
+
"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/eventconsumer"
+
"tangled.sh/tangled.sh/core/idresolver"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/tid"
+
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/go-chi/chi/v5"
+
)
+
+
type Strings struct {
+
Db *db.DB
+
OAuth *oauth.OAuth
+
Pages *pages.Pages
+
Config *config.Config
+
Enforcer *rbac.Enforcer
+
IdResolver *idresolver.Resolver
+
Logger *slog.Logger
+
Knotstream *eventconsumer.Consumer
+
}
+
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
+
r := chi.NewRouter()
+
+
r.
+
Get("/", s.timeline)
+
+
r.
+
With(mw.ResolveIdent()).
+
Route("/{user}", func(r chi.Router) {
+
r.Get("/", s.dashboard)
+
+
r.Route("/{rkey}", func(r chi.Router) {
+
r.Get("/", s.contents)
+
r.Delete("/", s.delete)
+
r.Get("/raw", s.contents)
+
r.Get("/edit", s.edit)
+
r.Post("/edit", s.edit)
+
r.
+
With(middleware.AuthMiddleware(s.OAuth)).
+
Post("/comment", s.comment)
+
})
+
})
+
+
r.
+
With(middleware.AuthMiddleware(s.OAuth)).
+
Route("/new", func(r chi.Router) {
+
r.Get("/", s.create)
+
r.Post("/", s.create)
+
})
+
+
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")
+
+
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)
+
+
rkey := chi.URLParam(r, "rkey")
+
if rkey == "" {
+
l.Error("malformed url, empty rkey")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
l = l.With("rkey", rkey)
+
+
strings, err := db.GetStrings(
+
s.Db,
+
0,
+
db.FilterEq("did", id.DID),
+
db.FilterEq("rkey", rkey),
+
)
+
if err != nil {
+
l.Error("failed to fetch string", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
if len(strings) < 1 {
+
l.Error("string not found")
+
s.Pages.Error404(w)
+
return
+
}
+
if len(strings) != 1 {
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
string := strings[0]
+
+
if path.Base(r.URL.Path) == "raw" {
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
if string.Filename != "" {
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
+
}
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
+
+
_, err = w.Write([]byte(string.Contents))
+
if err != nil {
+
l.Error("failed to write raw response", "err", err)
+
}
+
return
+
}
+
+
var showRendered, renderToggle bool
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
+
renderToggle = true
+
showRendered = r.URL.Query().Get("code") != "true"
+
}
+
+
s.Pages.SingleString(w, pages.SingleStringParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
RenderToggle: renderToggle,
+
ShowRendered: showRendered,
+
String: string,
+
Stats: string.Stats(),
+
Owner: id,
+
})
+
}
+
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
+
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) {
+
l := s.Logger.With("handler", "edit")
+
+
user := s.OAuth.GetUser(r)
+
+
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)
+
+
rkey := chi.URLParam(r, "rkey")
+
if rkey == "" {
+
l.Error("malformed url, empty rkey")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
l = l.With("rkey", rkey)
+
+
// get the string currently being edited
+
all, err := db.GetStrings(
+
s.Db,
+
0,
+
db.FilterEq("did", id.DID),
+
db.FilterEq("rkey", rkey),
+
)
+
if err != nil {
+
l.Error("failed to fetch string", "err", err)
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
if len(all) != 1 {
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
+
w.WriteHeader(http.StatusInternalServerError)
+
return
+
}
+
first := all[0]
+
+
// verify that the logged in user owns this string
+
if user.Did != id.DID.String() {
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
+
w.WriteHeader(http.StatusUnauthorized)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodGet:
+
// return the form with prefilled fields
+
s.Pages.PutString(w, pages.PutStringParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
Action: "edit",
+
String: first,
+
})
+
case http.MethodPost:
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
s.Pages.Notice(w, "error", msg)
+
}
+
+
filename := r.FormValue("filename")
+
if filename == "" {
+
fail("Empty filename.", nil)
+
return
+
}
+
+
content := r.FormValue("content")
+
if content == "" {
+
fail("Empty contents.", nil)
+
return
+
}
+
+
description := r.FormValue("description")
+
+
// construct new string from form values
+
entry := db.String{
+
Did: first.Did,
+
Rkey: first.Rkey,
+
Filename: filename,
+
Description: description,
+
Contents: content,
+
Created: first.Created,
+
}
+
+
record := entry.AsRecord()
+
+
client, err := s.OAuth.AuthorizedClient(r)
+
if err != nil {
+
fail("Failed to create record.", err)
+
return
+
}
+
+
// first replace the existing record in the PDS
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
+
if err != nil {
+
fail("Failed to updated existing record.", err)
+
return
+
}
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
+
Collection: tangled.StringNSID,
+
Repo: entry.Did.String(),
+
Rkey: entry.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
fail("Failed to updated existing record.", err)
+
return
+
}
+
l := l.With("aturi", resp.Uri)
+
l.Info("edited string")
+
+
// if that went okay, updated the db
+
if err = db.AddString(s.Db, entry); err != nil {
+
fail("Failed to update string.", err)
+
return
+
}
+
+
// if that went okay, redir to the string
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
+
}
+
+
}
+
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "create")
+
user := s.OAuth.GetUser(r)
+
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.PutString(w, pages.PutStringParams{
+
LoggedInUser: s.OAuth.GetUser(r),
+
Action: "new",
+
})
+
case http.MethodPost:
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
s.Pages.Notice(w, "error", msg)
+
}
+
+
filename := r.FormValue("filename")
+
if filename == "" {
+
fail("Empty filename.", nil)
+
return
+
}
+
+
content := r.FormValue("content")
+
if content == "" {
+
fail("Empty contents.", nil)
+
return
+
}
+
+
description := r.FormValue("description")
+
+
string := db.String{
+
Did: syntax.DID(user.Did),
+
Rkey: tid.TID(),
+
Filename: filename,
+
Description: description,
+
Contents: content,
+
Created: time.Now(),
+
}
+
+
record := string.AsRecord()
+
+
client, err := s.OAuth.AuthorizedClient(r)
+
if err != nil {
+
fail("Failed to create record.", err)
+
return
+
}
+
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
+
Collection: tangled.StringNSID,
+
Repo: user.Did,
+
Rkey: string.Rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &record,
+
},
+
})
+
if err != nil {
+
fail("Failed to create record.", err)
+
return
+
}
+
l := l.With("aturi", resp.Uri)
+
l.Info("created record")
+
+
// insert into DB
+
if err = db.AddString(s.Db, string); err != nil {
+
fail("Failed to create string.", err)
+
return
+
}
+
+
// successful
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
+
}
+
}
+
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
+
l := s.Logger.With("handler", "create")
+
user := s.OAuth.GetUser(r)
+
fail := func(msg string, err error) {
+
l.Error(msg, "err", err)
+
s.Pages.Notice(w, "error", msg)
+
}
+
+
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)
+
+
rkey := chi.URLParam(r, "rkey")
+
if rkey == "" {
+
l.Error("malformed url, empty rkey")
+
w.WriteHeader(http.StatusBadRequest)
+
return
+
}
+
+
if user.Did != id.DID.String() {
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
+
return
+
}
+
+
if err := db.DeleteString(
+
s.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("rkey", rkey),
+
); err != nil {
+
fail("Failed to delete string.", err)
+
return
+
}
+
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
+
}
+
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+
}
+53
appview/validator/issue.go
···
+
package validator
+
+
import (
+
"fmt"
+
"strings"
+
+
"tangled.sh/tangled.sh/core/appview/db"
+
)
+
+
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
+
// if comments have parents, only ingest ones that are 1 level deep
+
if comment.ReplyTo != nil {
+
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
+
if err != nil {
+
return fmt.Errorf("failed to fetch parent comment: %w", err)
+
}
+
if len(parents) != 1 {
+
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
+
}
+
+
// depth check
+
parent := parents[0]
+
if parent.ReplyTo != nil {
+
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
+
}
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
+
if issue.Title == "" {
+
return fmt.Errorf("issue title is empty")
+
}
+
+
if issue.Body == "" {
+
return fmt.Errorf("issue body is empty")
+
}
+
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
+
return fmt.Errorf("title is empty after HTML sanitization")
+
}
+
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
+
return fmt.Errorf("body is empty after HTML sanitization")
+
}
+
+
return nil
+
}
+18
appview/validator/validator.go
···
+
package validator
+
+
import (
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
)
+
+
type Validator struct {
+
db *db.DB
+
sanitizer markup.Sanitizer
+
}
+
+
func New(db *db.DB) *Validator {
+
return &Validator{
+
db: db,
+
sanitizer: markup.NewSanitizer(),
+
}
+
}
+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
+
}
+
}
+33 -4
avatar/src/index.js
···
export default {
async fetch(request, env) {
+
// Helper function to generate a color from a string
+
const stringToColor = (str) => {
+
let hash = 0;
+
for (let i = 0; i < str.length; i++) {
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
+
}
+
let color = "#";
+
for (let i = 0; i < 3; i++) {
+
const value = (hash >> (i * 8)) & 0xff;
+
color += ("00" + value.toString(16)).substr(-2);
+
}
+
return color;
+
};
+
const url = new URL(request.url);
const { pathname, searchParams } = url;
···
const profile = await profileResponse.json();
const avatar = profile.avatar;
-
if (!avatar) {
-
return new Response(`avatar not found for ${actor}.`, { status: 404 });
+
let avatarUrl = profile.avatar;
+
+
if (!avatarUrl) {
+
// Generate a random color based on the actor string
+
const bgColor = stringToColor(actor);
+
const size = resizeToTiny ? 32 : 128;
+
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
+
const svgData = new TextEncoder().encode(svg);
+
+
response = new Response(svgData, {
+
headers: {
+
"Content-Type": "image/svg+xml",
+
"Cache-Control": "public, max-age=43200",
+
},
+
});
+
await cache.put(cacheKey, response.clone());
+
return response;
}
// Resize if requested
let avatarResponse;
if (resizeToTiny) {
-
avatarResponse = await fetch(avatar, {
+
avatarResponse = await fetch(avatarUrl, {
cf: {
image: {
width: 32,
···
},
});
} else {
-
avatarResponse = await fetch(avatar);
+
avatarResponse = await fetch(avatarUrl);
}
if (!avatarResponse.ok) {
+3
cmd/appview/main.go
···
}
state, err := state.Make(ctx, c)
+
defer func() {
+
log.Println(state.Close())
+
}()
if err != nil {
log.Fatal(err)
+8 -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.PublicKey{},
tangled.Repo{},
tangled.RepoArtifact{},
+
tangled.RepoCollaborator{},
tangled.RepoIssue{},
tangled.RepoIssueComment{},
tangled.RepoIssueState{},
···
tangled.RepoPullComment{},
tangled.RepoPull_Source{},
tangled.RepoPullStatus{},
+
tangled.RepoPull_Target{},
tangled.Spindle{},
tangled.SpindleMember{},
+
tangled.String{},
); err != nil {
panic(err)
}
+4
cmd/genjwks/main.go
···
panic(err)
}
+
if err := key.Set("use", "sig"); err != nil {
+
panic(err)
+
}
+
b, err := json.Marshal(key)
if err != nil {
panic(err)
+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)
}
+17 -18
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
···
If you're submitting a PR with multiple commits, make sure each one is
signed.
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to
-
your jj config:
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
+
to make it sign off commits in the tangled repo:
-
```
-
ui.should-sign-off = true
-
```
-
-
and to your `templates.draft_commit_description`, add the following `if`
-
block:
-
-
```
-
if(
-
config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()),
-
"\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">",
-
),
+
```shell
+
# Safety check, should say "No matching config key..."
+
jj config list templates.commit_trailers
+
# The command below may need to be adjusted if the command above returned something.
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
```
Refer to the [jj
-
documentation](https://jj-vcs.github.io/jj/latest/config/#default-description)
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
for more information.
+65 -17
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` in the browser and
-
generate a knot secret. Replace the existing secret in
-
`flake.nix` with the newly generated secret.
+
<details>
+
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
+
+
In order to build Tangled's dev VM on macOS, you will
+
first need to set up a Linux Nix builder. The recommended
+
way to do so is to run a [`darwin.linux-builder`
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
+
and to register it in `nix.conf` as a builder for Linux
+
with the same architecture as your Mac (`linux-aarch64` if
+
you are using Apple Silicon).
+
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
+
> the tangled repo so that it doesn't conflict with the other VM. For example,
+
> you can do
+
>
+
> ```shell
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
+
> ```
+
>
+
> to store the builder VM in a temporary dir.
+
>
+
> You should read and follow [all the other intructions][darwin builder vm] to
+
> avoid subtle problems.
-
You can now start a lightweight NixOS VM using
-
`nixos-shell` like so:
+
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>
+
+
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
+
### 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.
+
`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`.
+27 -7
docs/knot-hosting.md
···
So you want to run your own knot server? Great! Here are a few prerequisites:
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
2. A (sub)domain name. People generally use `knot.example.com`.
3. A valid SSL certificate for your domain.
···
EOF
```
+
Then, reload `sshd`:
+
+
```
+
sudo systemctl reload ssh
+
```
+
Next, create the `git` user. We'll use the `git` user's home directory
to store repositories:
···
```
Create `/home/git/.knot.env` with the following, updating the values as
-
necessary. The `KNOT_SERVER_SECRET` can be obtaind from the
-
[/knots](/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
```
···
systemctl start knotserver
```
-
The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
knot. Here's an example configuration for Nginx:
```
···
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](/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
···
```
Make sure to restart your SSH server!
+
+
#### MOTD (message of the day)
+
+
To configure the MOTD used ("Welcome to this knot!" by default), edit the
+
`/home/git/motd` file:
+
+
```
+
printf "Hi from this knot!\n" > /home/git/motd
+
```
+
+
Note that you should add a newline at the end if setting a non-empty message
+
since the knot won't do this for you.
+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";
+
};
+
};
+
```
+
+4 -3
docs/spindle/architecture.md
···
### the engine
-
At present, the only supported backend is Docker. Spindle executes each step in
-
the pipeline in a fresh container, with state persisted across steps within the
-
`/tangled/workspace` directory.
+
At present, the only supported backend is Docker (and Podman, if Docker
+
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
+
executes each step in the pipeline in a fresh container, with state persisted
+
across steps within the `/tangled/workspace` directory.
The base image for the container is constructed on the fly using
[Nixery](https://nixery.dev), which is handy for caching layers for frequently
+285
docs/spindle/openbao.md
···
+
# spindle secrets with openbao
+
+
This document covers setting up Spindle to use OpenBao for secrets
+
management via OpenBao Proxy instead of the default SQLite backend.
+
+
## overview
+
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
+
authentication automatically using AppRole credentials, while Spindle
+
connects to the local proxy instead of directly to the OpenBao server.
+
+
This approach provides better security, automatic token renewal, and
+
simplified application code.
+
+
## installation
+
+
Install OpenBao from nixpkgs:
+
+
```bash
+
nix shell nixpkgs#openbao # for a local server
+
```
+
+
## setup
+
+
The setup process can is documented for both local development and production.
+
+
### local development
+
+
Start OpenBao in dev mode:
+
+
```bash
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
+
```
+
+
This starts OpenBao on `http://localhost:8201` with a root token.
+
+
Set up environment for bao CLI:
+
+
```bash
+
export BAO_ADDR=http://localhost:8200
+
export BAO_TOKEN=root
+
```
+
+
### production
+
+
You would typically use a systemd service with a configuration file. Refer to
+
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
+
achieved using Nix.
+
+
Then, initialize the bao server:
+
```bash
+
bao operator init -key-shares=1 -key-threshold=1
+
```
+
+
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
+
```bash
+
bao operator unseal <unseal_key>
+
```
+
+
All steps below remain the same across both dev and production setups.
+
+
### configure openbao server
+
+
Create the spindle KV mount:
+
+
```bash
+
bao secrets enable -path=spindle -version=2 kv
+
```
+
+
Set up AppRole authentication and policy:
+
+
Create a policy file `spindle-policy.hcl`:
+
+
```hcl
+
# Full access to spindle KV v2 data
+
path "spindle/data/*" {
+
capabilities = ["create", "read", "update", "delete"]
+
}
+
+
# Access to metadata for listing and management
+
path "spindle/metadata/*" {
+
capabilities = ["list", "read", "delete", "update"]
+
}
+
+
# Allow listing at root level
+
path "spindle/" {
+
capabilities = ["list"]
+
}
+
+
# Required for connection testing and health checks
+
path "auth/token/lookup-self" {
+
capabilities = ["read"]
+
}
+
```
+
+
Apply the policy and create an AppRole:
+
+
```bash
+
bao policy write spindle-policy spindle-policy.hcl
+
bao auth enable approle
+
bao write auth/approle/role/spindle \
+
token_policies="spindle-policy" \
+
token_ttl=1h \
+
token_max_ttl=4h \
+
bind_secret_id=true \
+
secret_id_ttl=0 \
+
secret_id_num_uses=0
+
```
+
+
Get the credentials:
+
+
```bash
+
# Get role ID (static)
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
+
+
# Generate secret ID
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
+
+
echo "Role ID: $ROLE_ID"
+
echo "Secret ID: $SECRET_ID"
+
```
+
+
### create proxy configuration
+
+
Create the credential files:
+
+
```bash
+
# Create directory for OpenBao files
+
mkdir -p /tmp/openbao
+
+
# Save credentials
+
echo "$ROLE_ID" > /tmp/openbao/role-id
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
+
```
+
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
+
+
```hcl
+
# OpenBao server connection
+
vault {
+
address = "http://localhost:8200"
+
}
+
+
# Auto-Auth using AppRole
+
auto_auth {
+
method "approle" {
+
mount_path = "auth/approle"
+
config = {
+
role_id_file_path = "/tmp/openbao/role-id"
+
secret_id_file_path = "/tmp/openbao/secret-id"
+
}
+
}
+
+
# Optional: write token to file for debugging
+
sink "file" {
+
config = {
+
path = "/tmp/openbao/token"
+
mode = 0640
+
}
+
}
+
}
+
+
# Proxy listener for Spindle
+
listener "tcp" {
+
address = "127.0.0.1:8201"
+
tls_disable = true
+
}
+
+
# Enable API proxy with auto-auth token
+
api_proxy {
+
use_auto_auth_token = true
+
}
+
+
# Enable response caching
+
cache {
+
use_auto_auth_token = true
+
}
+
+
# Logging
+
log_level = "info"
+
```
+
+
### start the proxy
+
+
Start OpenBao Proxy:
+
+
```bash
+
bao proxy -config=/tmp/openbao/proxy.hcl
+
```
+
+
The proxy will authenticate with OpenBao and start listening on
+
`127.0.0.1:8201`.
+
+
### configure spindle
+
+
Set these environment variables for Spindle:
+
+
```bash
+
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
+
```
+
+
Start Spindle:
+
+
Spindle will now connect to the local proxy, which handles all
+
authentication automatically.
+
+
## production setup for proxy
+
+
For production, you'll want to run the proxy as a service:
+
+
Place your production configuration in `/etc/openbao/proxy.hcl` with
+
proper TLS settings for the vault connection.
+
+
## verifying setup
+
+
Test the proxy directly:
+
+
```bash
+
# Check proxy health
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
+
+
# Test token lookup through proxy
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
+
```
+
+
Test OpenBao operations through the server:
+
+
```bash
+
# List all secrets
+
bao kv list spindle/
+
+
# Add a test secret via Spindle API, then check it exists
+
bao kv list spindle/repos/
+
+
# Get a specific secret
+
bao kv get spindle/repos/your_repo_path/SECRET_NAME
+
```
+
+
## how it works
+
+
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
+
- The proxy authenticates with OpenBao using AppRole credentials
+
- All Spindle requests go through the proxy, which injects authentication tokens
+
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
+
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
+
- The proxy handles all token renewal automatically
+
- Spindle no longer manages tokens or authentication directly
+
+
## troubleshooting
+
+
**Connection refused**: Check that the OpenBao Proxy is running and
+
listening on the configured address.
+
+
**403 errors**: Verify the AppRole credentials are correct and the policy
+
has the necessary permissions.
+
+
**404 route errors**: The spindle KV mount probably doesn't exist - run
+
the mount creation step again.
+
+
**Proxy authentication failures**: Check the proxy logs and verify the
+
role-id and secret-id files are readable and contain valid credentials.
+
+
**Secret not found after writing**: This can indicate policy permission
+
issues. Verify the policy includes both `spindle/data/*` and
+
`spindle/metadata/*` paths with appropriate capabilities.
+
+
Check proxy logs:
+
+
```bash
+
# If running as systemd service
+
journalctl -u openbao-proxy -f
+
+
# If running directly, check the console output
+
```
+
+
Test AppRole authentication manually:
+
+
```bash
+
bao write auth/approle/login \
+
role_id="$(cat /tmp/openbao/role-id)" \
+
secret_id="$(cat /tmp/openbao/secret-id)"
+
```
+142 -36
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:
+
+
- [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.
-
Spindle pipelines are defined under the `.tangled/workflows` directory in a
-
repo. Generally:
+
## Trigger
+
+
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:
-
* 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.
+
- `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.
-
Here's an example that uses all fields:
+
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
+
```
-
steps:
-
- name: "Install dependencies"
-
command: "npm install"
-
environment:
-
NODE_ENV: "development"
-
CI: "true"
+
Now these dependencies are available to use in your workflow!
-
- name: "Run linter"
-
command: "npm run lint"
+
## Environment
+
+
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
+
+
Example:
+
+
```yaml
+
environment:
+
GOOS: "linux"
+
GOARCH: "arm64"
+
NODE_ENV: "production"
+
MY_ENV_VAR: "MY_ENV_VALUE"
+
```
+
+
## 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.**
+
+
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
+
+
```yaml
+
# .tangled/workflows/build.yml
+
+
when:
+
- event: ["push", "manual"]
+
branch: ["main", "develop"]
+
- event: ["pull_request"]
+
branch: ["main"]
+
+
engine: "nixery"
-
## current repository is cloned and checked out at the target ref
-
## by default.
+
# 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
+
+
environment:
+
GOOS: "linux"
+
GOARCH: "arm64"
+
NODE_ENV: "production"
+
MY_ENV_VAR: "MY_ENV_VALUE"
+
+
steps:
+
- name: "Build backend"
+
command: "go build"
+
environment:
+
GOOS: "darwin"
+
GOARCH: "arm64"
+
- name: "Build frontend"
+
command: "npm run build"
+
environment:
+
NODE_ENV: "production"
```
+
+
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)
}
+10 -31
flake.lock
···
{
"nodes": {
-
"gitignore": {
-
"inputs": {
-
"nixpkgs": [
-
"nixpkgs"
-
]
-
},
-
"locked": {
-
"lastModified": 1709087332,
-
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
-
"owner": "hercules-ci",
-
"repo": "gitignore.nix",
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
-
"type": "github"
-
},
-
"original": {
-
"owner": "hercules-ci",
-
"repo": "gitignore.nix",
-
"type": "github"
-
}
-
},
"flake-utils": {
"inputs": {
"systems": "systems"
···
]
},
"locked": {
-
"lastModified": 1751702058,
-
"narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=",
+
"lastModified": 1754078208,
+
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
"owner": "nix-community",
"repo": "gomod2nix",
-
"rev": "664ad7a2df4623037e315e4094346bff5c44e9ee",
+
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
"type": "github"
},
"original": {
···
"indigo": {
"flake": false,
"locked": {
-
"lastModified": 1745333930,
-
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
+
"lastModified": 1753693716,
+
"narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=",
"owner": "oppiliappan",
"repo": "indigo",
-
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
+
"rev": "5f170569da9360f57add450a278d73538092d8ca",
"type": "github"
},
"original": {
···
"lucide-src": {
"flake": false,
"locked": {
-
"lastModified": 1742302029,
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
+
"lastModified": 1754044466,
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
"type": "tarball",
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
},
"original": {
"type": "tarball",
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
}
},
"nixpkgs": {
···
},
"root": {
"inputs": {
-
"gitignore": "gitignore",
"gomod2nix": "gomod2nix",
"htmx-src": "htmx-src",
"htmx-ws-src": "htmx-ws-src",
+103 -29
flake.nix
···
flake = false;
};
lucide-src = {
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
flake = false;
};
inter-fonts-src = {
···
url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip";
flake = false;
};
-
gitignore = {
-
url = "github:hercules-ci/gitignore.nix";
-
inputs.nixpkgs.follows = "nixpkgs";
-
};
};
outputs = {
···
htmx-src,
htmx-ws-src,
lucide-src,
-
gitignore,
inter-fonts-src,
sqlite-lib-src,
ibm-plex-mono-src,
···
mkPackageSet = pkgs:
pkgs.lib.makeScope pkgs.newScope (self: {
-
inherit (gitignore.lib) gitignoreSource;
+
src = let
+
fs = pkgs.lib.fileset;
+
in
+
fs.toSource {
+
root = ./.;
+
fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj);
+
};
buildGoApplication =
(self.callPackage "${gomod2nix}/builder" {
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
···
};
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
-
appview = self.callPackage ./nix/pkgs/appview.nix {
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
};
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
staticPackages = mkPackageSet pkgs.pkgsStatic;
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
in {
-
appview = packages.appview;
-
lexgen = packages.lexgen;
-
knot = packages.knot;
-
knot-unwrapped = packages.knot-unwrapped;
-
spindle = packages.spindle;
-
genjwks = packages.genjwks;
-
sqlite-lib = packages.sqlite-lib;
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
pkgsStatic-appview = staticPackages.appview;
pkgsStatic-knot = staticPackages.knot;
···
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.tailwindcss
pkgs.nixos-shell
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/{fonts,icons}
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
+
mkdir -p appview/pages/static
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
'';
env.CGO_ENABLED = 1;
···
});
apps = forAllSystems (system: let
pkgs = nixpkgsFor."${system}";
+
packages' = self.packages.${system};
air-watcher = name: arg:
pkgs.writeShellScriptBin "run"
''
···
${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 = ''${air-watcher "appview" ""}/bin/run'';
+
program = toString (pkgs.writeShellScript "watch-appview" ''
+
echo "copying static files to appview/pages/static..."
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
+
${air-watcher "appview" ""}/bin/run
+
'');
};
watch-knot = {
type = "app";
···
type = "app";
program = ''${tailwind-watcher}/bin/run'';
};
-
vm = {
+
vm = let
+
guestSystem =
+
if pkgs.stdenv.hostPlatform.isAarch64
+
then "aarch64-linux"
+
else "x86_64-linux";
+
in {
type = "app";
-
program = toString (pkgs.writeShellScript "vm" ''
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
-
'');
+
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";
···
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
'');
};
+
lexgen = {
+
type = "app";
+
program =
+
(pkgs.writeShellApplication {
+
name = "lexgen";
+
text = ''
+
if ! command -v lexgen > /dev/null; then
+
echo "error: must be executed from devshell"
+
exit 1
+
fi
+
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
+
cd "$rootDir"
+
+
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/*
+
go run cmd/gen.go
+
lexgen --build-file lexicon-build-config.json lexicons
+
rm api/tangled/*.bak
+
'';
+
})
+
+ /bin/lexgen;
+
};
});
nixosModules.appview = {
···
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
};
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
};
}
+38 -14
go.mod
···
module tangled.sh/tangled.sh/core
-
go 1.24.0
-
-
toolchain go1.24.3
+
go 1.24.4
require (
github.com/Blank-Xu/sql-adapter v1.1.1
+
github.com/alecthomas/assert/v2 v2.11.0
github.com/alecthomas/chroma/v2 v2.15.0
github.com/avast/retry-go/v4 v4.6.1
github.com/bluekeyes/go-gitdiff v0.8.1
···
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
github.com/carlmjohnson/versioninfo v0.22.5
github.com/casbin/casbin/v2 v2.103.0
+
github.com/cloudflare/cloudflare-go v0.115.0
github.com/cyphar/filepath-securejoin v0.4.1
github.com/dgraph-io/ristretto v0.2.0
github.com/docker/docker v28.2.2+incompatible
···
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.3
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/hiddeco/sshsig v0.2.0
github.com/hpcloud/tail v1.0.0
github.com/ipfs/go-cid v0.5.0
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/mattn/go-sqlite3 v1.14.24
github.com/microcosm-cc/bluemonday v1.0.27
+
github.com/openbao/openbao/api/v2 v2.3.0
github.com/posthog/posthog-go v1.5.5
-
github.com/redis/go-redis/v9 v9.3.0
+
github.com/redis/go-redis/v9 v9.7.3
github.com/resend/resend-go/v2 v2.15.0
github.com/sethvargo/go-envconfig v1.1.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.41.0
+
golang.org/x/net v0.42.0
+
golang.org/x/sync v0.16.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
gopkg.in/yaml.v3 v3.0.1
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1
···
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
+
github.com/alecthomas/repr v0.4.0 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
-
github.com/cloudflare/circl v1.6.0 // indirect
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
···
github.com/docker/go-units v0.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
+
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
+
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-redis/cache/v9 v9.0.0 // indirect
+
github.com/go-test/deep v1.1.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+
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/securecookie v1.1.2 // indirect
+
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
+
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
+
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/boxo v0.33.0 // indirect
github.com/ipfs/go-block-format v0.2.2 // indirect
···
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
+
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
···
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+
github.com/onsi/gomega v1.37.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
-
github.com/opentracing/opentracing-go v1.2.0 // indirect
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
···
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
+
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/spaolacci/murmur3 v1.1.0 // 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
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
···
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
-
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.34.0 // indirect
+
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
-
google.golang.org/grpc v1.72.1 // indirect
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
+
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
+97 -97
go.sum
···
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-
github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs=
-
github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
+
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
+
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
···
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4=
-
github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng=
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
···
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
-
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4=
+
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
+
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
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/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
-
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
-
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
+
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
···
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY=
github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM=
+
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
+
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
···
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
+
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
-
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
-
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
-
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
···
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
+
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
-
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
+
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
-
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
-
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
+
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
+
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
+
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
+
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
+
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw=
···
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
-
github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ=
-
github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370=
github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw=
github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM=
-
github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q=
-
github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk=
github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ=
github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8=
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
···
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
-
github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0=
-
github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0=
github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E=
github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A=
-
github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ=
-
github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs=
github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU=
github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk=
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
···
github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU=
github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY=
-
github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE=
-
github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M=
-
github.com/ipfs/go-test v0.2.2 h1:1yjYyfbdt1w93lVzde6JZ2einh3DIV40at4rVoyEcE8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
-
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
···
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
-
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
···
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
-
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
-
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
-
github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE=
-
github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI=
-
github.com/libp2p/go-libp2p v0.42.0 h1:A8foZk+ZEhZTv0Jb++7xUFlrFhBDv4j2Vh/uq4YX+KE=
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
···
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
+
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
···
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
-
github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo=
-
github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0=
-
github.com/multiformats/go-multiaddr v0.16.0 h1:oGWEVKioVQcdIOBlYM8BH1rZDWOGJSqr9/BKl6zQ4qc=
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
-
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
-
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
-
github.com/multiformats/go-multicodec v0.9.2 h1:YrlXCuqxjqm3bXl+vBq5LKz5pz4mvAsugdqy78k0pXQ=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
···
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
-
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
-
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
+
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
+
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
+
github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc=
+
github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
-
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
+
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU=
github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo=
···
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
-
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
-
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
-
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
+
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
+
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
···
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
···
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=
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
-
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
-
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk=
-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME=
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4=
-
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
-
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
-
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
-
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
-
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
-
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
-
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
···
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
-
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
-
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
-
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
···
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
···
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
-
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
-
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
-
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
-
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
-
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
-
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
···
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
···
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
-
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
-
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
···
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
-
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
···
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
-
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
-
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
-
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
+
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
+
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
···
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90=
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ=
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU=
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
+19 -3
guard/guard.go
···
import (
"context"
+
"errors"
"fmt"
+
"io"
"log/slog"
"net/http"
"net/url"
···
Usage: "internal API endpoint",
Value: "http://localhost:5444",
},
+
&cli.StringFlag{
+
Name: "motd-file",
+
Usage: "path to message of the day file",
+
Value: "/home/git/motd",
+
},
},
}
}
···
gitDir := cmd.String("git-dir")
logPath := cmd.String("log-path")
endpoint := cmd.String("internal-api")
+
motdFile := cmd.String("motd-file")
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
···
"fullPath", fullPath,
"client", clientIP)
-
if gitCommand == "git-upload-pack" {
-
fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
+
var motdReader io.Reader
+
if reader, err := os.Open(motdFile); err != nil {
+
if !errors.Is(err, os.ErrNotExist) {
+
l.Error("failed to read motd file", "error", err)
+
}
+
motdReader = strings.NewReader("Welcome to this knot!\n")
} else {
-
fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
+
motdReader = reader
+
}
+
if gitCommand == "git-upload-pack" {
+
io.WriteString(os.Stderr, "\x02")
}
+
io.Copy(os.Stderr, motdReader)
gitCmd := exec.Command(gitCommand, fullPath)
gitCmd.Stdout = os.Stdout
+24
hook/hook.go
···
import (
"bufio"
"context"
+
"encoding/json"
"fmt"
"net/http"
"os"
···
"github.com/urfave/cli/v3"
)
+
+
type HookResponse struct {
+
Messages []string `json:"messages"`
+
}
// The hook command is nested like so:
//
···
Usage: "endpoint for the internal API",
Value: "http://localhost:5444",
},
+
&cli.StringSliceFlag{
+
Name: "push-option",
+
Usage: "any push option from git",
+
},
},
Commands: []*cli.Command{
{
···
userDid := cmd.String("user-did")
userHandle := cmd.String("user-handle")
endpoint := cmd.String("internal-api")
+
pushOptions := cmd.StringSlice("push-option")
payloadReader := bufio.NewReader(os.Stdin)
payload, _ := payloadReader.ReadString('\n')
···
req.Header.Set("X-Git-Dir", gitDir)
req.Header.Set("X-Git-User-Did", userDid)
req.Header.Set("X-Git-User-Handle", userHandle)
+
if pushOptions != nil {
+
for _, option := range pushOptions {
+
req.Header.Add("X-Git-Push-Option", option)
+
}
+
}
resp, err := client.Do(req)
if err != nil {
···
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+
}
+
+
var data HookResponse
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+
return fmt.Errorf("failed to decode response: %w", err)
+
}
+
+
for _, message := range data.Messages {
+
fmt.Println(message)
}
return nil
+6 -1
hook/setup.go
···
hookContent := fmt.Sprintf(`#!/usr/bin/env bash
# AUTO GENERATED BY KNOT, DO NOT MODIFY
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve
+
push_options=()
+
for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do
+
option_var="GIT_PUSH_OPTION_$i"
+
push_options+=(-push-option "${!option_var}")
+
done
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
`, executablePath, config.internalApi)
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+85 -9
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;
···
details summary::-webkit-details-marker {
display: none;
}
+
+
code {
+
@apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white;
+
}
}
@layer components {
···
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-left: 0;
-
margin-right: 0;
+
margin: 0;
vertical-align: middle;
+
}
+
+
.prose input {
+
@apply inline-block my-0 mb-1 mx-1;
+
}
+
+
.prose input[type="checkbox"] {
+
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
}
}
@layer utilities {
···
/* PreWrapper */
.chroma {
color: #4c4f69;
-
background-color: #eff1f5;
}
/* Error */
.chroma .err {
···
/* PreWrapper */
.chroma {
color: #cad3f5;
-
background-color: #24273a;
}
/* Error */
.chroma .err {
+19 -4
jetstream/jetstream.go
···
j.mu.Unlock()
}
+
func (j *JetstreamClient) RemoveDid(did string) {
+
if did == "" {
+
return
+
}
+
+
if j.logDids {
+
j.l.Info("removing did from in-memory filter", "did", did)
+
}
+
j.mu.Lock()
+
delete(j.wantedDids, did)
+
j.mu.Unlock()
+
}
+
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)
-56
knotserver/file.go
···
-
package knotserver
-
-
import (
-
"bytes"
-
"io"
-
"log/slog"
-
"net/http"
-
"strings"
-
-
"tangled.sh/tangled.sh/core/types"
-
)
-
-
func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) {
-
data["files"] = files
-
-
writeJSON(w, data)
-
return
-
}
-
-
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)
-
return
-
}
+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
}
+28 -22
knotserver/git/post_receive.go
···
import (
"bufio"
"context"
+
"errors"
"fmt"
"io"
"strings"
···
ByEmail map[string]int
}
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
+
var errs error
+
commitCount, err := g.newCommitCount(line)
-
if err != nil {
-
// TODO: log this
-
}
+
errors.Join(errs, err)
isDefaultRef, err := g.isDefaultBranch(line)
-
if err != nil {
-
// TODO: log this
-
}
+
errors.Join(errs, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
defer cancel()
breakdown, err := g.AnalyzeLanguages(ctx)
-
if err != nil {
-
// TODO: log this
-
}
+
errors.Join(errs, err)
return RefUpdateMeta{
CommitCount: commitCount,
IsDefaultRef: isDefaultRef,
LangBreakdown: breakdown,
-
}
+
}, errs
}
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
args := []string{fmt.Sprintf("--max-count=%d", 100)}
if line.OldSha.IsZero() {
-
// just git rev-list <newsha>
+
// git rev-list <newsha> ^other-branches --not ^this-branch
args = append(args, line.NewSha.String())
+
+
branches, _ := g.Branches()
+
for _, b := range branches {
+
if !strings.Contains(line.Ref, b.Name) {
+
args = append(args, fmt.Sprintf("^%s", b.Name))
+
}
+
}
+
+
args = append(args, "--not")
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
} else {
// git rev-list <oldsha>..<newsha>
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
···
}
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)
-
}
+130 -80
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)
}
-
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
+
// duplicated from add collaborator
+
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
+
}
+
+
resolver := idresolver.DefaultResolver()
+
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
+
return err
+
}
+
+
// TODO: fix this for good, we need to fetch the record here unfortunately
+
// resolve this aturi to extract the repo record
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
+
if err != nil || owner.Handle.IsInvalidHandle() {
+
return fmt.Errorf("failed to resolve handle: %w", err)
+
}
+
+
xrpcc := xrpc.Client{
+
Host: owner.PDSEndpoint(),
+
}
+
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
+
if err != nil {
+
return err
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
+
+
// check perms for this user
+
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 err
+
}
+
h.jc.AddDid(subjectId.DID.String())
+
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
+
return err
+
}
+
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
+
}
+
+
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
}
···
defer func() {
eventTime := event.TimeUS
lastTimeUs := eventTime + 1
-
fmt.Println("lastTimeUs", lastTimeUs)
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
}
}()
-
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:
+
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
}
+55 -17
knotserver/internal.go
···
import (
"context"
"encoding/json"
+
"errors"
"fmt"
"log/slog"
"net/http"
···
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/hook"
"tangled.sh/tangled.sh/core/knotserver/config"
"tangled.sh/tangled.sh/core/knotserver/db"
"tangled.sh/tangled.sh/core/knotserver/git"
···
}
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 {
+
skipCi bool
+
verboseCi bool
}
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
···
// non-fatal
}
+
// extract any push options
+
pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
+
pushOptions := PushOptions{}
+
for _, option := range pushOptionsRaw {
+
if option == "skip-ci" || option == "ci-skip" {
+
pushOptions.skipCi = true
+
}
+
if option == "verbose-ci" || option == "ci-verbose" {
+
pushOptions.verboseCi = true
+
}
+
}
+
+
resp := hook.HookResponse{
+
Messages: make([]string, 0),
+
}
+
for _, line := range lines {
err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
if err != nil {
···
// non-fatal
}
-
err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
+
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions)
if err != nil {
l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
// non-fatal
}
}
+
+
writeJSON(w, resp)
}
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
}
-
meta := gr.RefUpdateMeta(line)
+
var errs error
+
meta, err := gr.RefUpdateMeta(line)
+
errors.Join(errs, err)
metaRecord := meta.AsRecord()
···
EventJson: string(eventJson),
}
-
return h.db.InsertEvent(event, h.n)
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
}
-
func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
+
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+
if pushOptions.skipCi {
+
return nil
+
}
+
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
if err != nil {
return err
···
return 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_PushTriggerData{
···
},
}
-
// TODO: send the diagnostics back to the user here via stderr
-
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 {
+
if compiler.Diagnostics.IsEmpty() {
+
*clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
+
}
+
+
for _, w := range compiler.Diagnostics.Warnings {
+
*clientMsgs = append(*clientMsgs, w.String())
+
}
}
// do not run empty pipelines
-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
+
}
-1340
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"
-
}
-
-
if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") {
-
l.Error("attempted to serve non-image/video file", "mimetype", mimeType)
-
writeError(w, "only image and video 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")
-
-
// This allows the browser to use a proper name for the file when
-
// downloading
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
-
setContentDisposition(w, filename)
-
setGZipMIME(w)
-
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
-
gr, err := git.Open(path, ref)
-
if err != nil {
-
notFound(w)
-
return
-
}
-
-
gw := gzip.NewWriter(w)
-
defer gw.Close()
-
-
prefix := fmt.Sprintf("%s-%s", name, ref)
-
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
-
}
+17 -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)
+
`,
}
}
···
tangled.PublicKeyNSID,
tangled.KnotMemberNSID,
tangled.RepoPullNSID,
+
tangled.RepoCollaboratorNSID,
}, nil, logger, db, true, c.Server.LogDids)
if err != nil {
logger.Error("failed to setup jetstream", "error", err)
+156
knotserver/xrpc/create_repo.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
"strings"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
gogit "github.com/go-git/go-git/v5"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/hook"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+
l := h.Logger.With("handler", "NewRepo")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
if !isMember {
+
fail(xrpcerr.AccessControlError(actorDid.String()))
+
return
+
}
+
+
var data tangled.RepoCreate_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
rkey := data.Rkey
+
+
ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String())
+
if err != nil || ident.Handle.IsInvalidHandle() {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
xrpcc := xrpc.Client{
+
Host: ident.PDSEndpoint(),
+
}
+
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
+
defaultBranch := h.Config.Repo.MainBranch
+
if data.DefaultBranch != nil && *data.DefaultBranch != "" {
+
defaultBranch = *data.DefaultBranch
+
}
+
+
if err := validateRepoName(repo.Name); err != nil {
+
l.Error("creating repo", "error", err.Error())
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
relativeRepoPath := filepath.Join(actorDid.String(), repo.Name)
+
repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
+
+
if data.Source != nil && *data.Source != "" {
+
err = git.Fork(repoPath, *data.Source)
+
if err != nil {
+
l.Error("forking repo", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
} else {
+
err = git.InitBare(repoPath, defaultBranch)
+
if err != nil {
+
l.Error("initializing bare repo", "error", err.Error())
+
if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
+
fail(xrpcerr.RepoExistsError("repository already exists"))
+
return
+
} else {
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
}
+
}
+
+
// add perms for this user to access the repo
+
err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath)
+
if err != nil {
+
l.Error("adding repo permissions", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
hook.SetupRepo(
+
hook.Config(
+
hook.WithScanPath(h.Config.Repo.ScanPath),
+
hook.WithInternalApi(h.Config.Server.InternalListenAddr),
+
),
+
repoPath,
+
)
+
+
w.WriteHeader(http.StatusOK)
+
}
+
+
func validateRepoName(name string) error {
+
// check for path traversal attempts
+
if name == "." || name == ".." ||
+
strings.Contains(name, "/") || strings.Contains(name, "\\") {
+
return fmt.Errorf("Repository name contains invalid path characters")
+
}
+
+
// check for sequences that could be used for traversal when normalized
+
if strings.Contains(name, "./") || strings.Contains(name, "../") ||
+
strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
+
return fmt.Errorf("Repository name contains invalid path sequence")
+
}
+
+
// then continue with character validation
+
for _, char := range name {
+
if !((char >= 'a' && char <= 'z') ||
+
(char >= 'A' && char <= 'Z') ||
+
(char >= '0' && char <= '9') ||
+
char == '-' || char == '_' || char == '.') {
+
return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
+
}
+
}
+
+
// additional check to prevent multiple sequential dots
+
if strings.Contains(name, "..") {
+
return fmt.Errorf("Repository name cannot contain sequential dots")
+
}
+
+
// if all checks pass
+
return nil
+
}
+96
knotserver/xrpc/delete_repo.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"os"
+
"path/filepath"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "DeleteRepo")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoDelete_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
rkey := data.Rkey
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
+
return
+
}
+
+
ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String())
+
if err != nil || ident.Handle.IsInvalidHandle() {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
xrpcc := xrpc.Client{
+
Host: ident.PDSEndpoint(),
+
}
+
+
// ensure that the record does not exists
+
_, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
+
if err == nil {
+
fail(xrpcerr.RecordExistsError(rkey))
+
return
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
if !isDeleteAllowed {
+
fail(xrpcerr.AccessControlError(actorDid.String()))
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
err = os.RemoveAll(repoPath)
+
if err != nil {
+
l.Error("deleting repo", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath)
+
if err != nil {
+
l.Error("failed to delete repo from enforcer", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+111
knotserver/xrpc/fork_status.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "ForkStatus")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoForkStatus_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
source := data.Source
+
branch := data.Branch
+
hiddenRef := data.HiddenRef
+
+
if did == "" || source == "" || branch == "" || hiddenRef == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required")))
+
return
+
}
+
+
var name string
+
if data.Name != "" {
+
name = data.Name
+
} else {
+
name = filepath.Base(source)
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
forkCommit, err := gr.ResolveRevision(branch)
+
if err != nil {
+
l.Error("error resolving ref revision", "msg", err.Error())
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err)))
+
return
+
}
+
+
sourceCommit, err := gr.ResolveRevision(hiddenRef)
+
if err != nil {
+
l.Error("error resolving hidden ref revision", "msg", err.Error())
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err)))
+
return
+
}
+
+
status := types.UpToDate
+
if forkCommit.Hash.String() != sourceCommit.Hash.String() {
+
isAncestor, err := forkCommit.IsAncestor(sourceCommit)
+
if err != nil {
+
l.Error("error checking ancestor relationship", "error", err.Error())
+
fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err)))
+
return
+
}
+
+
if isAncestor {
+
status = types.FastForwardable
+
} else {
+
status = types.Conflict
+
}
+
}
+
+
response := tangled.RepoForkStatus_Output{
+
Status: int64(status),
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+73
knotserver/xrpc/fork_sync.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "ForkSync")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoForkSync_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
branch := data.Branch
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did, name are required")))
+
return
+
}
+
+
relativeRepoPath := filepath.Join(did, name)
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.Open(repoPath, branch)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
err = gr.Sync()
+
if err != nil {
+
l.Error("error syncing repo fork", "error", err.Error())
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+104
knotserver/xrpc/hidden_ref.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "HiddenRef")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoHiddenRef_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
forkRef := data.ForkRef
+
remoteRef := data.RemoteRef
+
repoAtUri := data.Repo
+
+
if forkRef == "" || remoteRef == "" || repoAtUri == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required")))
+
return
+
}
+
+
repoAt, err := syntax.ParseATURI(repoAtUri)
+
if err != nil {
+
fail(xrpcerr.InvalidRepoError(repoAtUri))
+
return
+
}
+
+
ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
+
if err != nil || ident.Handle.IsInvalidHandle() {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
+
return
+
}
+
+
xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
+
resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
+
if err != nil {
+
l.Error("error tracking hidden remote ref", "error", err.Error())
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.RepoHiddenRef_Output{
+
Success: true,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+58
knotserver/xrpc/list_keys.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+
cursor := r.URL.Query().Get("cursor")
+
+
limit := 100 // default
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
+
limit = l
+
}
+
}
+
+
keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor)
+
if err != nil {
+
x.Logger.Error("failed to get public keys", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to retrieve public keys"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys))
+
for _, key := range keys {
+
publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{
+
Did: key.Did,
+
Key: key.Key,
+
CreatedAt: key.CreatedAt,
+
})
+
}
+
+
response := tangled.KnotListKeys_Output{
+
Keys: publicKeys,
+
}
+
+
if nextCursor != "" {
+
response.Cursor = &nextCursor
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+114
knotserver/xrpc/merge.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"net/http"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/patchutil"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "Merge")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
+
if !ok {
+
fail(xrpcerr.MissingActorDidError)
+
return
+
}
+
+
var data tangled.RepoMerge_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
+
return
+
}
+
+
relativeRepoPath, err := securejoin.SecureJoin(did, name)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil {
+
l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath)
+
writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.Open(repoPath, data.Branch)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
mo := git.MergeOptions{}
+
if data.AuthorName != nil {
+
mo.AuthorName = *data.AuthorName
+
}
+
if data.AuthorEmail != nil {
+
mo.AuthorEmail = *data.AuthorEmail
+
}
+
if data.CommitBody != nil {
+
mo.CommitBody = *data.CommitBody
+
}
+
if data.CommitMessage != nil {
+
mo.CommitMessage = *data.CommitMessage
+
}
+
+
mo.CommitterName = x.Config.Git.UserName
+
mo.CommitterEmail = x.Config.Git.UserEmail
+
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
+
+
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+
if err != nil {
+
var mergeErr *git.ErrMerge
+
if errors.As(err, &mergeErr) {
+
conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
+
for i, conflict := range mergeErr.Conflicts {
+
conflicts[i] = types.ConflictInfo{
+
Filename: conflict.Filename,
+
Reason: conflict.Reason,
+
}
+
}
+
+
conflictErr := xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("MergeConflict"),
+
xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)),
+
)
+
writeError(w, conflictErr, http.StatusConflict)
+
return
+
} else {
+
l.Error("failed to merge", "error", err.Error())
+
writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError)
+
return
+
}
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+87
knotserver/xrpc/merge_check.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"errors"
+
"fmt"
+
"net/http"
+
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+
l := x.Logger.With("handler", "MergeCheck")
+
fail := func(e xrpcerr.XrpcError) {
+
l.Error("failed", "kind", e.Tag, "error", e.Message)
+
writeError(w, e, http.StatusBadRequest)
+
}
+
+
var data tangled.RepoMergeCheck_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
did := data.Did
+
name := data.Name
+
+
if did == "" || name == "" {
+
fail(xrpcerr.GenericError(fmt.Errorf("did and name are required")))
+
return
+
}
+
+
relativeRepoPath, err := securejoin.SecureJoin(did, name)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath)
+
if err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
gr, err := git.Open(repoPath, data.Branch)
+
if err != nil {
+
fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err)))
+
return
+
}
+
+
err = gr.MergeCheck([]byte(data.Patch), data.Branch)
+
+
response := tangled.RepoMergeCheck_Output{
+
Is_conflicted: false,
+
}
+
+
if err != nil {
+
var mergeErr *git.ErrMerge
+
if errors.As(err, &mergeErr) {
+
response.Is_conflicted = true
+
+
conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts))
+
for i, conflict := range mergeErr.Conflicts {
+
conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{
+
Filename: conflict.Filename,
+
Reason: conflict.Reason,
+
}
+
}
+
response.Conflicts = conflicts
+
+
if mergeErr.Message != "" {
+
response.Message = &mergeErr.Message
+
}
+
} else {
+
response.Is_conflicted = true
+
errMsg := err.Error()
+
response.Error = &errMsg
+
}
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+31
knotserver/xrpc/owner.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+
owner := x.Config.Server.Owner
+
if owner == "" {
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.Owner_Output{
+
Owner: owner,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+80
knotserver/xrpc/repo_archive.go
···
+
package xrpc
+
+
import (
+
"compress/gzip"
+
"fmt"
+
"net/http"
+
"strings"
+
+
"github.com/go-git/go-git/v5/plumbing"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+
repo, repoPath, unescapedRef, err := x.parseStandardParams(r)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
format := r.URL.Query().Get("format")
+
if format == "" {
+
format = "tar.gz" // default
+
}
+
+
prefix := r.URL.Query().Get("prefix")
+
+
if format != "tar.gz" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("only tar.gz format is supported"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, unescapedRef)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
repoParts := strings.Split(repo, "/")
+
repoName := repoParts[len(repoParts)-1]
+
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
+
+
var archivePrefix string
+
if prefix != "" {
+
archivePrefix = prefix
+
} else {
+
archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename)
+
}
+
+
filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename)
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
w.Header().Set("Content-Type", "application/gzip")
+
+
gw := gzip.NewWriter(w)
+
defer gw.Close()
+
+
err = gr.WriteTar(gw, archivePrefix)
+
if err != nil {
+
// once we start writing to the body we can't report error anymore
+
// so we are only left with logging the error
+
x.Logger.Error("writing tar file", "error", err.Error())
+
return
+
}
+
+
err = gw.Flush()
+
if err != nil {
+
// once we start writing to the body we can't report error anymore
+
// so we are only left with logging the error
+
x.Logger.Error("flushing", "error", err.Error())
+
return
+
}
+
}
+151
knotserver/xrpc/repo_blob.go
···
+
package xrpc
+
+
import (
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"path/filepath"
+
"slices"
+
"strings"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
+
_, repoPath, ref, err := x.parseStandardParams(r)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
treePath := r.URL.Query().Get("path")
+
if treePath == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing path parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
raw := r.URL.Query().Get("raw") == "true"
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
contents, err := gr.RawContent(treePath)
+
if err != nil {
+
x.Logger.Error("file content", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("FileNotFound"),
+
xrpcerr.WithMessage("file not found at the specified path"),
+
), http.StatusNotFound)
+
return
+
}
+
+
mimeType := http.DetectContentType(contents)
+
+
if filepath.Ext(treePath) == ".svg" {
+
mimeType = "image/svg+xml"
+
}
+
+
if raw {
+
contentHash := sha256.Sum256(contents)
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
+
+
switch {
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
+
w.WriteHeader(http.StatusNotModified)
+
return
+
}
+
w.Header().Set("ETag", eTag)
+
w.Header().Set("Content-Type", mimeType)
+
+
case strings.HasPrefix(mimeType, "text/"):
+
w.Header().Set("Cache-Control", "public, no-cache")
+
// serve all text content as text/plain
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
+
case isTextualMimeType(mimeType):
+
// handle textual application types (json, xml, etc.) as text/plain
+
w.Header().Set("Cache-Control", "public, no-cache")
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+
+
default:
+
x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
+
), http.StatusForbidden)
+
return
+
}
+
w.Write(contents)
+
return
+
}
+
+
isTextual := func(mt string) bool {
+
return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
+
}
+
+
var content string
+
var encoding string
+
+
isBinary := !isTextual(mimeType)
+
+
if isBinary {
+
content = base64.StdEncoding.EncodeToString(contents)
+
encoding = "base64"
+
} else {
+
content = string(contents)
+
encoding = "utf-8"
+
}
+
+
response := tangled.RepoBlob_Output{
+
Ref: ref,
+
Path: treePath,
+
Content: content,
+
Encoding: &encoding,
+
Size: &[]int64{int64(len(contents))}[0],
+
IsBinary: &isBinary,
+
}
+
+
if mimeType != "" {
+
response.MimeType = &mimeType
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+
+
// isTextualMimeType returns true if the MIME type represents textual content
+
// that should be served as text/plain for security reasons
+
func isTextualMimeType(mimeType string) bool {
+
textualTypes := []string{
+
"application/json",
+
"application/xml",
+
"application/yaml",
+
"application/x-yaml",
+
"application/toml",
+
"application/javascript",
+
"application/ecmascript",
+
}
+
+
return slices.Contains(textualTypes, mimeType)
+
}
+96
knotserver/xrpc/repo_branch.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
name := r.URL.Query().Get("name")
+
if name == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing name parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
branchName, _ := url.PathUnescape(name)
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
ref, err := gr.Branch(branchName)
+
if err != nil {
+
x.Logger.Error("getting branch", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("BranchNotFound"),
+
xrpcerr.WithMessage("branch not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
commit, err := gr.Commit(ref.Hash())
+
if err != nil {
+
x.Logger.Error("getting commit object", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("BranchNotFound"),
+
xrpcerr.WithMessage("failed to get commit object"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
defaultBranch, err := gr.FindMainBranch()
+
isDefault := false
+
if err != nil {
+
x.Logger.Error("getting default branch", "error", err.Error())
+
} else if defaultBranch == branchName {
+
isDefault = true
+
}
+
+
response := tangled.RepoBranch_Output{
+
Name: ref.Name().Short(),
+
Hash: ref.Hash().String(),
+
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
+
IsDefault: &isDefault,
+
}
+
+
if commit.Message != "" {
+
response.Message = &commit.Message
+
}
+
+
response.Author = &tangled.RepoBranch_Signature{
+
Name: commit.Author.Name,
+
Email: commit.Author.Email,
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+72
knotserver/xrpc/repo_branches.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
cursor := r.URL.Query().Get("cursor")
+
+
// limit := 50 // default
+
// if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
// if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
// limit = l
+
// }
+
// }
+
+
limit := 500
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
branches, _ := gr.Branches()
+
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) {
+
offset = o
+
}
+
}
+
+
end := offset + limit
+
if end > len(branches) {
+
end = len(branches)
+
}
+
+
paginatedBranches := branches[offset:end]
+
+
// Create response using existing types.RepoBranchesResponse
+
response := types.RepoBranchesResponse{
+
Branches: paginatedBranches,
+
}
+
+
// Write JSON response directly
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+98
knotserver/xrpc/repo_compare.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/url"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
rev1Param := r.URL.Query().Get("rev1")
+
if rev1Param == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing rev1 parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rev2Param := r.URL.Query().Get("rev2")
+
if rev2Param == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing rev2 parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rev1, _ := url.PathUnescape(rev1Param)
+
rev2, _ := url.PathUnescape(rev2Param)
+
+
gr, err := git.PlainOpen(repoPath)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
commit1, err := gr.ResolveRevision(rev1)
+
if err != nil {
+
x.Logger.Error("error resolving revision 1", "msg", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RevisionNotFound"),
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)),
+
), http.StatusBadRequest)
+
return
+
}
+
+
commit2, err := gr.ResolveRevision(rev2)
+
if err != nil {
+
x.Logger.Error("error resolving revision 2", "msg", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RevisionNotFound"),
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)),
+
), http.StatusBadRequest)
+
return
+
}
+
+
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
+
if err != nil {
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("CompareError"),
+
xrpcerr.WithMessage("error comparing revisions"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
resp := types.RepoFormatPatchResponse{
+
Rev1: commit1.Hash.String(),
+
Rev2: commit2.Hash.String(),
+
FormatPatch: formatPatch,
+
Patch: rawPatch,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+65
knotserver/xrpc/repo_diff.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
ref, _ := url.QueryUnescape(refParam)
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
diff, err := gr.Diff()
+
if err != nil {
+
x.Logger.Error("getting diff", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("failed to generate diff"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
resp := types.RepoCommitResponse{
+
Ref: ref,
+
Diff: diff,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+54
knotserver/xrpc/repo_get_default_branch.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, "")
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
branch, err := gr.FindMainBranch()
+
if err != nil {
+
x.Logger.Error("getting default branch", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("failed to get default branch"),
+
), http.StatusInternalServerError)
+
return
+
}
+
+
response := tangled.RepoGetDefaultBranch_Output{
+
Name: branch,
+
Hash: "",
+
When: "1970-01-01T00:00:00.000Z",
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+93
knotserver/xrpc/repo_languages.go
···
+
package xrpc
+
+
import (
+
"context"
+
"encoding/json"
+
"math"
+
"net/http"
+
"net/url"
+
"time"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
refParam = "HEAD" // default
+
}
+
ref, _ := url.PathUnescape(refParam)
+
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
x.Logger.Error("opening repo", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
+
defer cancel()
+
+
sizes, err := gr.AnalyzeLanguages(ctx)
+
if err != nil {
+
x.Logger.Error("failed to analyze languages", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("failed to analyze repository languages"),
+
), http.StatusNoContent)
+
return
+
}
+
+
var apiLanguages []*tangled.RepoLanguages_Language
+
var totalSize int64
+
+
for _, size := range sizes {
+
totalSize += size
+
}
+
+
for name, size := range sizes {
+
percentagef64 := float64(size) / float64(totalSize) * 100
+
percentage := math.Round(percentagef64)
+
+
lang := &tangled.RepoLanguages_Language{
+
Name: name,
+
Size: size,
+
Percentage: int64(percentage),
+
}
+
+
apiLanguages = append(apiLanguages, lang)
+
}
+
+
response := tangled.RepoLanguages_Output{
+
Ref: ref,
+
Languages: apiLanguages,
+
}
+
+
if totalSize > 0 {
+
response.TotalSize = &totalSize
+
totalFiles := int64(len(sizes))
+
response.TotalFiles = &totalFiles
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+111
knotserver/xrpc/repo_log.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
"strconv"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
path := r.URL.Query().Get("path")
+
cursor := r.URL.Query().Get("cursor")
+
+
limit := 50 // default
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
limit = l
+
}
+
}
+
+
ref, err := url.QueryUnescape(refParam)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
+
offset = o
+
}
+
}
+
+
commits, err := gr.Commits(offset, limit)
+
if err != nil {
+
x.Logger.Error("fetching commits", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("PathNotFound"),
+
xrpcerr.WithMessage("failed to read commit log"),
+
), http.StatusNotFound)
+
return
+
}
+
+
total, err := gr.TotalCommits()
+
if err != nil {
+
x.Logger.Error("fetching total commits", "error", err.Error())
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to fetch total commits"),
+
), http.StatusNotFound)
+
return
+
}
+
+
// Create response using existing types.RepoLogResponse
+
response := types.RepoLogResponse{
+
Commits: commits,
+
Ref: ref,
+
Page: (offset / limit) + 1,
+
PerPage: limit,
+
Total: total,
+
}
+
+
if path != "" {
+
response.Description = path
+
}
+
+
response.Log = true
+
+
// Write JSON response directly
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+99
knotserver/xrpc/repo_tags.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/go-git/go-git/v5/plumbing/object"
+
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
"tangled.sh/tangled.sh/core/types"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
cursor := r.URL.Query().Get("cursor")
+
+
limit := 50 // default
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
+
limit = l
+
}
+
}
+
+
gr, err := git.Open(repoPath, "")
+
if err != nil {
+
x.Logger.Error("failed to open", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("repository not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
tags, err := gr.Tags()
+
if err != nil {
+
x.Logger.Warn("getting tags", "error", err.Error())
+
tags = []object.Tag{}
+
}
+
+
rtags := []*types.TagReference{}
+
for _, tag := range tags {
+
var target *object.Tag
+
if tag.Target != plumbing.ZeroHash {
+
target = &tag
+
}
+
tr := types.TagReference{
+
Tag: target,
+
}
+
+
tr.Reference = types.Reference{
+
Name: tag.Name,
+
Hash: tag.Hash.String(),
+
}
+
+
if tag.Message != "" {
+
tr.Message = tag.Message
+
}
+
+
rtags = append(rtags, &tr)
+
}
+
+
// apply pagination manually
+
offset := 0
+
if cursor != "" {
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) {
+
offset = o
+
}
+
}
+
+
// calculate end index
+
end := min(offset+limit, len(rtags))
+
+
paginatedTags := rtags[offset:end]
+
+
// Create response using existing types.RepoTagsResponse
+
response := types.RepoTagsResponse{
+
Tags: paginatedTags,
+
}
+
+
// Write JSON response directly
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+116
knotserver/xrpc/repo_tree.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"net/http"
+
"net/url"
+
"path/filepath"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/knotserver/git"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
+
ctx := r.Context()
+
+
repo := r.URL.Query().Get("repo")
+
repoPath, err := x.parseRepoParam(repo)
+
if err != nil {
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
+
return
+
}
+
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
path := r.URL.Query().Get("path")
+
// path can be empty (defaults to root)
+
+
ref, err := url.QueryUnescape(refParam)
+
if err != nil {
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid ref parameter"),
+
), http.StatusBadRequest)
+
return
+
}
+
+
gr, err := git.Open(repoPath, ref)
+
if err != nil {
+
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RefNotFound"),
+
xrpcerr.WithMessage("repository or ref not found"),
+
), http.StatusNotFound)
+
return
+
}
+
+
files, err := gr.FileTree(ctx, path)
+
if err != nil {
+
x.Logger.Error("failed to get file tree", "error", err, "path", path)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("PathNotFound"),
+
xrpcerr.WithMessage("failed to read repository tree"),
+
), http.StatusNotFound)
+
return
+
}
+
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
+
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
+
for i, file := range files {
+
entry := &tangled.RepoTree_TreeEntry{
+
Name: file.Name,
+
Mode: file.Mode,
+
Size: file.Size,
+
Is_file: file.IsFile,
+
Is_subtree: file.IsSubtree,
+
}
+
+
if file.LastCommit != nil {
+
entry.Last_commit = &tangled.RepoTree_LastCommit{
+
Hash: file.LastCommit.Hash.String(),
+
Message: file.LastCommit.Message,
+
When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"),
+
}
+
}
+
+
treeEntries[i] = entry
+
}
+
+
var parentPtr *string
+
if path != "" {
+
parentPtr = &path
+
}
+
+
var dotdotPtr *string
+
if path != "" {
+
dotdot := filepath.Dir(path)
+
if dotdot != "." {
+
dotdotPtr = &dotdot
+
}
+
}
+
+
response := tangled.RepoTree_Output{
+
Ref: ref,
+
Parent: parentPtr,
+
Dotdot: dotdotPtr,
+
Files: treeEntries,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
-149
knotserver/xrpc/router.go
···
-
package xrpc
-
-
import (
-
"context"
-
"encoding/json"
-
"fmt"
-
"log/slog"
-
"net/http"
-
"strings"
-
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/idresolver"
-
"tangled.sh/tangled.sh/core/jetstream"
-
"tangled.sh/tangled.sh/core/knotserver/config"
-
"tangled.sh/tangled.sh/core/knotserver/db"
-
"tangled.sh/tangled.sh/core/notifier"
-
"tangled.sh/tangled.sh/core/rbac"
-
-
"github.com/bluesky-social/indigo/atproto/auth"
-
"github.com/go-chi/chi/v5"
-
)
-
-
type Xrpc struct {
-
Config *config.Config
-
Db *db.DB
-
Ingester *jetstream.JetstreamClient
-
Enforcer *rbac.Enforcer
-
Logger *slog.Logger
-
Notifier *notifier.Notifier
-
Resolver *idresolver.Resolver
-
}
-
-
func (x *Xrpc) Router() http.Handler {
-
r := chi.NewRouter()
-
-
r.With(x.VerifyServiceAuth).Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
-
-
return r
-
}
-
-
func (x *Xrpc) VerifyServiceAuth(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
l := x.Logger.With("url", r.URL)
-
-
token := r.Header.Get("Authorization")
-
token = strings.TrimPrefix(token, "Bearer ")
-
-
s := auth.ServiceAuthValidator{
-
Audience: x.Config.Server.Did().String(),
-
Dir: x.Resolver.Directory(),
-
}
-
-
did, err := s.Validate(r.Context(), token, nil)
-
if err != nil {
-
l.Error("signature verification failed", "err", err)
-
writeError(w, AuthError(err), http.StatusForbidden)
-
return
-
}
-
-
r = r.WithContext(
-
context.WithValue(r.Context(), ActorDid, did),
-
)
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
-
type XrpcError struct {
-
Tag string `json:"error"`
-
Message string `json:"message"`
-
}
-
-
func NewXrpcError(opts ...ErrOpt) XrpcError {
-
x := XrpcError{}
-
for _, o := range opts {
-
o(&x)
-
}
-
-
return x
-
}
-
-
type ErrOpt = func(xerr *XrpcError)
-
-
func WithTag(tag string) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Tag = tag
-
}
-
}
-
-
func WithMessage[S ~string](s S) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Message = string(s)
-
}
-
}
-
-
func WithError(e error) ErrOpt {
-
return func(xerr *XrpcError) {
-
xerr.Message = e.Error()
-
}
-
}
-
-
var MissingActorDidError = NewXrpcError(
-
WithTag("MissingActorDid"),
-
WithMessage("actor DID not supplied"),
-
)
-
-
var AuthError = func(err error) XrpcError {
-
return NewXrpcError(
-
WithTag("Auth"),
-
WithError(fmt.Errorf("signature verification failed: %w", err)),
-
)
-
}
-
-
var InvalidRepoError = func(r string) XrpcError {
-
return NewXrpcError(
-
WithTag("InvalidRepo"),
-
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
-
)
-
}
-
-
var AccessControlError = func(d string) XrpcError {
-
return NewXrpcError(
-
WithTag("AccessControl"),
-
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
-
)
-
}
-
-
var GitError = func(e error) XrpcError {
-
return NewXrpcError(
-
WithTag("Git"),
-
WithError(fmt.Errorf("git error: %w", e)),
-
)
-
}
-
-
func GenericError(err error) XrpcError {
-
return NewXrpcError(
-
WithTag("InvalidRepo"),
-
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)
-
}
+14 -12
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)
+
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
}
-
w.WriteHeader(http.StatusNoContent)
+
w.WriteHeader(http.StatusOK)
}
+70
knotserver/xrpc/version.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"runtime/debug"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
// version is set during build time.
+
var version string
+
+
func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) {
+
if version == "" {
+
info, ok := debug.ReadBuildInfo()
+
if !ok {
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
+
return
+
}
+
+
var modVer string
+
var sha string
+
var modified bool
+
+
for _, mod := range info.Deps {
+
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
+
modVer = mod.Version
+
break
+
}
+
}
+
+
for _, setting := range info.Settings {
+
switch setting.Key {
+
case "vcs.revision":
+
sha = setting.Value
+
case "vcs.modified":
+
modified = setting.Value == "true"
+
}
+
}
+
+
if modVer == "" {
+
modVer = "unknown"
+
}
+
+
if sha == "" {
+
version = modVer
+
} else if modified {
+
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
+
} else {
+
version = fmt.Sprintf("%s (%s)", modVer, sha)
+
}
+
}
+
+
response := tangled.KnotVersion_Output{
+
Version: version,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
x.Logger.Error("failed to encode response", "error", err)
+
writeError(w, xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InternalServerError"),
+
xrpcerr.WithMessage("failed to encode response"),
+
), http.StatusInternalServerError)
+
return
+
}
+
}
+148
knotserver/xrpc/xrpc.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/idresolver"
+
"tangled.sh/tangled.sh/core/jetstream"
+
"tangled.sh/tangled.sh/core/knotserver/config"
+
"tangled.sh/tangled.sh/core/knotserver/db"
+
"tangled.sh/tangled.sh/core/notifier"
+
"tangled.sh/tangled.sh/core/rbac"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
type Xrpc struct {
+
Config *config.Config
+
Db *db.DB
+
Ingester *jetstream.JetstreamClient
+
Enforcer *rbac.Enforcer
+
Logger *slog.Logger
+
Notifier *notifier.Notifier
+
Resolver *idresolver.Resolver
+
ServiceAuth *serviceauth.ServiceAuth
+
}
+
+
func (x *Xrpc) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Group(func(r chi.Router) {
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
+
+
r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)
+
r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)
+
r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)
+
r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+
r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)
+
r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)
+
r.Post("/"+tangled.RepoMergeNSID, x.Merge)
+
})
+
+
// merge check is an open endpoint
+
//
+
// TODO: should we constrain this more?
+
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
+
// - use ETags on clients to keep requests to a minimum
+
r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
+
+
// repo query endpoints (no auth required)
+
r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
+
r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
+
r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
+
r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
+
r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
+
r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
+
r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
+
r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
+
r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
+
r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
+
r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
+
+
// knot query endpoints (no auth required)
+
r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
+
r.Get("/"+tangled.KnotVersionNSID, x.Version)
+
+
// service query endpoints (no auth required)
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
+
+
return r
+
}
+
+
// parseRepoParam parses a repo parameter in 'did/repoName' format and returns
+
// the full repository path on disk
+
func (x *Xrpc) parseRepoParam(repo string) (string, error) {
+
if repo == "" {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing repo parameter"),
+
)
+
}
+
+
// Parse repo string (did/repoName format)
+
parts := strings.Split(repo, "/")
+
if len(parts) < 2 {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
+
)
+
}
+
+
did := strings.Join(parts[:len(parts)-1], "/")
+
repoName := parts[len(parts)-1]
+
+
// Construct repository path using the same logic as didPath
+
didRepoPath, err := securejoin.SecureJoin(did, repoName)
+
if err != nil {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("failed to access repository"),
+
)
+
}
+
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
+
if err != nil {
+
return "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("RepoNotFound"),
+
xrpcerr.WithMessage("failed to access repository"),
+
)
+
}
+
+
return repoPath, nil
+
}
+
+
// parseStandardParams parses common query parameters used by most handlers
+
func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
+
// Parse repo parameter
+
repo = r.URL.Query().Get("repo")
+
repoPath, err = x.parseRepoParam(repo)
+
if err != nil {
+
return "", "", "", err
+
}
+
+
// Parse and unescape ref parameter
+
refParam := r.URL.Query().Get("ref")
+
if refParam == "" {
+
return "", "", "", xrpcerr.NewXrpcError(
+
xrpcerr.WithTag("InvalidRequest"),
+
xrpcerr.WithMessage("missing ref parameter"),
+
)
+
}
+
+
ref, _ = url.QueryUnescape(refParam)
+
return repo, repoPath, ref, nil
+
}
+
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(status)
+
json.NewEncoder(w).Encode(e)
+
}
+158
legal/privacy.md
···
+
# Privacy Policy
+
+
**Last updated:** January 15, 2025
+
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
+
collects, uses, and shares your personal information when you use our
+
platform and services (the "Service").
+
+
## 1. Information We Collect
+
+
### Account Information
+
+
When you create an account, we collect:
+
+
- Your chosen username
+
- Email address
+
- Profile information you choose to provide
+
- Authentication data
+
+
### Content and Activity
+
+
We store:
+
+
- Code repositories and associated metadata
+
- Issues, pull requests, and comments
+
- Activity logs and usage patterns
+
- Public keys for authentication
+
+
## 2. Data Location and Hosting
+
+
### EU Data Hosting
+
+
**All Tangled service data is hosted within the European Union.**
+
Specifically:
+
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
+
(*.tngl.sh) are located in Finland
+
- **Application Data:** All other service data is stored on EU-based
+
servers
+
- **Data Processing:** All data processing occurs within EU
+
jurisdiction
+
+
### External PDS Notice
+
+
**Important:** If your account is hosted on Bluesky's PDS or other
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
+
that data. The data protection, storage location, and privacy
+
practices for such accounts are governed by the respective PDS
+
provider's policies, not this Privacy Policy. We only control data
+
processing within our own services and infrastructure.
+
+
## 3. Third-Party Data Processors
+
+
We only share your data with the following third-party processors:
+
+
### Resend (Email Services)
+
+
- **Purpose:** Sending transactional emails (account verification,
+
notifications)
+
- **Data Shared:** Email address and necessary message content
+
+
### Cloudflare (Image Caching)
+
+
- **Purpose:** Caching and optimizing image delivery
+
- **Data Shared:** Public images and associated metadata for caching
+
purposes
+
+
### Posthog (Usage Metrics Tracking)
+
+
- **Purpose:** Tracking usage and platform metrics
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
+
information
+
+
## 4. How We Use Your Information
+
+
We use your information to:
+
+
- Provide and maintain the Service
+
- Process your transactions and requests
+
- Send you technical notices and support messages
+
- Improve and develop new features
+
- Ensure security and prevent fraud
+
- Comply with legal obligations
+
+
## 5. Data Sharing and Disclosure
+
+
We do not sell, trade, or rent your personal information. We may share
+
your information only in the following circumstances:
+
+
- With the third-party processors listed above
+
- When required by law or legal process
+
- To protect our rights, property, or safety, or that of our users
+
- In connection with a merger, acquisition, or sale of assets (with
+
appropriate protections)
+
+
## 6. Data Security
+
+
We implement appropriate technical and organizational measures to
+
protect your personal information against unauthorized access,
+
alteration, disclosure, or destruction. However, no method of
+
transmission over the Internet is 100% secure.
+
+
## 7. Data Retention
+
+
We retain your personal information for as long as necessary to provide
+
the Service and fulfill the purposes outlined in this Privacy Policy,
+
unless a longer retention period is required by law.
+
+
## 8. Your Rights
+
+
Under applicable data protection laws, you have the right to:
+
+
- Access your personal information
+
- Correct inaccurate information
+
- Request deletion of your information
+
- Object to processing of your information
+
- Data portability
+
- Withdraw consent (where applicable)
+
+
## 9. Cookies and Tracking
+
+
We use cookies and similar technologies to:
+
+
- Maintain your login session
+
- Remember your preferences
+
- Analyze usage patterns to improve the Service
+
+
You can control cookie settings through your browser preferences.
+
+
## 10. Children's Privacy
+
+
The Service is not intended for children under 16 years of age. We do
+
not knowingly collect personal information from children under 16. If
+
we become aware that we have collected such information, we will take
+
steps to delete it.
+
+
## 11. International Data Transfers
+
+
While all our primary data processing occurs within the EU, some of our
+
third-party processors may process data outside the EU. When this
+
occurs, we ensure appropriate safeguards are in place, such as Standard
+
Contractual Clauses or adequacy decisions.
+
+
## 12. Changes to This Privacy Policy
+
+
We may update this Privacy Policy from time to time. We will notify you
+
of any changes by posting the new Privacy Policy on this page and
+
updating the "Last updated" date.
+
+
## 13. Contact Information
+
+
If you have any questions about this Privacy Policy or wish to exercise
+
your rights, please contact us through our platform or via email.
+
+
---
+
+
This Privacy Policy complies with the EU General Data Protection
+
Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
···
+
# Terms of Service
+
+
**Last updated:** January 15, 2025
+
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
+
to and use of the Tangled platform and services (the "Service")
+
operated by us ("Tangled," "we," "us," or "our").
+
+
## 1. Acceptance of Terms
+
+
By accessing or using our Service, you agree to be bound by these Terms.
+
If you disagree with any part of these terms, then you may not access
+
the Service.
+
+
## 2. Account Registration
+
+
To use certain features of the Service, you must register for an
+
account. You agree to provide accurate, current, and complete
+
information during the registration process and to update such
+
information to keep it accurate, current, and complete.
+
+
## 3. Account Termination
+
+
> **Important Notice**
+
>
+
> **We reserve the right to terminate, suspend, or restrict access to
+
> your account at any time, for any reason, or for no reason at all, at
+
> our sole discretion.** This includes, but is not limited to,
+
> termination for violation of these Terms, inappropriate conduct, spam,
+
> abuse, or any other behavior we deem harmful to the Service or other
+
> users.
+
>
+
> Account termination may result in the loss of access to your
+
> repositories, data, and other content associated with your account. We
+
> are not obligated to provide advance notice of termination, though we
+
> may do so in our discretion.
+
+
## 4. Acceptable Use
+
+
You agree not to use the Service to:
+
+
- Violate any applicable laws or regulations
+
- Infringe upon the rights of others
+
- Upload, store, or share content that is illegal, harmful, threatening,
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
+
objectionable
+
- Engage in spam, phishing, or other deceptive practices
+
- Attempt to gain unauthorized access to the Service or other users'
+
accounts
+
- Interfere with or disrupt the Service or servers connected to the
+
Service
+
+
## 5. Content and Intellectual Property
+
+
You retain ownership of the content you upload to the Service. By
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
+
license to use, reproduce, modify, and distribute your content as
+
necessary to provide the Service.
+
+
## 6. Privacy
+
+
Your privacy is important to us. Please review our [Privacy
+
Policy](/privacy), which also governs your use of the Service.
+
+
## 7. Disclaimers
+
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
+
no warranties, expressed or implied, and hereby disclaim and negate all
+
other warranties including without limitation, implied warranties or
+
conditions of merchantability, fitness for a particular purpose, or
+
non-infringement of intellectual property or other violation of rights.
+
+
## 8. Limitation of Liability
+
+
In no event shall Tangled, nor its directors, employees, partners,
+
agents, suppliers, or affiliates, be liable for any indirect,
+
incidental, special, consequential, or punitive damages, including
+
without limitation, loss of profits, data, use, goodwill, or other
+
intangible losses, resulting from your use of the Service.
+
+
## 9. Indemnification
+
+
You agree to defend, indemnify, and hold harmless Tangled and its
+
affiliates, officers, directors, employees, and agents from and against
+
any and all claims, damages, obligations, losses, liabilities, costs,
+
or debt, and expenses (including attorney's fees).
+
+
## 10. Governing Law
+
+
These Terms shall be interpreted and governed by the laws of Finland,
+
without regard to its conflict of law provisions.
+
+
## 11. Changes to Terms
+
+
We reserve the right to modify or replace these Terms at any time. If a
+
revision is material, we will try to provide at least 30 days notice
+
prior to any new terms taking effect.
+
+
## 12. Contact Information
+
+
If you have any questions about these Terms of Service, please contact
+
us through our platform or via email.
+
+
---
+
+
These terms are effective as of the last updated date shown above and
+
will remain in effect except with respect to any changes in their
+
provisions in the future, which will be in effect immediately after
+
being posted on this page.
-52
lexicons/artifact.json
···
-
{
-
"lexicon": 1,
-
"id": "sh.tangled.repo.artifact",
-
"needsCbor": true,
-
"needsType": true,
-
"defs": {
-
"main": {
-
"type": "record",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": [
-
"name",
-
"repo",
-
"tag",
-
"createdAt",
-
"artifact"
-
],
-
"properties": {
-
"name": {
-
"type": "string",
-
"description": "name of the artifact"
-
},
-
"repo": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "repo that this artifact is being uploaded to"
-
},
-
"tag": {
-
"type": "bytes",
-
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
-
"minLength": 20,
-
"maxLength": 20
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "time of creation of this artifact"
-
},
-
"artifact": {
-
"type": "blob",
-
"description": "the artifact",
-
"accept": [
-
"*/*"
-
],
-
"maxSize": 52428800
-
}
-
}
-
}
-
}
-
}
-
}
-29
lexicons/defaultBranch.json
···
-
{
-
"lexicon": 1,
-
"id": "sh.tangled.repo.setDefaultBranch",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Set the default branch for a repository",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": [
-
"repo",
-
"defaultBranch"
-
],
-
"properties": {
-
"repo": {
-
"type": "string",
-
"format": "at-uri"
-
},
-
"defaultBranch": {
-
"type": "string"
-
}
-
}
-
}
-
}
-
}
-
}
-
}
+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"
+
}
+
]
+
}
+
}
+
}
+207
lexicons/pipeline/pipeline.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.pipeline",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"triggerMetadata",
+
"workflows"
+
],
+
"properties": {
+
"triggerMetadata": {
+
"type": "ref",
+
"ref": "#triggerMetadata"
+
},
+
"workflows": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#workflow"
+
}
+
}
+
}
+
}
+
},
+
"triggerMetadata": {
+
"type": "object",
+
"required": [
+
"kind",
+
"repo"
+
],
+
"properties": {
+
"kind": {
+
"type": "string",
+
"enum": [
+
"push",
+
"pull_request",
+
"manual"
+
]
+
},
+
"repo": {
+
"type": "ref",
+
"ref": "#triggerRepo"
+
},
+
"push": {
+
"type": "ref",
+
"ref": "#pushTriggerData"
+
},
+
"pullRequest": {
+
"type": "ref",
+
"ref": "#pullRequestTriggerData"
+
},
+
"manual": {
+
"type": "ref",
+
"ref": "#manualTriggerData"
+
}
+
}
+
},
+
"triggerRepo": {
+
"type": "object",
+
"required": [
+
"knot",
+
"did",
+
"repo",
+
"defaultBranch"
+
],
+
"properties": {
+
"knot": {
+
"type": "string"
+
},
+
"did": {
+
"type": "string",
+
"format": "did"
+
},
+
"repo": {
+
"type": "string"
+
},
+
"defaultBranch": {
+
"type": "string"
+
}
+
}
+
},
+
"pushTriggerData": {
+
"type": "object",
+
"required": [
+
"ref",
+
"newSha",
+
"oldSha"
+
],
+
"properties": {
+
"ref": {
+
"type": "string"
+
},
+
"newSha": {
+
"type": "string",
+
"minLength": 40,
+
"maxLength": 40
+
},
+
"oldSha": {
+
"type": "string",
+
"minLength": 40,
+
"maxLength": 40
+
}
+
}
+
},
+
"pullRequestTriggerData": {
+
"type": "object",
+
"required": [
+
"sourceBranch",
+
"targetBranch",
+
"sourceSha",
+
"action"
+
],
+
"properties": {
+
"sourceBranch": {
+
"type": "string"
+
},
+
"targetBranch": {
+
"type": "string"
+
},
+
"sourceSha": {
+
"type": "string",
+
"minLength": 40,
+
"maxLength": 40
+
},
+
"action": {
+
"type": "string"
+
}
+
}
+
},
+
"manualTriggerData": {
+
"type": "object",
+
"properties": {
+
"inputs": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#pair"
+
}
+
}
+
}
+
},
+
"workflow": {
+
"type": "object",
+
"required": [
+
"name",
+
"engine",
+
"clone",
+
"raw"
+
],
+
"properties": {
+
"name": {
+
"type": "string"
+
},
+
"engine": {
+
"type": "string"
+
},
+
"clone": {
+
"type": "ref",
+
"ref": "#cloneOpts"
+
},
+
"raw": {
+
"type": "string"
+
}
+
}
+
},
+
"cloneOpts": {
+
"type": "object",
+
"required": [
+
"skip",
+
"depth",
+
"submodules"
+
],
+
"properties": {
+
"skip": {
+
"type": "boolean"
+
},
+
"depth": {
+
"type": "integer"
+
},
+
"submodules": {
+
"type": "boolean"
+
}
+
}
+
},
+
"pair": {
+
"type": "object",
+
"required": [
+
"key",
+
"value"
+
],
+
"properties": {
+
"key": {
+
"type": "string"
+
},
+
"value": {
+
"type": "string"
+
}
+
}
+
}
+
}
+
}
-263
lexicons/pipeline.json
···
-
{
-
"lexicon": 1,
-
"id": "sh.tangled.pipeline",
-
"needsCbor": true,
-
"needsType": true,
-
"defs": {
-
"main": {
-
"type": "record",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": [
-
"triggerMetadata",
-
"workflows"
-
],
-
"properties": {
-
"triggerMetadata": {
-
"type": "ref",
-
"ref": "#triggerMetadata"
-
},
-
"workflows": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#workflow"
-
}
-
}
-
}
-
}
-
},
-
"triggerMetadata": {
-
"type": "object",
-
"required": [
-
"kind",
-
"repo"
-
],
-
"properties": {
-
"kind": {
-
"type": "string",
-
"enum": [
-
"push",
-
"pull_request",
-
"manual"
-
]
-
},
-
"repo": {
-
"type": "ref",
-
"ref": "#triggerRepo"
-
},
-
"push": {
-
"type": "ref",
-
"ref": "#pushTriggerData"
-
},
-
"pullRequest": {
-
"type": "ref",
-
"ref": "#pullRequestTriggerData"
-
},
-
"manual": {
-
"type": "ref",
-
"ref": "#manualTriggerData"
-
}
-
}
-
},
-
"triggerRepo": {
-
"type": "object",
-
"required": [
-
"knot",
-
"did",
-
"repo",
-
"defaultBranch"
-
],
-
"properties": {
-
"knot": {
-
"type": "string"
-
},
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"repo": {
-
"type": "string"
-
},
-
"defaultBranch": {
-
"type": "string"
-
}
-
}
-
},
-
"pushTriggerData": {
-
"type": "object",
-
"required": [
-
"ref",
-
"newSha",
-
"oldSha"
-
],
-
"properties": {
-
"ref": {
-
"type": "string"
-
},
-
"newSha": {
-
"type": "string",
-
"minLength": 40,
-
"maxLength": 40
-
},
-
"oldSha": {
-
"type": "string",
-
"minLength": 40,
-
"maxLength": 40
-
}
-
}
-
},
-
"pullRequestTriggerData": {
-
"type": "object",
-
"required": [
-
"sourceBranch",
-
"targetBranch",
-
"sourceSha",
-
"action"
-
],
-
"properties": {
-
"sourceBranch": {
-
"type": "string"
-
},
-
"targetBranch": {
-
"type": "string"
-
},
-
"sourceSha": {
-
"type": "string",
-
"minLength": 40,
-
"maxLength": 40
-
},
-
"action": {
-
"type": "string"
-
}
-
}
-
},
-
"manualTriggerData": {
-
"type": "object",
-
"properties": {
-
"inputs": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#pair"
-
}
-
}
-
}
-
},
-
"workflow": {
-
"type": "object",
-
"required": [
-
"name",
-
"dependencies",
-
"steps",
-
"environment",
-
"clone"
-
],
-
"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"
-
}
-
},
-
"clone": {
-
"type": "ref",
-
"ref": "#cloneOpts"
-
}
-
}
-
},
-
"dependency": {
-
"type": "object",
-
"required": [
-
"registry",
-
"packages"
-
],
-
"properties": {
-
"registry": {
-
"type": "string"
-
},
-
"packages": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
}
-
}
-
}
-
},
-
"cloneOpts": {
-
"type": "object",
-
"required": [
-
"skip",
-
"depth",
-
"submodules"
-
],
-
"properties": {
-
"skip": {
-
"type": "boolean"
-
},
-
"depth": {
-
"type": "integer"
-
},
-
"submodules": {
-
"type": "boolean"
-
}
-
}
-
},
-
"step": {
-
"type": "object",
-
"required": [
-
"name",
-
"command"
-
],
-
"properties": {
-
"name": {
-
"type": "string"
-
},
-
"command": {
-
"type": "string"
-
},
-
"environment": {
-
"type": "array",
-
"items": {
-
"type": "ref",
-
"ref": "#pair"
-
}
-
}
-
}
-
},
-
"pair": {
-
"type": "object",
-
"required": [
-
"key",
-
"value"
-
],
-
"properties": {
-
"key": {
-
"type": "string"
-
},
-
"value": {
-
"type": "string"
-
}
-
}
-
}
-
}
-
}
-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"
}
}
},
+37
lexicons/repo/addSecret.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.addSecret",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Add a CI secret",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"key",
+
"value"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 50,
+
"minLength": 1
+
},
+
"value": {
+
"type": "string",
+
"maxLength": 200,
+
"minLength": 1
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
]
+
}
+
}
+
}
+52
lexicons/repo/artifact.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.artifact",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"name",
+
"repo",
+
"tag",
+
"createdAt",
+
"artifact"
+
],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "name of the artifact"
+
},
+
"repo": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "repo that this artifact is being uploaded to"
+
},
+
"tag": {
+
"type": "bytes",
+
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
+
"minLength": 20,
+
"maxLength": 20
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "time of creation of this artifact"
+
},
+
"artifact": {
+
"type": "blob",
+
"description": "the artifact",
+
"accept": [
+
"*/*"
+
],
+
"maxSize": 52428800
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
]
+
}
+
}
+
}
+36
lexicons/repo/collaborator.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.collaborator",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"subject",
+
"repo",
+
"createdAt"
+
],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "did"
+
},
+
"repo": {
+
"type": "string",
+
"description": "repo to add this user to",
+
"format": "at-uri"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+
+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."
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+29
lexicons/repo/defaultBranch.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.setDefaultBranch",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Set the default branch for a repository",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"defaultBranch"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"defaultBranch": {
+
"type": "string"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
}
+
}
+
}
+
}
+67
lexicons/repo/listSecrets.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.listSecrets",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"required": [
+
"repo"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"secrets"
+
],
+
"properties": {
+
"secrets": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "#secret"
+
}
+
}
+
}
+
}
+
}
+
},
+
"secret": {
+
"type": "object",
+
"required": [
+
"repo",
+
"key",
+
"createdAt",
+
"createdBy"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 50,
+
"minLength": 1
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"createdBy": {
+
"type": "string",
+
"format": "did"
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
}
+
}
+
}
+
}
+31
lexicons/repo/removeSecret.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.removeSecret",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Remove a CI secret",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": [
+
"repo",
+
"key"
+
],
+
"properties": {
+
"repo": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"key": {
+
"type": "string",
+
"maxLength": 50,
+
"minLength": 1
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+53
lexicons/repo/repo.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"name",
+
"knot",
+
"owner",
+
"createdAt"
+
],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "name of the repo"
+
},
+
"owner": {
+
"type": "string",
+
"format": "did"
+
},
+
"knot": {
+
"type": "string",
+
"description": "knot where the repo was created"
+
},
+
"spindle": {
+
"type": "string",
+
"description": "CI runner to send jobs to and receive results from"
+
},
+
"description": {
+
"type": "string",
+
"minGraphemes": 1,
+
"maxGraphemes": 140
+
},
+
"source": {
+
"type": "string",
+
"format": "uri",
+
"description": "source of the repo"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+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"
+
}
+
}
+
}
+
}
+
}
-54
lexicons/repo.json
···
-
{
-
"lexicon": 1,
-
"id": "sh.tangled.repo",
-
"needsCbor": true,
-
"needsType": true,
-
"defs": {
-
"main": {
-
"type": "record",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": [
-
"name",
-
"knot",
-
"owner",
-
"createdAt"
-
],
-
"properties": {
-
"name": {
-
"type": "string",
-
"description": "name of the repo"
-
},
-
"owner": {
-
"type": "string",
-
"format": "did"
-
},
-
"knot": {
-
"type": "string",
-
"description": "knot where the repo was created"
-
},
-
"spindle": {
-
"type": "string",
-
"description": "CI runner to send jobs to and receive results from"
-
},
-
"description": {
-
"type": "string",
-
"format": "datetime",
-
"minGraphemes": 1,
-
"maxGraphemes": 140
-
},
-
"source": {
-
"type": "string",
-
"format": "uri",
-
"description": "source of the repo"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-
}
+25
lexicons/spindle/spindle.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.spindle",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": [
+
"createdAt"
+
],
+
"properties": {
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+
-25
lexicons/spindle.json
···
-
{
-
"lexicon": 1,
-
"id": "sh.tangled.spindle",
-
"needsCbor": true,
-
"needsType": true,
-
"defs": {
-
"main": {
-
"type": "record",
-
"key": "any",
-
"record": {
-
"type": "object",
-
"required": [
-
"createdAt"
-
],
-
"properties": {
-
"createdAt": {
-
"type": "string",
-
"format": "datetime"
-
}
-
}
-
}
-
}
-
}
-
}
-
+40
lexicons/string/string.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.string",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"filename",
+
"description",
+
"createdAt",
+
"contents"
+
],
+
"properties": {
+
"filename": {
+
"type": "string",
+
"maxGraphemes": 140,
+
"minGraphemes": 1
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 280
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"contents": {
+
"type": "string",
+
"minGraphemes": 1
+
}
+
}
+
}
+
}
+
}
+
}
+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)})
+139 -61
nix/gomod2nix.toml
···
version = "v0.6.2"
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
[mod."github.com/ProtonMail/go-crypto"]
-
version = "v1.2.0"
-
hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo="
+
version = "v1.3.0"
+
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
+
[mod."github.com/alecthomas/assert/v2"]
+
version = "v2.11.0"
+
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
[mod."github.com/alecthomas/chroma/v2"]
version = "v2.19.0"
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
replaced = "github.com/oppiliappan/chroma/v2"
+
[mod."github.com/alecthomas/repr"]
+
version = "v0.4.0"
+
hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU="
[mod."github.com/anmitsu/go-shlex"]
version = "v0.0.0-20200514113438-38f4b401e2be"
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
···
hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ="
replaced = "tangled.sh/oppi.li/go-gitdiff"
[mod."github.com/bluesky-social/indigo"]
-
version = "v0.0.0-20250520232546-236dd575c91e"
-
hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0="
+
version = "v0.0.0-20250724221105-5827c8fb61bb"
+
hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI="
[mod."github.com/bluesky-social/jetstream"]
version = "v0.0.0-20241210005130-ea96859b93d1"
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
···
[mod."github.com/casbin/govaluate"]
version = "v1.3.0"
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
+
[mod."github.com/cenkalti/backoff/v4"]
+
version = "v4.3.0"
+
hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8="
[mod."github.com/cespare/xxhash/v2"]
version = "v2.3.0"
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
[mod."github.com/cloudflare/circl"]
-
version = "v1.6.0"
-
hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0="
+
version = "v1.6.2-0.20250618153321-aa837fd1539d"
+
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
+
[mod."github.com/cloudflare/cloudflare-go"]
+
version = "v0.115.0"
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
[mod."github.com/containerd/errdefs"]
version = "v1.0.0"
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
[mod."github.com/felixge/httpsnoop"]
version = "v1.0.4"
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
+
[mod."github.com/fsnotify/fsnotify"]
+
version = "v1.6.0"
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
[mod."github.com/gliderlabs/ssh"]
version = "v0.3.8"
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
version = "v5.17.0"
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
replaced = "github.com/oppiliappan/go-git/v5"
+
[mod."github.com/go-jose/go-jose/v3"]
+
version = "v3.0.4"
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
[mod."github.com/go-logr/logr"]
-
version = "v1.4.2"
-
hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI="
+
version = "v1.4.3"
+
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
[mod."github.com/go-logr/stdr"]
version = "v1.2.2"
hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE="
[mod."github.com/go-redis/cache/v9"]
version = "v9.0.0"
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
+
[mod."github.com/go-test/deep"]
+
version = "v1.1.1"
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
[mod."github.com/goccy/go-json"]
version = "v0.10.5"
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
version = "v1.3.2"
hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c="
[mod."github.com/golang-jwt/jwt/v5"]
-
version = "v5.2.2"
-
hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4="
+
version = "v5.2.3"
+
hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo="
[mod."github.com/golang/groupcache"]
version = "v0.0.0-20241129210726-2c02b8208cf8"
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
+
[mod."github.com/golang/mock"]
+
version = "v1.6.0"
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
+
[mod."github.com/google/go-querystring"]
+
version = "v1.1.0"
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
[mod."github.com/google/uuid"]
version = "v1.6.0"
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
[mod."github.com/gorilla/css"]
version = "v1.0.1"
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
+
[mod."github.com/gorilla/feeds"]
+
version = "v1.2.0"
+
hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk="
[mod."github.com/gorilla/securecookie"]
version = "v1.1.2"
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
···
version = "v1.4.0"
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
[mod."github.com/gorilla/websocket"]
-
version = "v1.5.3"
-
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
+
[mod."github.com/hashicorp/errwrap"]
+
version = "v1.1.0"
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
[mod."github.com/hashicorp/go-cleanhttp"]
version = "v0.5.2"
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
+
[mod."github.com/hashicorp/go-multierror"]
+
version = "v1.1.1"
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
[mod."github.com/hashicorp/go-retryablehttp"]
-
version = "v0.7.7"
-
hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU="
+
version = "v0.7.8"
+
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
+
version = "v0.2.0"
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
+
version = "v0.1.2"
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
+
[mod."github.com/hashicorp/go-sockaddr"]
+
version = "v1.0.7"
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
[mod."github.com/hashicorp/golang-lru"]
version = "v1.0.2"
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
[mod."github.com/hashicorp/golang-lru/v2"]
version = "v2.0.7"
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
+
[mod."github.com/hashicorp/hcl"]
+
version = "v1.0.1-vault-7"
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
+
[mod."github.com/hexops/gotextdiff"]
+
version = "v1.0.3"
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
[mod."github.com/hiddeco/sshsig"]
version = "v0.2.0"
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
version = "v0.0.4"
hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU="
[mod."github.com/ipfs/boxo"]
-
version = "v0.30.0"
-
hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848="
+
version = "v0.33.0"
+
hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38="
[mod."github.com/ipfs/go-block-format"]
-
version = "v0.2.1"
-
hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8="
+
version = "v0.2.2"
+
hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU="
[mod."github.com/ipfs/go-cid"]
version = "v0.5.0"
hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk="
···
version = "v1.1.1"
hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY="
[mod."github.com/ipfs/go-ipld-cbor"]
-
version = "v0.2.0"
-
hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc="
+
version = "v0.2.1"
+
hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4="
[mod."github.com/ipfs/go-ipld-format"]
-
version = "v0.6.1"
-
hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4="
+
version = "v0.6.2"
+
hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU="
[mod."github.com/ipfs/go-log"]
version = "v1.0.5"
hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4="
···
version = "v1.18.0"
hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk="
[mod."github.com/klauspost/cpuid/v2"]
-
version = "v2.2.10"
-
hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0="
+
version = "v2.3.0"
+
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
[mod."github.com/lestrrat-go/blackmagic"]
-
version = "v1.0.3"
-
hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw="
+
version = "v1.0.4"
+
hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8="
[mod."github.com/lestrrat-go/httpcc"]
version = "v1.0.1"
hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos="
···
[mod."github.com/minio/sha256-simd"]
version = "v1.0.1"
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
+
[mod."github.com/mitchellh/mapstructure"]
+
version = "v1.5.0"
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
[mod."github.com/moby/docker-image-spec"]
version = "v1.3.1"
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
[mod."github.com/munnerz/goautoneg"]
version = "v0.0.0-20191010083416-a7dc8b61c822"
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
+
[mod."github.com/onsi/gomega"]
+
version = "v1.37.0"
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
+
[mod."github.com/openbao/openbao/api/v2"]
+
version = "v2.3.0"
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
[mod."github.com/opencontainers/go-digest"]
version = "v1.0.0"
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
version = "v1.1.1"
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
[mod."github.com/opentracing/opentracing-go"]
-
version = "v1.2.0"
-
hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM="
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
[mod."github.com/pjbgf/sha1cd"]
version = "v0.3.2"
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
version = "v0.6.2"
hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ="
[mod."github.com/prometheus/common"]
-
version = "v0.63.0"
-
hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE="
+
version = "v0.64.0"
+
hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI="
[mod."github.com/prometheus/procfs"]
version = "v0.16.1"
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
[mod."github.com/redis/go-redis/v9"]
-
version = "v9.3.0"
-
hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w="
+
version = "v9.7.3"
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
[mod."github.com/resend/resend-go/v2"]
version = "v2.15.0"
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
+
[mod."github.com/ryanuber/go-glob"]
+
version = "v1.0.0"
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
[mod."github.com/segmentio/asm"]
version = "v1.2.0"
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
[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="
···
version = "v1.1.0"
hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo="
[mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"]
-
version = "v0.61.0"
-
hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM="
+
version = "v0.62.0"
+
hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc="
[mod."go.opentelemetry.io/otel"]
-
version = "v1.36.0"
-
hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko="
+
version = "v1.37.0"
+
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
+
version = "v1.33.0"
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
[mod."go.opentelemetry.io/otel/metric"]
-
version = "v1.36.0"
-
hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8="
+
version = "v1.37.0"
+
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
[mod."go.opentelemetry.io/otel/trace"]
-
version = "v1.36.0"
-
hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA="
+
version = "v1.37.0"
+
hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY="
[mod."go.opentelemetry.io/proto/otlp"]
version = "v1.6.0"
hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg="
···
version = "v1.27.0"
hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU="
[mod."golang.org/x/crypto"]
-
version = "v0.38.0"
-
hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY="
+
version = "v0.40.0"
+
hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng="
[mod."golang.org/x/exp"]
-
version = "v0.0.0-20250408133849-7e4ce0ab07d0"
-
hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8="
+
version = "v0.0.0-20250620022241-b7579e27df2b"
+
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
[mod."golang.org/x/net"]
-
version = "v0.40.0"
-
hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8="
+
version = "v0.42.0"
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
[mod."golang.org/x/sync"]
-
version = "v0.14.0"
-
hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4="
+
version = "v0.16.0"
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
[mod."golang.org/x/sys"]
-
version = "v0.33.0"
-
hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ="
+
version = "v0.34.0"
+
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
+
[mod."golang.org/x/text"]
+
version = "v0.27.0"
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
[mod."golang.org/x/time"]
-
version = "v0.8.0"
-
hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ="
+
version = "v0.12.0"
+
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
[mod."golang.org/x/xerrors"]
version = "v0.0.0-20240903120638-7835f813f4da"
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
[mod."google.golang.org/genproto/googleapis/api"]
-
version = "v0.0.0-20250519155744-55703ea1f237"
-
hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ="
+
version = "v0.0.0-20250603155806-513f23925822"
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
[mod."google.golang.org/genproto/googleapis/rpc"]
-
version = "v0.0.0-20250519155744-55703ea1f237"
+
version = "v0.0.0-20250603155806-513f23925822"
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
[mod."google.golang.org/grpc"]
-
version = "v1.72.1"
-
hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs="
+
version = "v1.73.0"
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
[mod."google.golang.org/protobuf"]
version = "v1.36.6"
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
···
version = "v1.4.1"
hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="
[mod."tangled.sh/icyphox.sh/atproto-oauth"]
-
version = "v0.0.0-20250526154904-3906c5336421"
-
hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM="
+
version = "v0.0.0-20250724194903-28e660378cb1"
+
hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+14
nix/modules/appview.nix
···
default = "00000000000000000000000000000000";
description = "Cookie secret";
};
+
environmentFile = mkOption {
+
type = with types; nullOr path;
+
default = null;
+
example = "/etc/tangled-appview.env";
+
description = ''
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
+
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
+
passed to the service without makeing them world readable in the
+
nix store.
+
+
'';
+
};
};
};
···
ListenStream = "0.0.0.0:${toString cfg.port}";
ExecStart = "${cfg.package}/bin/appview";
Restart = "always";
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
};
environment = {
+54 -18
nix/modules/knot.nix
···
};
};
+
motd = mkOption {
+
type = types.nullOr types.str;
+
default = null;
+
description = ''
+
Message of the day
+
+
The contents are shown as-is; eg. you will want to add a newline if
+
setting a non-empty message since the knot won't do this for you.
+
'';
+
};
+
+
motdFile = mkOption {
+
type = types.nullOr types.path;
+
default = null;
+
description = ''
+
File containing message of the day
+
+
The contents are shown as-is; eg. you will want to add a newline if
+
setting a non-empty message since the knot won't do this for you.
+
'';
+
};
+
server = {
listenAddr = mkOption {
type = types.str;
···
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 = ''
-
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
-
EOF
-
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";
};
+40 -2
nix/modules/spindle.nix
···
example = "did:plc:qfpnj4og54vl56wngdriaxug";
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;
+
default = "sqlite";
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
+
};
+
+
openbao = {
+
proxyAddr = mkOption {
+
type = types.str;
+
default = "http://127.0.0.1:8200";
+
};
+
mount = mkOption {
+
type = types.str;
+
default = "spindle";
+
};
+
};
+
};
};
pipelines = {
···
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
-
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
-
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
+
"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_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
+
"SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
];
ExecStart = "${cfg.package}/bin/spindle";
Restart = "always";
+29
nix/pkgs/appview-static-files.nix
···
+
{
+
runCommandLocal,
+
htmx-src,
+
htmx-ws-src,
+
lucide-src,
+
inter-fonts-src,
+
ibm-plex-mono-src,
+
sqlite-lib,
+
tailwindcss,
+
src,
+
}:
+
runCommandLocal "appview-static-files" {
+
# TOOD(winter): figure out why this is even required after
+
# changing the libraries that the tailwindcss binary loads
+
sandboxProfile = ''
+
(allow file-read* (subpath "/System/Library/OpenSSL"))
+
'';
+
} ''
+
mkdir -p $out/{fonts,icons} && cd $out
+
cp -f ${htmx-src} htmx.min.js
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
+
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*.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
+
''
+5 -17
nix/pkgs/appview.nix
···
{
buildGoApplication,
modules,
-
htmx-src,
-
htmx-ws-src,
-
lucide-src,
-
inter-fonts-src,
-
ibm-plex-mono-src,
-
tailwindcss,
+
appview-static-files,
sqlite-lib,
-
gitignoreSource,
+
src,
}:
buildGoApplication {
pname = "appview";
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
+
inherit src modules;
postUnpack = ''
pushd source
-
mkdir -p appview/pages/static/{fonts,icons}
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
+
mkdir -p appview/pages/static
+
cp -frv ${appview-static-files}/* appview/pages/static
popd
'';
+7 -3
nix/pkgs/genjwks.nix
···
{
-
gitignoreSource,
buildGoApplication,
modules,
}:
buildGoApplication {
pname = "genjwks";
version = "0.1.0";
-
src = gitignoreSource ../..;
+
src = ../../cmd/genjwks;
+
postPatch = ''
+
ln -s ${../../go.mod} ./go.mod
+
'';
+
postInstall = ''
+
mv $out/bin/core $out/bin/genjwks
+
'';
inherit modules;
-
subPackages = ["cmd/genjwks"];
doCheck = false;
CGO_ENABLED = 0;
}
+18 -14
nix/pkgs/knot-unwrapped.nix
···
buildGoApplication,
modules,
sqlite-lib,
-
gitignoreSource,
-
}:
-
buildGoApplication {
-
pname = "knot";
-
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
+
src,
+
}: 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"];
-
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
-
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
-
CGO_ENABLED = 1;
-
}
+
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;
+
}
+1 -1
nix/pkgs/lexgen.nix
···
version = "0.1.0";
src = indigo;
subPackages = ["cmd/lexgen"];
-
vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs=";
+
vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw=";
doCheck = false;
}
+2 -3
nix/pkgs/spindle.nix
···
buildGoApplication,
modules,
sqlite-lib,
-
gitignoreSource,
+
src,
}:
buildGoApplication {
pname = "spindle";
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
+
inherit src modules;
doCheck = false;
+123 -63
nix/vm.nix
···
{
nixpkgs,
+
system,
+
hostSystem,
self,
-
}:
-
nixpkgs.lib.nixosSystem {
-
system = "x86_64-linux";
-
modules = [
-
self.nixosModules.knot
-
self.nixosModules.spindle
-
({
-
config,
-
pkgs,
-
...
-
}: {
-
virtualisation = {
-
memorySize = 2048;
-
diskSize = 10 * 1024;
-
cores = 2;
-
forwardPorts = [
-
# ssh
-
{
-
from = "host";
-
host.port = 2222;
-
guest.port = 22;
-
}
-
# knot
-
{
-
from = "host";
-
host.port = 6000;
-
guest.port = 6000;
-
}
-
# spindle
-
{
-
from = "host";
-
host.port = 6555;
-
guest.port = 6555;
-
}
-
];
-
};
-
services.getty.autologinUser = "root";
-
environment.systemPackages = with pkgs; [curl vim git];
-
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=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
-
];
-
services.tangled-knot = {
-
enable = true;
-
server = {
-
secretFile = "/var/lib/knot/secret";
-
hostname = "localhost:6000";
-
listenAddr = "0.0.0.0:6000";
+
}: let
+
envVar = name: let
+
var = builtins.getEnv name;
+
in
+
if var == ""
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
+
else var;
+
in
+
nixpkgs.lib.nixosSystem {
+
inherit system;
+
modules = [
+
self.nixosModules.knot
+
self.nixosModules.spindle
+
({
+
lib,
+
config,
+
pkgs,
+
...
+
}: {
+
virtualisation.vmVariant.virtualisation = {
+
host.pkgs = import nixpkgs {system = hostSystem;};
+
+
graphics = false;
+
memorySize = 2048;
+
diskSize = 10 * 1024;
+
cores = 2;
+
forwardPorts = [
+
# ssh
+
{
+
from = "host";
+
host.port = 2222;
+
guest.port = 22;
+
}
+
# knot
+
{
+
from = "host";
+
host.port = 6000;
+
guest.port = 6000;
+
}
+
# spindle
+
{
+
from = "host";
+
host.port = 6555;
+
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";
+
};
+
};
};
-
};
-
services.tangled-spindle = {
-
enable = true;
-
server = {
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
-
hostname = "localhost:6555";
-
listenAddr = "0.0.0.0:6555";
-
dev = true;
+
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
+
networking.firewall.enable = false;
+
time.timeZone = "Europe/London";
+
services.getty.autologinUser = "root";
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
+
services.tangled-knot = {
+
enable = true;
+
motd = "Welcome to the development knot!\n";
+
server = {
+
owner = envVar "TANGLED_VM_KNOT_OWNER";
+
hostname = "localhost:6000";
+
listenAddr = "0.0.0.0:6000";
+
};
+
};
+
services.tangled-spindle = {
+
enable = true;
+
server = {
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
+
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")
+29 -10
spindle/config/config.go
···
import (
"context"
+
"fmt"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/sethvargo/go-envconfig"
)
type Server struct {
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
-
DBPath string `env:"DB_PATH, default=spindle.db"`
-
Hostname string `env:"HOSTNAME, required"`
-
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
-
Dev bool `env:"DEV, default=false"`
-
Owner string `env:"OWNER, required"`
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"`
+
DBPath string `env:"DB_PATH, default=spindle.db"`
+
Hostname string `env:"HOSTNAME, required"`
+
JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"`
+
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 {
+
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
+
}
+
+
type Secrets struct {
+
Provider string `env:"PROVIDER, default=sqlite"`
+
OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"`
+
}
+
+
type OpenBaoConfig struct {
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
+
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) {
+29 -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
}
+
+
// 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(`
-
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;
-
create table if not exists _jetstream (
id integer primary key autoincrement,
last_time_us integer not null
···
addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
unique(owner, name)
+
);
+
+
create table if not exists spindle_members (
+
-- identifiers for the record
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text not null,
+
+
-- data
+
instance text not null,
+
subject text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique (did, instance, subject)
);
-- status event for a single workflow
+59
spindle/db/member.go
···
+
package db
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type SpindleMember struct {
+
Id int
+
Did syntax.DID // owner of the record
+
Rkey string // rkey of the record
+
Instance string
+
Subject syntax.DID // the member being added
+
Created time.Time
+
}
+
+
func AddSpindleMember(db *DB, member SpindleMember) error {
+
_, err := db.Exec(
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
+
member.Did,
+
member.Rkey,
+
member.Instance,
+
member.Subject,
+
)
+
return err
+
}
+
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
+
_, err := db.Exec(
+
"delete from spindle_members where did = ? and rkey = ?",
+
owner_did,
+
rkey,
+
)
+
return err
+
}
+
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
+
query :=
+
`select id, did, rkey, instance, subject, created
+
from spindle_members
+
where did = ? and rkey = ?`
+
+
var member SpindleMember
+
var createdAt string
+
err := db.QueryRow(query, did, rkey).Scan(
+
&member.Id,
+
&member.Did,
+
&member.Rkey,
+
&member.Instance,
+
&member.Subject,
+
&createdAt,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
return &member, nil
+
}
-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)
-
}
+77 -401
spindle/engine/engine.go
···
"context"
"errors"
"fmt"
-
"io"
"log/slog"
-
"os"
-
"strings"
-
"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/api/types/volume"
-
"github.com/docker/docker/client"
-
"github.com/docker/docker/pkg/stdcopy"
-
"tangled.sh/tangled.sh/core/log"
+
securejoin "github.com/cyphar/filepath-securejoin"
+
"golang.org/x/sync/errgroup"
"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/models"
+
"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
-
-
cleanupMu sync.Mutex
-
cleanup map[string][]cleanupFunc
-
}
+
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)
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*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,
+
// extract secrets
+
var allSecrets []secrets.UnlockedSecret
+
if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil {
+
if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil {
+
allSecrets = res
+
}
}
-
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)
-
-
wg := sync.WaitGroup{}
-
for _, w := range pipeline.Workflows {
-
wg.Add(1)
-
go func() error {
-
defer wg.Done()
-
wid := models.WorkflowId{
-
PipelineId: pipelineId,
-
Name: w.Name,
-
}
-
-
err := e.db.StatusRunning(wid, e.n)
-
if err != nil {
-
return err
-
}
+
eg, ctx := errgroup.WithContext(ctx)
+
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)
+
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)
-
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)
-
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
-
defer cancel()
+
destroyErr := eng.DestroyWorkflow(ctx, wid)
+
if destroyErr != nil {
+
l.Error("failed to destroy workflow after setup failure", "error", destroyErr)
+
}
-
err = e.StartSteps(ctx, w.Steps, wid, w.Image)
-
if err != nil {
-
if errors.Is(err, ErrTimedOut) {
-
dbErr := e.db.StatusTimeout(wid, e.n)
+
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
if dbErr != nil {
return dbErr
}
-
} else {
-
dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n)
-
if dbErr != nil {
-
return dbErr
-
}
+
return err
}
-
-
return fmt.Errorf("starting steps image: %w", err)
-
}
-
-
err = e.db.StatusSuccess(wid, e.n)
-
if err != nil {
-
return err
-
}
-
-
return nil
-
}()
-
}
-
-
wg.Wait()
-
}
-
-
// 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)
-
-
_, 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))
-
})
-
-
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, steps []models.Step, wid models.WorkflowId, image string) error {
-
-
for stepIdx, step := range steps {
-
select {
-
case <-ctx.Done():
-
return ctx.Err()
-
default:
-
}
-
-
envs := ConstructEnvs(step.Environment)
-
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: 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)
-
}
+
defer eng.DestroyWorkflow(ctx, wid)
-
// wait for both goroutines to finish
-
<-waitDone
-
<-tailDone
-
-
return ErrTimedOut
-
}
+
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()
+
}
-
select {
-
case <-ctx.Done():
-
return ctx.Err()
-
default:
-
}
+
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
+
defer cancel()
-
if waitErr != nil {
-
return waitErr
-
}
+
for stepIdx, step := range w.Steps {
+
if wfLogger != nil {
+
ctl := wfLogger.ControlWriter(stepIdx, step)
+
ctl.Write([]byte(step.Name()))
+
}
-
err = e.DestroyStep(ctx, resp.ID)
-
if err != nil {
-
return err
-
}
+
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
+
}
+
}
-
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 fmt.Errorf("starting steps image: %w", err)
+
}
+
}
-
return nil
-
}
+
err = db.StatusSuccess(wid, n)
+
if err != nil {
+
return err
+
}
-
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
+
return nil
+
})
}
-
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
+
}
+175 -9
spindle/ingester.go
···
import (
"context"
"encoding/json"
+
"errors"
"fmt"
+
"time"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/eventconsumer"
+
"tangled.sh/tangled.sh/core/idresolver"
+
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/spindle/db"
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
"github.com/bluesky-social/jetstream/pkg/models"
+
securejoin "github.com/cyphar/filepath-securejoin"
)
type Ingester func(ctx context.Context, e *models.Event) error
···
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:
+
err = s.ingestCollaborator(ctx, e)
+
}
+
+
if err != nil {
+
s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err)
}
-
return err
+
return nil
}
}
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
-
did := e.Did
var err error
+
did := e.Did
+
rkey := e.Commit.RKey
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
···
}
domain := s.cfg.Server.Hostname
-
if s.cfg.Server.Dev {
-
domain = s.cfg.Server.ListenAddr
-
}
recordInstance := record.Instance
if recordInstance != domain {
···
return fmt.Errorf("failed to enforce permissions: %w", err)
}
-
if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil {
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
+
Did: syntax.DID(did),
+
Rkey: rkey,
+
Instance: recordInstance,
+
Subject: syntax.DID(record.Subject),
+
Created: time.Now(),
+
}); err != nil {
+
l.Error("failed to add member", "error", err)
+
return fmt.Errorf("failed to add member: %w", err)
+
}
+
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
l.Error("failed to add member", "error", err)
return fmt.Errorf("failed to add member: %w", err)
}
···
return nil
+
case models.CommitOperationDelete:
+
record, err := db.GetSpindleMember(s.db, did, rkey)
+
if err != nil {
+
l.Error("failed to find member", "error", err)
+
return fmt.Errorf("failed to find member: %w", err)
+
}
+
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
+
l.Error("failed to remove member", "error", err)
+
return fmt.Errorf("failed to remove member: %w", err)
+
}
+
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
+
l.Error("failed to add member", "error", err)
+
return fmt.Errorf("failed to add member: %w", err)
+
}
+
l.Info("added member from firehose", "member", record.Subject)
+
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
+
l.Error("failed to add did", "error", err)
+
return fmt.Errorf("failed to add did: %w", err)
+
}
+
s.jc.RemoveDid(record.Subject.String())
+
}
return nil
}
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
var err error
+
did := e.Did
+
resolver := idresolver.DefaultResolver()
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
···
return fmt.Errorf("failed to add repo: %w", err)
}
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
+
if err != nil {
+
return err
+
}
+
+
// add repo to rbac
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
+
l.Error("failed to add repo to enforcer", "error", err)
+
return fmt.Errorf("failed to add repo: %w", err)
+
}
+
+
// add collaborators to rbac
+
owner, err := resolver.ResolveIdent(ctx, did)
+
if err != nil || owner.Handle.IsInvalidHandle() {
+
return err
+
}
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
+
return err
+
}
+
// add this knot to the event consumer
src := eventconsumer.NewKnotSource(record.Knot)
s.ks.AddSource(context.Background(), src)
···
}
return nil
}
+
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
+
var err error
+
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
+
+
l.Info("ingesting collaborator record")
+
+
switch e.Commit.Operation {
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
+
raw := e.Commit.Record
+
record := tangled.RepoCollaborator{}
+
err = json.Unmarshal(raw, &record)
+
if err != nil {
+
l.Error("invalid record", "error", err)
+
return err
+
}
+
+
resolver := idresolver.DefaultResolver()
+
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
+
return err
+
}
+
+
repoAt, err := syntax.ParseATURI(record.Repo)
+
if err != nil {
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
+
return nil
+
}
+
+
// TODO: get rid of this entirely
+
// resolve this aturi to extract the repo record
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
+
if err != nil || owner.Handle.IsInvalidHandle() {
+
return fmt.Errorf("failed to resolve handle: %w", err)
+
}
+
+
xrpcc := xrpc.Client{
+
Host: owner.PDSEndpoint(),
+
}
+
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
+
if err != nil {
+
return err
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
+
+
// check perms for this user
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
+
return fmt.Errorf("insufficient permissions: %w", err)
+
}
+
+
// add collaborator to rbac
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
+
l.Error("failed to add repo to enforcer", "error", err)
+
return fmt.Errorf("failed to add repo: %w", err)
+
}
+
+
return nil
+
}
+
return nil
+
}
+
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
+
+
l.Info("fetching and adding existing collaborators")
+
+
xrpcc := xrpc.Client{
+
Host: owner.PDSEndpoint(),
+
}
+
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
+
if err != nil {
+
return err
+
}
+
+
var errs error
+
for _, r := range resp.Records {
+
if r == nil {
+
continue
+
}
+
record := r.Value.Val.(*tangled.RepoCollaborator)
+
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
+
l.Error("failed to add repo to enforcer", "error", err)
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
+
}
+
}
+
+
return errs
+
}
+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(),
}
}
+10 -108
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 {
-
Workflows []Workflow
+
RepoOwner string
+
RepoName string
+
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)
-
-
swf.addNixProfileToPath()
-
swf.setGlobalEnvs()
-
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)
-
}
-
return &Pipeline{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)
-
}
-
-
func (wf *Workflow) addNixProfileToPath() {
-
wf.Environment["PATH"] = "$PATH:/.nix-profile/bin"
-
}
-
-
func (wf *Workflow) setGlobalEnvs() {
-
wf.Environment["NIX_CONFIG"] = "experimental-features = nix-command flakes"
-
wf.Environment["HOME"] = "/tangled/workspace"
+
Steps []Step
+
Name string
+
Data any
}
-125
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
-
}
-
-
// 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
-
}
+25
spindle/motd
···
+
****
+
*** ***
+
*** ** ****** **
+
** * *****
+
* ** **
+
* * * ***************
+
** ** *# **
+
* ** ** *** **
+
* * ** ** * ******
+
* ** ** * ** * *
+
** ** *** ** ** *
+
** ** * ** * *
+
** **** ** * *
+
** *** ** ** **
+
*** ** *****
+
********************
+
**
+
*
+
#**************
+
**
+
********
+
+
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle
+
+
Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
···
+
package secrets
+
+
import (
+
"context"
+
"errors"
+
"regexp"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type DidSlashRepo string
+
+
type Secret[T any] struct {
+
Key string
+
Value T
+
Repo DidSlashRepo
+
CreatedAt time.Time
+
CreatedBy syntax.DID
+
}
+
+
// the secret is not present
+
type LockedSecret = Secret[struct{}]
+
+
// the secret is present in plaintext, never expose this publicly,
+
// only use in the workflow engine
+
type UnlockedSecret = Secret[string]
+
+
type Manager interface {
+
AddSecret(ctx context.Context, secret UnlockedSecret) error
+
RemoveSecret(ctx context.Context, secret Secret[any]) error
+
GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error)
+
GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error)
+
}
+
+
// stopper interface for managers that need cleanup
+
type Stopper interface {
+
Stop()
+
}
+
+
var ErrKeyAlreadyPresent = errors.New("key already present")
+
var ErrInvalidKeyIdent = errors.New("key is not a valid identifier")
+
var ErrKeyNotFound = errors.New("key not found")
+
+
// ensure that we are satisfying the interface
+
var (
+
_ = []Manager{
+
&SqliteManager{},
+
&OpenBaoManager{},
+
}
+
)
+
+
var (
+
// bash identifier syntax
+
keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
+
)
+
+
func isValidKey(key string) bool {
+
if key == "" {
+
return false
+
}
+
return keyIdent.MatchString(key)
+
}
+
+
func ValidateKey(key string) error {
+
if !isValidKey(key) {
+
return ErrInvalidKeyIdent
+
}
+
return nil
+
}
+313
spindle/secrets/openbao.go
···
+
package secrets
+
+
import (
+
"context"
+
"fmt"
+
"log/slog"
+
"path"
+
"strings"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
vault "github.com/openbao/openbao/api/v2"
+
)
+
+
type OpenBaoManager struct {
+
client *vault.Client
+
mountPath string
+
logger *slog.Logger
+
}
+
+
type OpenBaoManagerOpt func(*OpenBaoManager)
+
+
func WithMountPath(mountPath string) OpenBaoManagerOpt {
+
return func(v *OpenBaoManager) {
+
v.mountPath = mountPath
+
}
+
}
+
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
+
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
+
// The proxy handles all authentication automatically via Auto-Auth
+
func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
+
if proxyAddress == "" {
+
return nil, fmt.Errorf("proxy address cannot be empty")
+
}
+
+
config := vault.DefaultConfig()
+
config.Address = proxyAddress
+
+
client, err := vault.NewClient(config)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create openbao client: %w", err)
+
}
+
+
manager := &OpenBaoManager{
+
client: client,
+
mountPath: "spindle", // default KV v2 mount path
+
logger: logger,
+
}
+
+
for _, opt := range opts {
+
opt(manager)
+
}
+
+
if err := manager.testConnection(); err != nil {
+
return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
+
}
+
+
logger.Info("successfully connected to bao proxy", "address", proxyAddress)
+
return manager, nil
+
}
+
+
// testConnection verifies that we can connect to the proxy
+
func (v *OpenBaoManager) testConnection() error {
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
defer cancel()
+
+
// try token self-lookup as a quick way to verify proxy works
+
// and is authenticated
+
_, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
+
if err != nil {
+
return fmt.Errorf("proxy connection test failed: %w", err)
+
}
+
+
return nil
+
}
+
+
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
+
if err := ValidateKey(secret.Key); err != nil {
+
return err
+
}
+
+
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
+
v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
+
+
// Check if secret already exists
+
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
+
if err == nil && existing != nil {
+
v.logger.Debug("secret already exists", "path", secretPath)
+
return ErrKeyAlreadyPresent
+
}
+
+
secretData := map[string]interface{}{
+
"value": secret.Value,
+
"repo": string(secret.Repo),
+
"key": secret.Key,
+
"created_at": secret.CreatedAt.Format(time.RFC3339),
+
"created_by": secret.CreatedBy.String(),
+
}
+
+
v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
+
resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
+
if err != nil {
+
v.logger.Error("failed to write secret", "path", secretPath, "error", err)
+
return fmt.Errorf("failed to store secret in openbao: %w", err)
+
}
+
+
v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
+
+
v.logger.Debug("verifying secret was written", "path", secretPath)
+
readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
+
if err != nil {
+
v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
+
return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
+
}
+
+
if readBack == nil || readBack.Data == nil {
+
v.logger.Error("secret verification returned empty data", "path", secretPath)
+
return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
+
}
+
+
v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
+
return nil
+
}
+
+
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
+
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
+
+
// check if secret exists
+
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
+
if err != nil || existing == nil {
+
return ErrKeyNotFound
+
}
+
+
err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath)
+
if err != nil {
+
return fmt.Errorf("failed to delete secret from openbao: %w", err)
+
}
+
+
v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
+
return nil
+
}
+
+
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
+
repoPath := v.buildRepoPath(repo)
+
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
+
if err != nil {
+
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
+
return []LockedSecret{}, nil
+
}
+
return nil, fmt.Errorf("failed to list secrets: %w", err)
+
}
+
+
if secretsList == nil || secretsList.Data == nil {
+
return []LockedSecret{}, nil
+
}
+
+
keys, ok := secretsList.Data["keys"].([]interface{})
+
if !ok {
+
return []LockedSecret{}, nil
+
}
+
+
var secrets []LockedSecret
+
+
for _, keyInterface := range keys {
+
key, ok := keyInterface.(string)
+
if !ok {
+
continue
+
}
+
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
+
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
+
if err != nil {
+
v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
+
continue
+
}
+
+
if secretData == nil || secretData.Data == nil {
+
continue
+
}
+
+
data := secretData.Data
+
+
createdAtStr, ok := data["created_at"].(string)
+
if !ok {
+
createdAtStr = time.Now().Format(time.RFC3339)
+
}
+
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
+
if err != nil {
+
createdAt = time.Now()
+
}
+
+
createdByStr, ok := data["created_by"].(string)
+
if !ok {
+
createdByStr = ""
+
}
+
+
keyStr, ok := data["key"].(string)
+
if !ok {
+
keyStr = key
+
}
+
+
secret := LockedSecret{
+
Key: keyStr,
+
Repo: repo,
+
CreatedAt: createdAt,
+
CreatedBy: syntax.DID(createdByStr),
+
}
+
+
secrets = append(secrets, secret)
+
}
+
+
v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
+
return secrets, nil
+
}
+
+
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
+
repoPath := v.buildRepoPath(repo)
+
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
+
if err != nil {
+
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
+
return []UnlockedSecret{}, nil
+
}
+
return nil, fmt.Errorf("failed to list secrets: %w", err)
+
}
+
+
if secretsList == nil || secretsList.Data == nil {
+
return []UnlockedSecret{}, nil
+
}
+
+
keys, ok := secretsList.Data["keys"].([]interface{})
+
if !ok {
+
return []UnlockedSecret{}, nil
+
}
+
+
var secrets []UnlockedSecret
+
+
for _, keyInterface := range keys {
+
key, ok := keyInterface.(string)
+
if !ok {
+
continue
+
}
+
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
+
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
+
if err != nil {
+
v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
+
continue
+
}
+
+
if secretData == nil || secretData.Data == nil {
+
continue
+
}
+
+
data := secretData.Data
+
+
valueStr, ok := data["value"].(string)
+
if !ok {
+
v.logger.Warn("secret missing value", "path", secretPath)
+
continue
+
}
+
+
createdAtStr, ok := data["created_at"].(string)
+
if !ok {
+
createdAtStr = time.Now().Format(time.RFC3339)
+
}
+
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
+
if err != nil {
+
createdAt = time.Now()
+
}
+
+
createdByStr, ok := data["created_by"].(string)
+
if !ok {
+
createdByStr = ""
+
}
+
+
keyStr, ok := data["key"].(string)
+
if !ok {
+
keyStr = key
+
}
+
+
secret := UnlockedSecret{
+
Key: keyStr,
+
Value: valueStr,
+
Repo: repo,
+
CreatedAt: createdAt,
+
CreatedBy: syntax.DID(createdByStr),
+
}
+
+
secrets = append(secrets, secret)
+
}
+
+
v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
+
return secrets, nil
+
}
+
+
// buildRepoPath creates a safe path for a repository
+
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
+
// convert DidSlashRepo to a safe path by replacing special characters
+
repoPath := strings.ReplaceAll(string(repo), "/", "_")
+
repoPath = strings.ReplaceAll(repoPath, ":", "_")
+
repoPath = strings.ReplaceAll(repoPath, ".", "_")
+
return fmt.Sprintf("repos/%s", repoPath)
+
}
+
+
// buildSecretPath creates a path for a specific secret
+
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
+
return path.Join(v.buildRepoPath(repo), key)
+
}
+605
spindle/secrets/openbao_test.go
···
+
package secrets
+
+
import (
+
"context"
+
"log/slog"
+
"os"
+
"testing"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/stretchr/testify/assert"
+
)
+
+
// MockOpenBaoManager is a mock implementation of Manager interface for testing
+
type MockOpenBaoManager struct {
+
secrets map[string]UnlockedSecret // key: repo_key format
+
shouldError bool
+
errorToReturn error
+
}
+
+
func NewMockOpenBaoManager() *MockOpenBaoManager {
+
return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)}
+
}
+
+
func (m *MockOpenBaoManager) SetError(err error) {
+
m.shouldError = true
+
m.errorToReturn = err
+
}
+
+
func (m *MockOpenBaoManager) ClearError() {
+
m.shouldError = false
+
m.errorToReturn = nil
+
}
+
+
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
+
return string(repo) + "_" + key
+
}
+
+
func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
+
if m.shouldError {
+
return m.errorToReturn
+
}
+
+
key := m.buildKey(secret.Repo, secret.Key)
+
if _, exists := m.secrets[key]; exists {
+
return ErrKeyAlreadyPresent
+
}
+
+
m.secrets[key] = secret
+
return nil
+
}
+
+
func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
+
if m.shouldError {
+
return m.errorToReturn
+
}
+
+
key := m.buildKey(secret.Repo, secret.Key)
+
if _, exists := m.secrets[key]; !exists {
+
return ErrKeyNotFound
+
}
+
+
delete(m.secrets, key)
+
return nil
+
}
+
+
func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
+
if m.shouldError {
+
return nil, m.errorToReturn
+
}
+
+
var result []LockedSecret
+
for _, secret := range m.secrets {
+
if secret.Repo == repo {
+
result = append(result, LockedSecret{
+
Key: secret.Key,
+
Repo: secret.Repo,
+
CreatedAt: secret.CreatedAt,
+
CreatedBy: secret.CreatedBy,
+
})
+
}
+
}
+
+
return result, nil
+
}
+
+
func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
+
if m.shouldError {
+
return nil, m.errorToReturn
+
}
+
+
var result []UnlockedSecret
+
for _, secret := range m.secrets {
+
if secret.Repo == repo {
+
result = append(result, secret)
+
}
+
}
+
+
return result, nil
+
}
+
+
func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret {
+
return UnlockedSecret{
+
Key: key,
+
Value: value,
+
Repo: DidSlashRepo(repo),
+
CreatedAt: time.Now(),
+
CreatedBy: syntax.DID(createdBy),
+
}
+
}
+
+
// Test MockOpenBaoManager interface compliance
+
func TestMockOpenBaoManagerInterface(t *testing.T) {
+
var _ Manager = (*MockOpenBaoManager)(nil)
+
}
+
+
func TestOpenBaoManagerInterface(t *testing.T) {
+
var _ Manager = (*OpenBaoManager)(nil)
+
}
+
+
func TestNewOpenBaoManager(t *testing.T) {
+
tests := []struct {
+
name string
+
proxyAddr string
+
opts []OpenBaoManagerOpt
+
expectError bool
+
errorContains string
+
}{
+
{
+
name: "empty proxy address",
+
proxyAddr: "",
+
opts: nil,
+
expectError: true,
+
errorContains: "proxy address cannot be empty",
+
},
+
{
+
name: "valid proxy address",
+
proxyAddr: "http://localhost:8200",
+
opts: nil,
+
expectError: true, // Will fail because no real proxy is running
+
errorContains: "failed to connect to bao proxy",
+
},
+
{
+
name: "with mount path option",
+
proxyAddr: "http://localhost:8200",
+
opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
+
expectError: true, // Will fail because no real proxy is running
+
errorContains: "failed to connect to bao proxy",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
+
+
if tt.expectError {
+
assert.Error(t, err)
+
assert.Nil(t, manager)
+
assert.Contains(t, err.Error(), tt.errorContains)
+
} else {
+
assert.NoError(t, err)
+
assert.NotNil(t, manager)
+
}
+
})
+
}
+
}
+
+
func TestOpenBaoManager_PathBuilding(t *testing.T) {
+
manager := &OpenBaoManager{mountPath: "secret"}
+
+
tests := []struct {
+
name string
+
repo DidSlashRepo
+
key string
+
expected string
+
}{
+
{
+
name: "simple repo path",
+
repo: DidSlashRepo("did:plc:foo/repo"),
+
key: "api_key",
+
expected: "repos/did_plc_foo_repo/api_key",
+
},
+
{
+
name: "complex repo path with dots",
+
repo: DidSlashRepo("did:web:example.com/my-repo"),
+
key: "secret_key",
+
expected: "repos/did_web_example_com_my-repo/secret_key",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := manager.buildSecretPath(tt.repo, tt.key)
+
assert.Equal(t, tt.expected, result)
+
})
+
}
+
}
+
+
func TestOpenBaoManager_buildRepoPath(t *testing.T) {
+
manager := &OpenBaoManager{mountPath: "test"}
+
+
tests := []struct {
+
name string
+
repo DidSlashRepo
+
expected string
+
}{
+
{
+
name: "simple repo",
+
repo: "did:plc:test/myrepo",
+
expected: "repos/did_plc_test_myrepo",
+
},
+
{
+
name: "repo with dots",
+
repo: "did:plc:example.com/my.repo",
+
expected: "repos/did_plc_example_com_my_repo",
+
},
+
{
+
name: "complex repo",
+
repo: "did:web:example.com:8080/path/to/repo",
+
expected: "repos/did_web_example_com_8080_path_to_repo",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := manager.buildRepoPath(tt.repo)
+
assert.Equal(t, tt.expected, result)
+
})
+
}
+
}
+
+
func TestWithMountPath(t *testing.T) {
+
manager := &OpenBaoManager{mountPath: "default"}
+
+
opt := WithMountPath("custom-mount")
+
opt(manager)
+
+
assert.Equal(t, "custom-mount", manager.mountPath)
+
}
+
+
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
+
tests := []struct {
+
name string
+
secrets []UnlockedSecret
+
expectError bool
+
}{
+
{
+
name: "add single secret",
+
secrets: []UnlockedSecret{
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
+
},
+
expectError: false,
+
},
+
{
+
name: "add multiple secrets",
+
secrets: []UnlockedSecret{
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
+
},
+
expectError: false,
+
},
+
{
+
name: "add duplicate secret",
+
secrets: []UnlockedSecret{
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"),
+
},
+
expectError: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
mock := NewMockOpenBaoManager()
+
ctx := context.Background()
+
var err error
+
+
for i, secret := range tt.secrets {
+
err = mock.AddSecret(ctx, secret)
+
if tt.expectError && i == 1 { // Second secret should fail for duplicate test
+
assert.Equal(t, ErrKeyAlreadyPresent, err)
+
return
+
}
+
if !tt.expectError {
+
assert.NoError(t, err)
+
}
+
}
+
+
if !tt.expectError {
+
assert.NoError(t, err)
+
}
+
})
+
}
+
}
+
+
func TestMockOpenBaoManager_RemoveSecret(t *testing.T) {
+
tests := []struct {
+
name string
+
setupSecrets []UnlockedSecret
+
removeSecret Secret[any]
+
expectError bool
+
}{
+
{
+
name: "remove existing secret",
+
setupSecrets: []UnlockedSecret{
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
+
},
+
removeSecret: Secret[any]{
+
Key: "API_KEY",
+
Repo: DidSlashRepo("did:plc:test/repo1"),
+
},
+
expectError: false,
+
},
+
{
+
name: "remove non-existent secret",
+
setupSecrets: []UnlockedSecret{},
+
removeSecret: Secret[any]{
+
Key: "API_KEY",
+
Repo: DidSlashRepo("did:plc:test/repo1"),
+
},
+
expectError: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
mock := NewMockOpenBaoManager()
+
ctx := context.Background()
+
+
// Setup secrets
+
for _, secret := range tt.setupSecrets {
+
err := mock.AddSecret(ctx, secret)
+
assert.NoError(t, err)
+
}
+
+
// Remove secret
+
err := mock.RemoveSecret(ctx, tt.removeSecret)
+
+
if tt.expectError {
+
assert.Equal(t, ErrKeyNotFound, err)
+
} else {
+
assert.NoError(t, err)
+
}
+
})
+
}
+
}
+
+
func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) {
+
tests := []struct {
+
name string
+
setupSecrets []UnlockedSecret
+
queryRepo DidSlashRepo
+
expectedCount int
+
expectedKeys []string
+
expectError bool
+
}{
+
{
+
name: "get secrets from repo with secrets",
+
setupSecrets: []UnlockedSecret{
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
+
createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
+
},
+
queryRepo: DidSlashRepo("did:plc:test/repo1"),
+
expectedCount: 2,
+
expectedKeys: []string{"API_KEY", "DB_PASSWORD"},
+
expectError: false,
+
},
+
{
+
name: "get secrets from empty repo",
+
setupSecrets: []UnlockedSecret{},
+
queryRepo: DidSlashRepo("did:plc:test/empty"),
+
expectedCount: 0,
+
expectedKeys: []string{},
+
expectError: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
mock := NewMockOpenBaoManager()
+
ctx := context.Background()
+
+
// Setup
+
for _, secret := range tt.setupSecrets {
+
err := mock.AddSecret(ctx, secret)
+
assert.NoError(t, err)
+
}
+
+
// Test
+
secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo)
+
+
if tt.expectError {
+
assert.Error(t, err)
+
} else {
+
assert.NoError(t, err)
+
assert.Len(t, secrets, tt.expectedCount)
+
+
// Check keys
+
actualKeys := make([]string, len(secrets))
+
for i, secret := range secrets {
+
actualKeys[i] = secret.Key
+
}
+
+
for _, expectedKey := range tt.expectedKeys {
+
assert.Contains(t, actualKeys, expectedKey)
+
}
+
}
+
})
+
}
+
}
+
+
func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) {
+
tests := []struct {
+
name string
+
setupSecrets []UnlockedSecret
+
queryRepo DidSlashRepo
+
expectedCount int
+
expectedSecrets map[string]string // key -> value
+
expectError bool
+
}{
+
{
+
name: "get unlocked secrets from repo",
+
setupSecrets: []UnlockedSecret{
+
createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
+
createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
+
createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
+
},
+
queryRepo: DidSlashRepo("did:plc:test/repo1"),
+
expectedCount: 2,
+
expectedSecrets: map[string]string{
+
"API_KEY": "secret123",
+
"DB_PASSWORD": "dbpass456",
+
},
+
expectError: false,
+
},
+
{
+
name: "get secrets from empty repo",
+
setupSecrets: []UnlockedSecret{},
+
queryRepo: DidSlashRepo("did:plc:test/empty"),
+
expectedCount: 0,
+
expectedSecrets: map[string]string{},
+
expectError: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
mock := NewMockOpenBaoManager()
+
ctx := context.Background()
+
+
// Setup
+
for _, secret := range tt.setupSecrets {
+
err := mock.AddSecret(ctx, secret)
+
assert.NoError(t, err)
+
}
+
+
// Test
+
secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo)
+
+
if tt.expectError {
+
assert.Error(t, err)
+
} else {
+
assert.NoError(t, err)
+
assert.Len(t, secrets, tt.expectedCount)
+
+
// Check key-value pairs
+
actualSecrets := make(map[string]string)
+
for _, secret := range secrets {
+
actualSecrets[secret.Key] = secret.Value
+
}
+
+
for expectedKey, expectedValue := range tt.expectedSecrets {
+
actualValue, exists := actualSecrets[expectedKey]
+
assert.True(t, exists, "Expected key %s not found", expectedKey)
+
assert.Equal(t, expectedValue, actualValue)
+
}
+
}
+
})
+
}
+
}
+
+
func TestMockOpenBaoManager_ErrorHandling(t *testing.T) {
+
mock := NewMockOpenBaoManager()
+
ctx := context.Background()
+
testError := assert.AnError
+
+
// Test error injection
+
mock.SetError(testError)
+
+
secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator")
+
+
// All operations should return the injected error
+
err := mock.AddSecret(ctx, secret)
+
assert.Equal(t, testError, err)
+
+
_, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1")
+
assert.Equal(t, testError, err)
+
+
_, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1")
+
assert.Equal(t, testError, err)
+
+
err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"})
+
assert.Equal(t, testError, err)
+
+
// Clear error and test normal operation
+
mock.ClearError()
+
err = mock.AddSecret(ctx, secret)
+
assert.NoError(t, err)
+
}
+
+
func TestMockOpenBaoManager_Integration(t *testing.T) {
+
tests := []struct {
+
name string
+
scenario func(t *testing.T, mock *MockOpenBaoManager)
+
}{
+
{
+
name: "complete workflow",
+
scenario: func(t *testing.T, mock *MockOpenBaoManager) {
+
ctx := context.Background()
+
repo := DidSlashRepo("did:plc:test/integration")
+
+
// Start with empty repo
+
secrets, err := mock.GetSecretsLocked(ctx, repo)
+
assert.NoError(t, err)
+
assert.Empty(t, secrets)
+
+
// Add some secrets
+
secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator")
+
secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator")
+
+
err = mock.AddSecret(ctx, secret1)
+
assert.NoError(t, err)
+
+
err = mock.AddSecret(ctx, secret2)
+
assert.NoError(t, err)
+
+
// Verify secrets exist
+
secrets, err = mock.GetSecretsLocked(ctx, repo)
+
assert.NoError(t, err)
+
assert.Len(t, secrets, 2)
+
+
unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo)
+
assert.NoError(t, err)
+
assert.Len(t, unlockedSecrets, 2)
+
+
// Remove one secret
+
err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo})
+
assert.NoError(t, err)
+
+
// Verify only one secret remains
+
secrets, err = mock.GetSecretsLocked(ctx, repo)
+
assert.NoError(t, err)
+
assert.Len(t, secrets, 1)
+
assert.Equal(t, "DB_PASSWORD", secrets[0].Key)
+
},
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
mock := NewMockOpenBaoManager()
+
tt.scenario(t, mock)
+
})
+
}
+
}
+
+
func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
+
tests := []struct {
+
name string
+
proxyAddr string
+
description string
+
}{
+
{
+
name: "default_localhost",
+
proxyAddr: "http://127.0.0.1:8200",
+
description: "Should connect to default localhost proxy",
+
},
+
{
+
name: "custom_host",
+
proxyAddr: "http://bao-proxy:8200",
+
description: "Should connect to custom proxy host",
+
},
+
{
+
name: "https_proxy",
+
proxyAddr: "https://127.0.0.1:8200",
+
description: "Should connect to HTTPS proxy",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
t.Log("Testing scenario:", tt.description)
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+
// All these will fail because no real proxy is running
+
// but we can test that the configuration is properly accepted
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
+
assert.Error(t, err) // Expected because no real proxy
+
assert.Nil(t, manager)
+
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+
})
+
}
+
}
+22
spindle/secrets/policy.hcl
···
+
# Allow full access to the spindle KV mount
+
path "spindle/*" {
+
capabilities = ["create", "read", "update", "delete", "list"]
+
}
+
+
path "spindle/data/*" {
+
capabilities = ["create", "read", "update", "delete"]
+
}
+
+
path "spindle/metadata/*" {
+
capabilities = ["list", "read", "delete"]
+
}
+
+
# Allow listing mounts (for connection testing)
+
path "sys/mounts" {
+
capabilities = ["read"]
+
}
+
+
# Allow token self-lookup (for health checks)
+
path "auth/token/lookup-self" {
+
capabilities = ["read"]
+
}
+172
spindle/secrets/sqlite.go
···
+
// an sqlite3 backed secret manager
+
package secrets
+
+
import (
+
"context"
+
"database/sql"
+
"fmt"
+
"time"
+
+
_ "github.com/mattn/go-sqlite3"
+
)
+
+
type SqliteManager struct {
+
db *sql.DB
+
tableName string
+
}
+
+
type SqliteManagerOpt func(*SqliteManager)
+
+
func WithTableName(name string) SqliteManagerOpt {
+
return func(s *SqliteManager) {
+
s.tableName = name
+
}
+
}
+
+
func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) {
+
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1")
+
if err != nil {
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
+
}
+
+
manager := &SqliteManager{
+
db: db,
+
tableName: "secrets",
+
}
+
+
for _, o := range opts {
+
o(manager)
+
}
+
+
if err := manager.init(); err != nil {
+
return nil, err
+
}
+
+
return manager, nil
+
}
+
+
// creates a table and sets up the schema, migrations if any can go here
+
func (s *SqliteManager) init() error {
+
createTable :=
+
`create table if not exists ` + s.tableName + `(
+
id integer primary key autoincrement,
+
repo text not null,
+
key text not null,
+
value text not null,
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
created_by text not null,
+
+
unique(repo, key)
+
);`
+
_, err := s.db.Exec(createTable)
+
return err
+
}
+
+
func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
+
query := fmt.Sprintf(`
+
insert or ignore into %s (repo, key, value, created_by)
+
values (?, ?, ?, ?);
+
`, s.tableName)
+
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy)
+
if err != nil {
+
return err
+
}
+
+
num, err := res.RowsAffected()
+
if err != nil {
+
return err
+
}
+
+
if num == 0 {
+
return ErrKeyAlreadyPresent
+
}
+
+
return nil
+
}
+
+
func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
+
query := fmt.Sprintf(`
+
delete from %s where repo = ? and key = ?;
+
`, s.tableName)
+
+
res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key)
+
if err != nil {
+
return err
+
}
+
+
num, err := res.RowsAffected()
+
if err != nil {
+
return err
+
}
+
+
if num == 0 {
+
return ErrKeyNotFound
+
}
+
+
return nil
+
}
+
+
func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) {
+
query := fmt.Sprintf(`
+
select repo, key, created_at, created_by from %s where repo = ?;
+
`, s.tableName)
+
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
+
if err != nil {
+
return nil, err
+
}
+
+
var ls []LockedSecret
+
for rows.Next() {
+
var l LockedSecret
+
var createdAt string
+
if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil {
+
return nil, err
+
}
+
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
l.CreatedAt = t
+
}
+
+
ls = append(ls, l)
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return ls, nil
+
}
+
+
func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) {
+
query := fmt.Sprintf(`
+
select repo, key, value, created_at, created_by from %s where repo = ?;
+
`, s.tableName)
+
+
rows, err := s.db.QueryContext(ctx, query, didSlashRepo)
+
if err != nil {
+
return nil, err
+
}
+
+
var ls []UnlockedSecret
+
for rows.Next() {
+
var l UnlockedSecret
+
var createdAt string
+
if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil {
+
return nil, err
+
}
+
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
l.CreatedAt = t
+
}
+
+
ls = append(ls, l)
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return ls, nil
+
}
+590
spindle/secrets/sqlite_test.go
···
+
package secrets
+
+
import (
+
"context"
+
"testing"
+
"time"
+
+
"github.com/alecthomas/assert/v2"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
func createInMemoryDB(t *testing.T) *SqliteManager {
+
t.Helper()
+
manager, err := NewSQLiteManager(":memory:")
+
if err != nil {
+
t.Fatalf("Failed to create in-memory manager: %v", err)
+
}
+
return manager
+
}
+
+
func createTestSecret(repo, key, value, createdBy string) UnlockedSecret {
+
return UnlockedSecret{
+
Key: key,
+
Value: value,
+
Repo: DidSlashRepo(repo),
+
CreatedAt: time.Now(),
+
CreatedBy: syntax.DID(createdBy),
+
}
+
}
+
+
// ensure that interface is satisfied
+
func TestManagerInterface(t *testing.T) {
+
var _ Manager = (*SqliteManager)(nil)
+
}
+
+
func TestNewSQLiteManager(t *testing.T) {
+
tests := []struct {
+
name string
+
dbPath string
+
opts []SqliteManagerOpt
+
expectError bool
+
expectTable string
+
}{
+
{
+
name: "default table name",
+
dbPath: ":memory:",
+
opts: nil,
+
expectError: false,
+
expectTable: "secrets",
+
},
+
{
+
name: "custom table name",
+
dbPath: ":memory:",
+
opts: []SqliteManagerOpt{WithTableName("custom_secrets")},
+
expectError: false,
+
expectTable: "custom_secrets",
+
},
+
{
+
name: "invalid database path",
+
dbPath: "/invalid/path/to/database.db",
+
opts: nil,
+
expectError: true,
+
expectTable: "",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
manager, err := NewSQLiteManager(tt.dbPath, tt.opts...)
+
if tt.expectError {
+
if err == nil {
+
t.Error("Expected error but got none")
+
}
+
return
+
}
+
+
if err != nil {
+
t.Fatalf("Unexpected error: %v", err)
+
}
+
defer manager.db.Close()
+
+
if manager.tableName != tt.expectTable {
+
t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName)
+
}
+
})
+
}
+
}
+
+
func TestSqliteManager_AddSecret(t *testing.T) {
+
tests := []struct {
+
name string
+
secrets []UnlockedSecret
+
expectError []error
+
}{
+
{
+
name: "add single secret",
+
secrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
+
},
+
expectError: []error{nil},
+
},
+
{
+
name: "add multiple unique secrets",
+
secrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
+
createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"),
+
createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"),
+
},
+
expectError: []error{nil, nil, nil},
+
},
+
{
+
name: "add duplicate secret",
+
secrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
+
createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"),
+
},
+
expectError: []error{nil, ErrKeyAlreadyPresent},
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
manager := createInMemoryDB(t)
+
defer manager.db.Close()
+
+
for i, secret := range tt.secrets {
+
err := manager.AddSecret(context.Background(), secret)
+
if err != tt.expectError[i] {
+
t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err)
+
}
+
}
+
})
+
}
+
}
+
+
func TestSqliteManager_RemoveSecret(t *testing.T) {
+
tests := []struct {
+
name string
+
setupSecrets []UnlockedSecret
+
removeSecret Secret[any]
+
expectError error
+
}{
+
{
+
name: "remove existing secret",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
+
},
+
removeSecret: Secret[any]{
+
Key: "api_key",
+
Repo: DidSlashRepo("did:plc:foo/repo"),
+
},
+
expectError: nil,
+
},
+
{
+
name: "remove non-existent secret",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
+
},
+
removeSecret: Secret[any]{
+
Key: "non_existent_key",
+
Repo: DidSlashRepo("did:plc:foo/repo"),
+
},
+
expectError: ErrKeyNotFound,
+
},
+
{
+
name: "remove from empty database",
+
setupSecrets: []UnlockedSecret{},
+
removeSecret: Secret[any]{
+
Key: "any_key",
+
Repo: DidSlashRepo("did:plc:foo/repo"),
+
},
+
expectError: ErrKeyNotFound,
+
},
+
{
+
name: "remove secret from wrong repo",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"),
+
},
+
removeSecret: Secret[any]{
+
Key: "api_key",
+
Repo: DidSlashRepo("other.com/repo"),
+
},
+
expectError: ErrKeyNotFound,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
manager := createInMemoryDB(t)
+
defer manager.db.Close()
+
+
// Setup secrets
+
for _, secret := range tt.setupSecrets {
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
+
t.Fatalf("Failed to setup secret: %v", err)
+
}
+
}
+
+
// Test removal
+
err := manager.RemoveSecret(context.Background(), tt.removeSecret)
+
if err != tt.expectError {
+
t.Errorf("Expected error %v, got %v", tt.expectError, err)
+
}
+
})
+
}
+
}
+
+
func TestSqliteManager_GetSecretsLocked(t *testing.T) {
+
tests := []struct {
+
name string
+
setupSecrets []UnlockedSecret
+
queryRepo DidSlashRepo
+
expectedCount int
+
expectedKeys []string
+
expectError bool
+
}{
+
{
+
name: "get secrets for repo with multiple secrets",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
+
},
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
+
expectedCount: 2,
+
expectedKeys: []string{"key1", "key2"},
+
expectError: false,
+
},
+
{
+
name: "get secrets for repo with single secret",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
+
},
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
+
expectedCount: 1,
+
expectedKeys: []string{"single_key"},
+
expectError: false,
+
},
+
{
+
name: "get secrets for non-existent repo",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
+
},
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
+
expectedCount: 0,
+
expectedKeys: []string{},
+
expectError: false,
+
},
+
{
+
name: "get secrets from empty database",
+
setupSecrets: []UnlockedSecret{},
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
+
expectedCount: 0,
+
expectedKeys: []string{},
+
expectError: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
manager := createInMemoryDB(t)
+
defer manager.db.Close()
+
+
// Setup secrets
+
for _, secret := range tt.setupSecrets {
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
+
t.Fatalf("Failed to setup secret: %v", err)
+
}
+
}
+
+
// Test getting locked secrets
+
lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo)
+
if tt.expectError && err == nil {
+
t.Error("Expected error but got none")
+
return
+
}
+
if !tt.expectError && err != nil {
+
t.Fatalf("Unexpected error: %v", err)
+
}
+
+
if len(lockedSecrets) != tt.expectedCount {
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets))
+
}
+
+
// Verify keys and that values are not present (locked)
+
foundKeys := make(map[string]bool)
+
for _, ls := range lockedSecrets {
+
foundKeys[ls.Key] = true
+
if ls.Repo != tt.queryRepo {
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo)
+
}
+
if ls.CreatedBy == "" {
+
t.Error("Expected CreatedBy to be present")
+
}
+
if ls.CreatedAt.IsZero() {
+
t.Error("Expected CreatedAt to be set")
+
}
+
}
+
+
for _, expectedKey := range tt.expectedKeys {
+
if !foundKeys[expectedKey] {
+
t.Errorf("Expected key %s not found", expectedKey)
+
}
+
}
+
})
+
}
+
}
+
+
func TestSqliteManager_GetSecretsUnlocked(t *testing.T) {
+
tests := []struct {
+
name string
+
setupSecrets []UnlockedSecret
+
queryRepo DidSlashRepo
+
expectedCount int
+
expectedSecrets map[string]string // key -> value
+
expectError bool
+
}{
+
{
+
name: "get unlocked secrets for repo with multiple secrets",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
+
createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"),
+
createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"),
+
},
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
+
expectedCount: 2,
+
expectedSecrets: map[string]string{
+
"key1": "value1",
+
"key2": "value2",
+
},
+
expectError: false,
+
},
+
{
+
name: "get unlocked secrets for repo with single secret",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"),
+
createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"),
+
},
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
+
expectedCount: 1,
+
expectedSecrets: map[string]string{
+
"single_key": "single_value",
+
},
+
expectError: false,
+
},
+
{
+
name: "get unlocked secrets for non-existent repo",
+
setupSecrets: []UnlockedSecret{
+
createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"),
+
},
+
queryRepo: DidSlashRepo("nonexistent.com/repo"),
+
expectedCount: 0,
+
expectedSecrets: map[string]string{},
+
expectError: false,
+
},
+
{
+
name: "get unlocked secrets from empty database",
+
setupSecrets: []UnlockedSecret{},
+
queryRepo: DidSlashRepo("did:plc:foo/repo"),
+
expectedCount: 0,
+
expectedSecrets: map[string]string{},
+
expectError: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
manager := createInMemoryDB(t)
+
defer manager.db.Close()
+
+
// Setup secrets
+
for _, secret := range tt.setupSecrets {
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
+
t.Fatalf("Failed to setup secret: %v", err)
+
}
+
}
+
+
// Test getting unlocked secrets
+
unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo)
+
if tt.expectError && err == nil {
+
t.Error("Expected error but got none")
+
return
+
}
+
if !tt.expectError && err != nil {
+
t.Fatalf("Unexpected error: %v", err)
+
}
+
+
if len(unlockedSecrets) != tt.expectedCount {
+
t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets))
+
}
+
+
// Verify keys, values, and metadata
+
for _, us := range unlockedSecrets {
+
expectedValue, exists := tt.expectedSecrets[us.Key]
+
if !exists {
+
t.Errorf("Unexpected key: %s", us.Key)
+
continue
+
}
+
if us.Value != expectedValue {
+
t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value)
+
}
+
if us.Repo != tt.queryRepo {
+
t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo)
+
}
+
if us.CreatedBy == "" {
+
t.Error("Expected CreatedBy to be present")
+
}
+
if us.CreatedAt.IsZero() {
+
t.Error("Expected CreatedAt to be set")
+
}
+
}
+
})
+
}
+
}
+
+
// Test that demonstrates interface usage with table-driven tests
+
func TestManagerInterface_Usage(t *testing.T) {
+
tests := []struct {
+
name string
+
operations []func(Manager) error
+
expectError bool
+
}{
+
{
+
name: "successful workflow",
+
operations: []func(Manager) error{
+
func(m Manager) error {
+
secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user")
+
return m.AddSecret(context.Background(), secret)
+
},
+
func(m Manager) error {
+
_, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo"))
+
return err
+
},
+
func(m Manager) error {
+
_, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo"))
+
return err
+
},
+
func(m Manager) error {
+
secret := Secret[any]{
+
Key: "test_key",
+
Repo: DidSlashRepo("interface.test/repo"),
+
}
+
return m.RemoveSecret(context.Background(), secret)
+
},
+
},
+
expectError: false,
+
},
+
{
+
name: "error on duplicate key",
+
operations: []func(Manager) error{
+
func(m Manager) error {
+
secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user")
+
return m.AddSecret(context.Background(), secret)
+
},
+
func(m Manager) error {
+
secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user")
+
return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent
+
},
+
},
+
expectError: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
var manager Manager = createInMemoryDB(t)
+
defer func() {
+
if sqliteManager, ok := manager.(*SqliteManager); ok {
+
sqliteManager.db.Close()
+
}
+
}()
+
+
var finalErr error
+
for i, operation := range tt.operations {
+
if err := operation(manager); err != nil {
+
finalErr = err
+
t.Logf("Operation %d returned error: %v", i, err)
+
}
+
}
+
+
if tt.expectError && finalErr == nil {
+
t.Error("Expected error but got none")
+
}
+
if !tt.expectError && finalErr != nil {
+
t.Errorf("Unexpected error: %v", finalErr)
+
}
+
})
+
}
+
}
+
+
// Integration test with table-driven scenarios
+
func TestSqliteManager_Integration(t *testing.T) {
+
tests := []struct {
+
name string
+
scenario func(*testing.T, *SqliteManager)
+
}{
+
{
+
name: "multi-repo secret management",
+
scenario: func(t *testing.T, manager *SqliteManager) {
+
repo1 := DidSlashRepo("example1.com/repo")
+
repo2 := DidSlashRepo("example2.com/repo")
+
+
secrets := []UnlockedSecret{
+
createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"),
+
createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"),
+
createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"),
+
}
+
+
// Add all secrets
+
for _, secret := range secrets {
+
if err := manager.AddSecret(context.Background(), secret); err != nil {
+
t.Fatalf("Failed to add secret %s: %v", secret.Key, err)
+
}
+
}
+
+
// Verify counts
+
locked1, _ := manager.GetSecretsLocked(context.Background(), repo1)
+
locked2, _ := manager.GetSecretsLocked(context.Background(), repo2)
+
+
if len(locked1) != 2 {
+
t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1))
+
}
+
if len(locked2) != 1 {
+
t.Errorf("Expected 1 secret for repo2, got %d", len(locked2))
+
}
+
+
// Remove and verify
+
secretToRemove := Secret[any]{Key: "db_password", Repo: repo1}
+
if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil {
+
t.Fatalf("Failed to remove secret: %v", err)
+
}
+
+
locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1)
+
if len(locked1After) != 1 {
+
t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After))
+
}
+
if locked1After[0].Key != "api_key" {
+
t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key)
+
}
+
},
+
},
+
{
+
name: "empty database operations",
+
scenario: func(t *testing.T, manager *SqliteManager) {
+
repo := DidSlashRepo("empty.test/repo")
+
+
// Operations on empty database should not error
+
locked, err := manager.GetSecretsLocked(context.Background(), repo)
+
if err != nil {
+
t.Errorf("GetSecretsLocked on empty DB failed: %v", err)
+
}
+
if len(locked) != 0 {
+
t.Errorf("Expected 0 secrets, got %d", len(locked))
+
}
+
+
unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo)
+
if err != nil {
+
t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err)
+
}
+
if len(unlocked) != 0 {
+
t.Errorf("Expected 0 secrets, got %d", len(unlocked))
+
}
+
+
// Remove from empty should return ErrKeyNotFound
+
nonExistent := Secret[any]{Key: "none", Repo: repo}
+
err = manager.RemoveSecret(context.Background(), nonExistent)
+
if err != ErrKeyNotFound {
+
t.Errorf("Expected ErrKeyNotFound, got %v", err)
+
}
+
},
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
manager := createInMemoryDB(t)
+
defer manager.db.Close()
+
tt.scenario(t, manager)
+
})
+
}
+
}
+
+
func TestSqliteManager_StopperInterface(t *testing.T) {
+
manager := &SqliteManager{}
+
+
// Verify that SqliteManager does NOT implement the Stopper interface
+
_, ok := interface{}(manager).(Stopper)
+
assert.False(t, ok, "SqliteManager should NOT implement Stopper interface")
+
}
+134 -50
spindle/server.go
···
import (
"context"
+
_ "embed"
"encoding/json"
"fmt"
"log/slog"
···
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
+
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/jetstream"
"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/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
+
var motd []byte
+
const (
rbacDomain = "thisserver"
)
type Spindle struct {
-
jc *jetstream.JetstreamClient
-
db *db.DB
-
e *rbac.Enforcer
-
l *slog.Logger
-
n *notifier.Notifier
-
eng *engine.Engine
-
jq *queue.Queue
-
cfg *config.Config
-
ks *eventconsumer.Consumer
+
jc *jetstream.JetstreamClient
+
db *db.DB
+
e *rbac.Enforcer
+
l *slog.Logger
+
n *notifier.Notifier
+
engs map[string]models.Engine
+
jq *queue.Queue
+
cfg *config.Config
+
ks *eventconsumer.Consumer
+
res *idresolver.Resolver
+
vault secrets.Manager
}
func Run(ctx context.Context) error {
···
n := notifier.New()
-
eng, err := engine.New(ctx, cfg, d, &n)
+
var vault secrets.Manager
+
switch cfg.Server.Secrets.Provider {
+
case "openbao":
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
+
}
+
vault, err = secrets.NewOpenBaoManager(
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
+
logger,
+
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
+
)
+
if err != nil {
+
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
+
}
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
+
case "sqlite", "":
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
+
if err != nil {
+
return fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
+
}
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
+
default:
+
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
+
}
+
+
nixeryEng, err := nixery.New(ctx, cfg)
if err != nil {
return err
}
-
jq := queue.NewQueue(100, 2)
+
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,
tangled.RepoNSID,
+
tangled.RepoCollaboratorNSID,
}
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
if err != nil {
···
}
jc.AddDid(cfg.Server.Owner)
+
// Check if the spindle knows about any Dids;
+
dids, err := d.GetAllDids()
+
if err != nil {
+
return fmt.Errorf("failed to get all dids: %w", err)
+
}
+
for _, d := range dids {
+
jc.AddDid(d)
+
}
+
+
resolver := idresolver.DefaultResolver()
+
spindle := Spindle{
-
jc: jc,
-
e: e,
-
db: d,
-
l: logger,
-
n: &n,
-
eng: eng,
-
jq: jq,
-
cfg: cfg,
+
jc: jc,
+
e: e,
+
db: d,
+
l: logger,
+
n: &n,
+
engs: map[string]models.Engine{"nixery": nixeryEng},
+
jq: jq,
+
cfg: cfg,
+
res: resolver,
+
vault: vault,
}
err = e.AddSpindle(rbacDomain)
···
jq.Start()
defer jq.Stop()
+
// Stop vault token renewal if it implements Stopper
+
if stopper, ok := vault.(secrets.Stopper); ok {
+
defer stopper.Stop()
+
}
+
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
if err != nil {
return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
···
mux := chi.NewRouter()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-
w.Write([]byte(
-
` ****
-
*** ***
-
*** ** ****** **
-
** * *****
-
* ** **
-
* * * ***************
-
** ** *# **
-
* ** ** *** **
-
* * ** ** * ******
-
* ** ** * ** * *
-
** ** *** ** ** *
-
** ** * ** * *
-
** **** ** * *
-
** *** ** ** **
-
*** ** *****
-
********************
-
**
-
*
-
#**************
-
**
-
********
-
-
This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle`))
+
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())
return mux
}
+
func (s *Spindle) XrpcRouter() http.Handler {
+
logger := s.l.With("route", "xrpc")
+
+
serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String())
+
+
x := xrpc.Xrpc{
+
Logger: logger,
+
Db: s.db,
+
Enforcer: s.e,
+
Engines: s.engs,
+
Config: s.cfg,
+
Resolver: s.res,
+
Vault: s.vault,
+
ServiceAuth: serviceAuth,
+
}
+
+
return x.Router()
+
}
+
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
if msg.Nsid == tangled.PipelineNSID {
tpl := tangled.Pipeline{}
···
return fmt.Errorf("no repo data found")
}
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
+
}
+
// filter by repos
_, err = s.db.GetRepo(
tpl.TriggerMetadata.Repo.Knot,
···
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,
+92
spindle/xrpc/add_secret.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"time"
+
+
"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"
+
"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 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.RepoAddSecret_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
if err := secrets.ValidateKey(data.Key); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
// unfortunately we have to resolve repo-at here
+
repoAt, err := syntax.ParseATURI(data.Repo)
+
if err != nil {
+
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(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(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
+
if err != nil {
+
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, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
secret := secrets.UnlockedSecret{
+
Repo: secrets.DidSlashRepo(didPath),
+
Key: data.Key,
+
Value: data.Value,
+
CreatedAt: time.Now(),
+
CreatedBy: actorDid,
+
}
+
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, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+92
spindle/xrpc/list_secrets.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"time"
+
+
"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"
+
"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 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
+
}
+
+
repoParam := r.URL.Query().Get("repo")
+
if repoParam == "" {
+
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(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(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(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
+
if err != nil {
+
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, 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, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
var out tangled.RepoListSecrets_Output
+
for _, l := range ls {
+
out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{
+
Repo: repoAt.String(),
+
Key: l.Key,
+
CreatedAt: l.CreatedAt.Format(time.RFC3339),
+
CreatedBy: l.CreatedBy.String(),
+
})
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(out)
+
}
+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
+
}
+
}
+83
spindle/xrpc/remove_secret.go
···
+
package xrpc
+
+
import (
+
"encoding/json"
+
"fmt"
+
"net/http"
+
+
"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"
+
"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 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.RepoRemoveSecret_Input
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
fail(xrpcerr.GenericError(err))
+
return
+
}
+
+
// unfortunately we have to resolve repo-at here
+
repoAt, err := syntax.ParseATURI(data.Repo)
+
if err != nil {
+
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(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(xrpcerr.GenericError(err))
+
return
+
}
+
+
repo := resp.Value.Val.(*tangled.Repo)
+
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
+
if err != nil {
+
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, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
+
return
+
}
+
+
secret := secrets.Secret[any]{
+
Repo: secrets.DidSlashRepo(didPath),
+
Key: data.Key,
+
}
+
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, xrpcerr.GenericError(err), http.StatusInternalServerError)
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+59
spindle/xrpc/xrpc.go
···
+
package xrpc
+
+
import (
+
_ "embed"
+
"encoding/json"
+
"log/slog"
+
"net/http"
+
+
"github.com/go-chi/chi/v5"
+
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/idresolver"
+
"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/models"
+
"tangled.sh/tangled.sh/core/spindle/secrets"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
+
)
+
+
const ActorDid string = "ActorDid"
+
+
type Xrpc struct {
+
Logger *slog.Logger
+
Db *db.DB
+
Enforcer *rbac.Enforcer
+
Engines map[string]models.Engine
+
Config *config.Config
+
Resolver *idresolver.Resolver
+
Vault secrets.Manager
+
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.RepoAddSecretNSID, x.AddSecret)
+
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
+
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
+
})
+
+
// service query endpoints (no auth required)
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
+
+
return r
+
}
+
+
// 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)
+
}
+1 -3
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": {},
+
"@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"])
-
}
+115
xrpc/errors/errors.go
···
+
package errors
+
+
import (
+
"encoding/json"
+
"fmt"
+
)
+
+
type XrpcError struct {
+
Tag string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
func (x XrpcError) Error() string {
+
if x.Message != "" {
+
return fmt.Sprintf("%s: %s", x.Tag, x.Message)
+
}
+
return x.Tag
+
}
+
+
func NewXrpcError(opts ...ErrOpt) XrpcError {
+
x := XrpcError{}
+
for _, o := range opts {
+
o(&x)
+
}
+
+
return x
+
}
+
+
type ErrOpt = func(xerr *XrpcError)
+
+
func WithTag(tag string) ErrOpt {
+
return func(xerr *XrpcError) {
+
xerr.Tag = tag
+
}
+
}
+
+
func WithMessage[S ~string](s S) ErrOpt {
+
return func(xerr *XrpcError) {
+
xerr.Message = string(s)
+
}
+
}
+
+
func WithError(e error) ErrOpt {
+
return func(xerr *XrpcError) {
+
xerr.Message = e.Error()
+
}
+
}
+
+
var MissingActorDidError = NewXrpcError(
+
WithTag("MissingActorDid"),
+
WithMessage("actor DID not supplied"),
+
)
+
+
var OwnerNotFoundError = NewXrpcError(
+
WithTag("OwnerNotFound"),
+
WithMessage("owner not set for this service"),
+
)
+
+
var AuthError = func(err error) XrpcError {
+
return NewXrpcError(
+
WithTag("Auth"),
+
WithError(fmt.Errorf("signature verification failed: %w", err)),
+
)
+
}
+
+
var InvalidRepoError = func(r string) XrpcError {
+
return NewXrpcError(
+
WithTag("InvalidRepo"),
+
WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)),
+
)
+
}
+
+
var GitError = func(e error) XrpcError {
+
return NewXrpcError(
+
WithTag("Git"),
+
WithError(fmt.Errorf("git error: %w", e)),
+
)
+
}
+
+
var AccessControlError = func(d string) XrpcError {
+
return NewXrpcError(
+
WithTag("AccessControl"),
+
WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)),
+
)
+
}
+
+
var RepoExistsError = func(r string) XrpcError {
+
return NewXrpcError(
+
WithTag("RepoExists"),
+
WithError(fmt.Errorf("repo already exists: %s", r)),
+
)
+
}
+
+
var RecordExistsError = func(r string) XrpcError {
+
return NewXrpcError(
+
WithTag("RecordExists"),
+
WithError(fmt.Errorf("repo already exists: %s", r)),
+
)
+
}
+
+
func GenericError(err error) XrpcError {
+
return NewXrpcError(
+
WithTag("Generic"),
+
WithError(err),
+
)
+
}
+
+
func Unmarshal(errStr string) (XrpcError, error) {
+
var xerr XrpcError
+
err := json.Unmarshal([]byte(errStr), &xerr)
+
if err != nil {
+
return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err)
+
}
+
return xerr, nil
+
}
+65
xrpc/serviceauth/service_auth.go
···
+
package serviceauth
+
+
import (
+
"context"
+
"encoding/json"
+
"log/slog"
+
"net/http"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/auth"
+
"tangled.sh/tangled.sh/core/idresolver"
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
+
)
+
+
const ActorDid string = "ActorDid"
+
+
type ServiceAuth struct {
+
logger *slog.Logger
+
resolver *idresolver.Resolver
+
audienceDid string
+
}
+
+
func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth {
+
return &ServiceAuth{
+
logger: logger,
+
resolver: resolver,
+
audienceDid: audienceDid,
+
}
+
}
+
+
func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
l := sa.logger.With("url", r.URL)
+
+
token := r.Header.Get("Authorization")
+
token = strings.TrimPrefix(token, "Bearer ")
+
+
s := auth.ServiceAuthValidator{
+
Audience: sa.audienceDid,
+
Dir: sa.resolver.Directory(),
+
}
+
+
did, err := s.Validate(r.Context(), token, nil)
+
if err != nil {
+
l.Error("signature verification failed", "err", err)
+
writeError(w, xrpcerr.AuthError(err), http.StatusForbidden)
+
return
+
}
+
+
r = r.WithContext(
+
context.WithValue(r.Context(), ActorDid, did),
+
)
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
+
// this is slightly different from http_util::write_error to follow the spec:
+
//
+
// the json object returned must include an "error" and a "message"
+
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(status)
+
json.NewEncoder(w).Encode(e)
+
}