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

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc
+474 -2
api/tangled/cbor_gen.go
··· 3923 3923 } 3924 3924 3925 3925 cw := cbg.NewCborWriter(w) 3926 - fieldCount := 3 3926 + fieldCount := 4 3927 3927 3928 3928 if t.Environment == nil { 3929 + fieldCount-- 3930 + } 3931 + 3932 + if t.Oidcs_tokens == nil { 3929 3933 fieldCount-- 3930 3934 } 3931 3935 ··· 4007 4011 4008 4012 } 4009 4013 } 4014 + 4015 + // t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice) 4016 + if t.Oidcs_tokens != nil { 4017 + 4018 + if len("oidcs_tokens") > 1000000 { 4019 + return xerrors.Errorf("Value in field \"oidcs_tokens\" was too long") 4020 + } 4021 + 4022 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oidcs_tokens"))); err != nil { 4023 + return err 4024 + } 4025 + if _, err := cw.WriteString(string("oidcs_tokens")); err != nil { 4026 + return err 4027 + } 4028 + 4029 + if len(t.Oidcs_tokens) > 8192 { 4030 + return xerrors.Errorf("Slice value in field t.Oidcs_tokens was too long") 4031 + } 4032 + 4033 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Oidcs_tokens))); err != nil { 4034 + return err 4035 + } 4036 + for _, v := range t.Oidcs_tokens { 4037 + if err := v.MarshalCBOR(cw); err != nil { 4038 + return err 4039 + } 4040 + 4041 + } 4042 + } 4010 4043 return nil 4011 4044 } 4012 4045 ··· 4035 4068 4036 4069 n := extra 4037 4070 4038 - nameBuf := make([]byte, 11) 4071 + nameBuf := make([]byte, 12) 4039 4072 for i := uint64(0); i < n; i++ { 4040 4073 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4041 4074 if err != nil { ··· 4121 4154 } 4122 4155 4123 4156 } 4157 + } 4158 + // t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice) 4159 + case "oidcs_tokens": 4160 + 4161 + maj, extra, err = cr.ReadHeader() 4162 + if err != nil { 4163 + return err 4164 + } 4165 + 4166 + if extra > 8192 { 4167 + return fmt.Errorf("t.Oidcs_tokens: array too large (%d)", extra) 4168 + } 4169 + 4170 + if maj != cbg.MajArray { 4171 + return fmt.Errorf("expected cbor array") 4172 + } 4173 + 4174 + if extra > 0 { 4175 + t.Oidcs_tokens = make([]*Pipeline_Step_Oidcs_tokens_Elem, extra) 4176 + } 4177 + 4178 + for i := 0; i < int(extra); i++ { 4179 + { 4180 + var maj byte 4181 + var extra uint64 4182 + var err error 4183 + _ = maj 4184 + _ = extra 4185 + _ = err 4186 + 4187 + { 4188 + 4189 + b, err := cr.ReadByte() 4190 + if err != nil { 4191 + return err 4192 + } 4193 + if b != cbg.CborNull[0] { 4194 + if err := cr.UnreadByte(); err != nil { 4195 + return err 4196 + } 4197 + t.Oidcs_tokens[i] = new(Pipeline_Step_Oidcs_tokens_Elem) 4198 + if err := t.Oidcs_tokens[i].UnmarshalCBOR(cr); err != nil { 4199 + return xerrors.Errorf("unmarshaling t.Oidcs_tokens[i] pointer: %w", err) 4200 + } 4201 + } 4202 + 4203 + } 4204 + 4205 + } 4206 + } 4207 + 4208 + default: 4209 + // Field doesn't exist on this type, so ignore it 4210 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4211 + return err 4212 + } 4213 + } 4214 + } 4215 + 4216 + return nil 4217 + } 4218 + func (t *Pipeline_Step_Oidcs_tokens_Elem) MarshalCBOR(w io.Writer) error { 4219 + if t == nil { 4220 + _, err := w.Write(cbg.CborNull) 4221 + return err 4222 + } 4223 + 4224 + cw := cbg.NewCborWriter(w) 4225 + fieldCount := 2 4226 + 4227 + if t.Aud == nil { 4228 + fieldCount-- 4229 + } 4230 + 4231 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 4232 + return err 4233 + } 4234 + 4235 + // t.Aud (string) (string) 4236 + if t.Aud != nil { 4237 + 4238 + if len("aud") > 1000000 { 4239 + return xerrors.Errorf("Value in field \"aud\" was too long") 4240 + } 4241 + 4242 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("aud"))); err != nil { 4243 + return err 4244 + } 4245 + if _, err := cw.WriteString(string("aud")); err != nil { 4246 + return err 4247 + } 4248 + 4249 + if t.Aud == nil { 4250 + if _, err := cw.Write(cbg.CborNull); err != nil { 4251 + return err 4252 + } 4253 + } else { 4254 + if len(*t.Aud) > 1000000 { 4255 + return xerrors.Errorf("Value in field t.Aud was too long") 4256 + } 4257 + 4258 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Aud))); err != nil { 4259 + return err 4260 + } 4261 + if _, err := cw.WriteString(string(*t.Aud)); err != nil { 4262 + return err 4263 + } 4264 + } 4265 + } 4266 + 4267 + // t.Name (string) (string) 4268 + if len("name") > 1000000 { 4269 + return xerrors.Errorf("Value in field \"name\" was too long") 4270 + } 4271 + 4272 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 4273 + return err 4274 + } 4275 + if _, err := cw.WriteString(string("name")); err != nil { 4276 + return err 4277 + } 4278 + 4279 + if len(t.Name) > 1000000 { 4280 + return xerrors.Errorf("Value in field t.Name was too long") 4281 + } 4282 + 4283 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 4284 + return err 4285 + } 4286 + if _, err := cw.WriteString(string(t.Name)); err != nil { 4287 + return err 4288 + } 4289 + return nil 4290 + } 4291 + 4292 + func (t *Pipeline_Step_Oidcs_tokens_Elem) UnmarshalCBOR(r io.Reader) (err error) { 4293 + *t = Pipeline_Step_Oidcs_tokens_Elem{} 4294 + 4295 + cr := cbg.NewCborReader(r) 4296 + 4297 + maj, extra, err := cr.ReadHeader() 4298 + if err != nil { 4299 + return err 4300 + } 4301 + defer func() { 4302 + if err == io.EOF { 4303 + err = io.ErrUnexpectedEOF 4304 + } 4305 + }() 4306 + 4307 + if maj != cbg.MajMap { 4308 + return fmt.Errorf("cbor input should be of type map") 4309 + } 4310 + 4311 + if extra > cbg.MaxLength { 4312 + return fmt.Errorf("Pipeline_Step_Oidcs_tokens_Elem: map struct too large (%d)", extra) 4313 + } 4314 + 4315 + n := extra 4316 + 4317 + nameBuf := make([]byte, 4) 4318 + for i := uint64(0); i < n; i++ { 4319 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4320 + if err != nil { 4321 + return err 4322 + } 4323 + 4324 + if !ok { 4325 + // Field doesn't exist on this type, so ignore it 4326 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4327 + return err 4328 + } 4329 + continue 4330 + } 4331 + 4332 + switch string(nameBuf[:nameLen]) { 4333 + // t.Aud (string) (string) 4334 + case "aud": 4335 + 4336 + { 4337 + b, err := cr.ReadByte() 4338 + if err != nil { 4339 + return err 4340 + } 4341 + if b != cbg.CborNull[0] { 4342 + if err := cr.UnreadByte(); err != nil { 4343 + return err 4344 + } 4345 + 4346 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4347 + if err != nil { 4348 + return err 4349 + } 4350 + 4351 + t.Aud = (*string)(&sval) 4352 + } 4353 + } 4354 + // t.Name (string) (string) 4355 + case "name": 4356 + 4357 + { 4358 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4359 + if err != nil { 4360 + return err 4361 + } 4362 + 4363 + t.Name = string(sval) 4124 4364 } 4125 4365 4126 4366 default: ··· 8423 8663 8424 8664 return nil 8425 8665 } 8666 + func (t *String) MarshalCBOR(w io.Writer) error { 8667 + if t == nil { 8668 + _, err := w.Write(cbg.CborNull) 8669 + return err 8670 + } 8671 + 8672 + cw := cbg.NewCborWriter(w) 8673 + 8674 + if _, err := cw.Write([]byte{165}); err != nil { 8675 + return err 8676 + } 8677 + 8678 + // t.LexiconTypeID (string) (string) 8679 + if len("$type") > 1000000 { 8680 + return xerrors.Errorf("Value in field \"$type\" was too long") 8681 + } 8682 + 8683 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8684 + return err 8685 + } 8686 + if _, err := cw.WriteString(string("$type")); err != nil { 8687 + return err 8688 + } 8689 + 8690 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 8691 + return err 8692 + } 8693 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 8694 + return err 8695 + } 8696 + 8697 + // t.Contents (string) (string) 8698 + if len("contents") > 1000000 { 8699 + return xerrors.Errorf("Value in field \"contents\" was too long") 8700 + } 8701 + 8702 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 8703 + return err 8704 + } 8705 + if _, err := cw.WriteString(string("contents")); err != nil { 8706 + return err 8707 + } 8708 + 8709 + if len(t.Contents) > 1000000 { 8710 + return xerrors.Errorf("Value in field t.Contents was too long") 8711 + } 8712 + 8713 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 8714 + return err 8715 + } 8716 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 8717 + return err 8718 + } 8719 + 8720 + // t.Filename (string) (string) 8721 + if len("filename") > 1000000 { 8722 + return xerrors.Errorf("Value in field \"filename\" was too long") 8723 + } 8724 + 8725 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 8726 + return err 8727 + } 8728 + if _, err := cw.WriteString(string("filename")); err != nil { 8729 + return err 8730 + } 8731 + 8732 + if len(t.Filename) > 1000000 { 8733 + return xerrors.Errorf("Value in field t.Filename was too long") 8734 + } 8735 + 8736 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 8737 + return err 8738 + } 8739 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 8740 + return err 8741 + } 8742 + 8743 + // t.CreatedAt (string) (string) 8744 + if len("createdAt") > 1000000 { 8745 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8746 + } 8747 + 8748 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8749 + return err 8750 + } 8751 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8752 + return err 8753 + } 8754 + 8755 + if len(t.CreatedAt) > 1000000 { 8756 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8757 + } 8758 + 8759 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8760 + return err 8761 + } 8762 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8763 + return err 8764 + } 8765 + 8766 + // t.Description (string) (string) 8767 + if len("description") > 1000000 { 8768 + return xerrors.Errorf("Value in field \"description\" was too long") 8769 + } 8770 + 8771 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 8772 + return err 8773 + } 8774 + if _, err := cw.WriteString(string("description")); err != nil { 8775 + return err 8776 + } 8777 + 8778 + if len(t.Description) > 1000000 { 8779 + return xerrors.Errorf("Value in field t.Description was too long") 8780 + } 8781 + 8782 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 8783 + return err 8784 + } 8785 + if _, err := cw.WriteString(string(t.Description)); err != nil { 8786 + return err 8787 + } 8788 + return nil 8789 + } 8790 + 8791 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 8792 + *t = String{} 8793 + 8794 + cr := cbg.NewCborReader(r) 8795 + 8796 + maj, extra, err := cr.ReadHeader() 8797 + if err != nil { 8798 + return err 8799 + } 8800 + defer func() { 8801 + if err == io.EOF { 8802 + err = io.ErrUnexpectedEOF 8803 + } 8804 + }() 8805 + 8806 + if maj != cbg.MajMap { 8807 + return fmt.Errorf("cbor input should be of type map") 8808 + } 8809 + 8810 + if extra > cbg.MaxLength { 8811 + return fmt.Errorf("String: map struct too large (%d)", extra) 8812 + } 8813 + 8814 + n := extra 8815 + 8816 + nameBuf := make([]byte, 11) 8817 + for i := uint64(0); i < n; i++ { 8818 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8819 + if err != nil { 8820 + return err 8821 + } 8822 + 8823 + if !ok { 8824 + // Field doesn't exist on this type, so ignore it 8825 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8826 + return err 8827 + } 8828 + continue 8829 + } 8830 + 8831 + switch string(nameBuf[:nameLen]) { 8832 + // t.LexiconTypeID (string) (string) 8833 + case "$type": 8834 + 8835 + { 8836 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8837 + if err != nil { 8838 + return err 8839 + } 8840 + 8841 + t.LexiconTypeID = string(sval) 8842 + } 8843 + // t.Contents (string) (string) 8844 + case "contents": 8845 + 8846 + { 8847 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8848 + if err != nil { 8849 + return err 8850 + } 8851 + 8852 + t.Contents = string(sval) 8853 + } 8854 + // t.Filename (string) (string) 8855 + case "filename": 8856 + 8857 + { 8858 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8859 + if err != nil { 8860 + return err 8861 + } 8862 + 8863 + t.Filename = string(sval) 8864 + } 8865 + // t.CreatedAt (string) (string) 8866 + case "createdAt": 8867 + 8868 + { 8869 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8870 + if err != nil { 8871 + return err 8872 + } 8873 + 8874 + t.CreatedAt = string(sval) 8875 + } 8876 + // t.Description (string) (string) 8877 + case "description": 8878 + 8879 + { 8880 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8881 + if err != nil { 8882 + return err 8883 + } 8884 + 8885 + t.Description = string(sval) 8886 + } 8887 + 8888 + default: 8889 + // Field doesn't exist on this type, so ignore it 8890 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8891 + return err 8892 + } 8893 + } 8894 + } 8895 + 8896 + return nil 8897 + }
+9 -3
api/tangled/tangledpipeline.go
··· 63 63 64 64 // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 65 65 type Pipeline_Step struct { 66 - Command string `json:"command" cborgen:"command"` 67 - Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 - Name string `json:"name" cborgen:"name"` 66 + Command string `json:"command" cborgen:"command"` 67 + Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"` 68 + Name string `json:"name" cborgen:"name"` 69 + Oidcs_tokens []*Pipeline_Step_Oidcs_tokens_Elem `json:"oidcs_tokens,omitempty" cborgen:"oidcs_tokens,omitempty"` 70 + } 71 + 72 + type Pipeline_Step_Oidcs_tokens_Elem struct { 73 + Aud *string `json:"aud,omitempty" cborgen:"aud,omitempty"` 74 + Name string `json:"name" cborgen:"name"` 69 75 } 70 76 71 77 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+3
appview/config/config.go
··· 16 16 AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 17 Dev bool `env:"DEV, default=false"` 18 18 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default spindle 21 + AppPassword string `env:"APP_PASSWORD"` 19 22 } 20 23 21 24 type OAuthConfig struct {
+76
appview/db/collaborators.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + type Collaborator struct { 12 + // identifiers for the record 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + 17 + // content 18 + SubjectDid syntax.DID 19 + RepoAt syntax.ATURI 20 + 21 + // meta 22 + Created time.Time 23 + } 24 + 25 + func AddCollaborator(e Execer, c Collaborator) error { 26 + _, err := e.Exec( 27 + `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 + c.Did, c.Rkey, c.SubjectDid, c.RepoAt, 29 + ) 30 + return err 31 + } 32 + 33 + func DeleteCollaborator(e Execer, filters ...filter) error { 34 + var conditions []string 35 + var args []any 36 + for _, filter := range filters { 37 + conditions = append(conditions, filter.Condition()) 38 + args = append(args, filter.Arg()...) 39 + } 40 + 41 + whereClause := "" 42 + if conditions != nil { 43 + whereClause = " where " + strings.Join(conditions, " and ") 44 + } 45 + 46 + query := fmt.Sprintf(`delete from collaborators %s`, whereClause) 47 + 48 + _, err := e.Exec(query, args...) 49 + return err 50 + } 51 + 52 + func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 + rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer rows.Close() 58 + 59 + var repoAts []string 60 + for rows.Next() { 61 + var aturi string 62 + err := rows.Scan(&aturi) 63 + if err != nil { 64 + return nil, err 65 + } 66 + repoAts = append(repoAts, aturi) 67 + } 68 + if err := rows.Err(); err != nil { 69 + return nil, err 70 + } 71 + if repoAts == nil { 72 + return nil, nil 73 + } 74 + 75 + return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 76 + }
+72 -2
appview/db/db.go
··· 443 443 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 444 444 ); 445 445 446 + create table if not exists strings ( 447 + -- identifiers 448 + did text not null, 449 + rkey text not null, 450 + 451 + -- content 452 + filename text not null, 453 + description text, 454 + content text not null, 455 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 456 + edited text, 457 + 458 + primary key (did, rkey) 459 + ); 460 + 446 461 create table if not exists migrations ( 447 462 id integer primary key autoincrement, 448 463 name text unique ··· 585 600 return nil 586 601 }) 587 602 603 + // recreate and add rkey + created columns with default constraint 604 + runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error { 605 + // create new table 606 + // - repo_at instead of repo integer 607 + // - rkey field 608 + // - created field 609 + _, err := tx.Exec(` 610 + create table collaborators_new ( 611 + -- identifiers for the record 612 + id integer primary key autoincrement, 613 + did text not null, 614 + rkey text, 615 + 616 + -- content 617 + subject_did text not null, 618 + repo_at text not null, 619 + 620 + -- meta 621 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 622 + 623 + -- constraints 624 + foreign key (repo_at) references repos(at_uri) on delete cascade 625 + ) 626 + `) 627 + if err != nil { 628 + return err 629 + } 630 + 631 + // copy data 632 + _, err = tx.Exec(` 633 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 634 + select 635 + c.id, 636 + r.did, 637 + '', 638 + c.did, 639 + r.at_uri 640 + from collaborators c 641 + join repos r on c.repo = r.id 642 + `) 643 + if err != nil { 644 + return err 645 + } 646 + 647 + // drop old table 648 + _, err = tx.Exec(`drop table collaborators`) 649 + if err != nil { 650 + return err 651 + } 652 + 653 + // rename new table 654 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 655 + return err 656 + }) 657 + 588 658 return &DB{db}, nil 589 659 } 590 660 ··· 658 728 kind := rv.Kind() 659 729 660 730 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 661 - if kind == reflect.Slice || kind == reflect.Array { 731 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 662 732 if rv.Len() == 0 { 663 733 // always false 664 734 return "1 = 0" ··· 678 748 func (f filter) Arg() []any { 679 749 rv := reflect.ValueOf(f.arg) 680 750 kind := rv.Kind() 681 - if kind == reflect.Slice || kind == reflect.Array { 751 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 682 752 if rv.Len() == 0 { 683 753 return nil 684 754 }
+2 -36
appview/db/repos.go
··· 391 391 var description, spindle sql.NullString 392 392 393 393 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 394 + select did, name, knot, created, at_uri, description, spindle 395 395 from repos 396 396 where did = ? and name = ? 397 397 `, ··· 550 550 return &repo, nil 551 551 } 552 552 553 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 554 - _, err := e.Exec( 555 - `insert into collaborators (did, repo) 556 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 557 - collaborator, repoOwnerDid, repoName, repoKnot) 558 - return err 559 - } 560 - 561 553 func UpdateDescription(e Execer, repoAt, newDescription string) error { 562 554 _, err := e.Exec( 563 555 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 564 556 return err 565 557 } 566 558 567 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 559 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 568 560 _, err := e.Exec( 569 561 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 570 562 return err 571 - } 572 - 573 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 574 - rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator) 575 - if err != nil { 576 - return nil, err 577 - } 578 - defer rows.Close() 579 - 580 - var repoIds []int 581 - for rows.Next() { 582 - var id int 583 - err := rows.Scan(&id) 584 - if err != nil { 585 - return nil, err 586 - } 587 - repoIds = append(repoIds, id) 588 - } 589 - if err := rows.Err(); err != nil { 590 - return nil, err 591 - } 592 - if repoIds == nil { 593 - return nil, nil 594 - } 595 - 596 - return GetRepos(e, 0, FilterIn("id", repoIds)) 597 563 } 598 564 599 565 type RepoStats struct {
+251
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if !strings.Contains(s.Filename, ".") { 54 + err = errors.Join(err, fmt.Errorf("missing filename extension")) 55 + } 56 + 57 + if strings.HasSuffix(s.Filename, ".") { 58 + err = errors.Join(err, fmt.Errorf("filename ends with `.`")) 59 + } 60 + 61 + if utf8.RuneCountInString(s.Filename) > 140 { 62 + err = errors.Join(err, fmt.Errorf("filename too long")) 63 + } 64 + 65 + if utf8.RuneCountInString(s.Description) > 280 { 66 + err = errors.Join(err, fmt.Errorf("description too long")) 67 + } 68 + 69 + if len(s.Contents) == 0 { 70 + err = errors.Join(err, fmt.Errorf("contents is empty")) 71 + } 72 + 73 + return err 74 + } 75 + 76 + func (s *String) AsRecord() tangled.String { 77 + return tangled.String{ 78 + Filename: s.Filename, 79 + Description: s.Description, 80 + Contents: s.Contents, 81 + CreatedAt: s.Created.Format(time.RFC3339), 82 + } 83 + } 84 + 85 + func StringFromRecord(did, rkey string, record tangled.String) String { 86 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 87 + if err != nil { 88 + created = time.Now() 89 + } 90 + return String{ 91 + Did: syntax.DID(did), 92 + Rkey: rkey, 93 + Filename: record.Filename, 94 + Description: record.Description, 95 + Contents: record.Contents, 96 + Created: created, 97 + } 98 + } 99 + 100 + func AddString(e Execer, s String) error { 101 + _, err := e.Exec( 102 + `insert into strings ( 103 + did, 104 + rkey, 105 + filename, 106 + description, 107 + content, 108 + created, 109 + edited 110 + ) 111 + values (?, ?, ?, ?, ?, ?, null) 112 + on conflict(did, rkey) do update set 113 + filename = excluded.filename, 114 + description = excluded.description, 115 + content = excluded.content, 116 + edited = case 117 + when 118 + strings.content != excluded.content 119 + or strings.filename != excluded.filename 120 + or strings.description != excluded.description then ? 121 + else strings.edited 122 + end`, 123 + s.Did, 124 + s.Rkey, 125 + s.Filename, 126 + s.Description, 127 + s.Contents, 128 + s.Created.Format(time.RFC3339), 129 + time.Now().Format(time.RFC3339), 130 + ) 131 + return err 132 + } 133 + 134 + func GetStrings(e Execer, filters ...filter) ([]String, error) { 135 + var all []String 136 + 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 142 + } 143 + 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 147 + } 148 + 149 + query := fmt.Sprintf(`select 150 + did, 151 + rkey, 152 + filename, 153 + description, 154 + content, 155 + created, 156 + edited 157 + from strings %s`, 158 + whereClause, 159 + ) 160 + 161 + rows, err := e.Query(query, args...) 162 + 163 + if err != nil { 164 + return nil, err 165 + } 166 + defer rows.Close() 167 + 168 + for rows.Next() { 169 + var s String 170 + var createdAt string 171 + var editedAt sql.NullString 172 + 173 + if err := rows.Scan( 174 + &s.Did, 175 + &s.Rkey, 176 + &s.Filename, 177 + &s.Description, 178 + &s.Contents, 179 + &createdAt, 180 + &editedAt, 181 + ); err != nil { 182 + return nil, err 183 + } 184 + 185 + s.Created, err = time.Parse(time.RFC3339, createdAt) 186 + if err != nil { 187 + s.Created = time.Now() 188 + } 189 + 190 + if editedAt.Valid { 191 + e, err := time.Parse(time.RFC3339, editedAt.String) 192 + if err != nil { 193 + e = time.Now() 194 + } 195 + s.Edited = &e 196 + } 197 + 198 + all = append(all, s) 199 + } 200 + 201 + if err := rows.Err(); err != nil { 202 + return nil, err 203 + } 204 + 205 + return all, nil 206 + } 207 + 208 + func DeleteString(e Execer, filters ...filter) error { 209 + var conditions []string 210 + var args []any 211 + for _, filter := range filters { 212 + conditions = append(conditions, filter.Condition()) 213 + args = append(args, filter.Arg()...) 214 + } 215 + 216 + whereClause := "" 217 + if conditions != nil { 218 + whereClause = " where " + strings.Join(conditions, " and ") 219 + } 220 + 221 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 222 + 223 + _, err := e.Exec(query, args...) 224 + return err 225 + } 226 + 227 + func countLines(r io.Reader) (int, error) { 228 + buf := make([]byte, 32*1024) 229 + bufLen := 0 230 + count := 0 231 + nl := []byte{'\n'} 232 + 233 + for { 234 + c, err := r.Read(buf) 235 + if c > 0 { 236 + bufLen += c 237 + } 238 + count += bytes.Count(buf[:c], nl) 239 + 240 + switch { 241 + case err == io.EOF: 242 + /* handle last line not having a newline at the end */ 243 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 244 + count++ 245 + } 246 + return count, nil 247 + case err != nil: 248 + return 0, err 249 + } 250 + } 251 + }
+60
appview/ingester.go
··· 64 64 err = i.ingestSpindleMember(e) 65 65 case tangled.SpindleNSID: 66 66 err = i.ingestSpindle(e) 67 + case tangled.StringNSID: 68 + err = i.ingestString(e) 67 69 } 68 70 l = i.Logger.With("nsid", e.Commit.Collection) 69 71 } ··· 385 387 if err != nil { 386 388 return fmt.Errorf("failed to update ACLs: %w", err) 387 389 } 390 + 391 + l.Info("added spindle member") 388 392 case models.CommitOperationDelete: 389 393 rkey := e.Commit.RKey 390 394 ··· 431 435 if err = i.Enforcer.E.SavePolicy(); err != nil { 432 436 return fmt.Errorf("failed to save ACLs: %w", err) 433 437 } 438 + 439 + l.Info("removed spindle member") 434 440 } 435 441 436 442 return nil ··· 549 555 550 556 return nil 551 557 } 558 + 559 + func (i *Ingester) ingestString(e *models.Event) error { 560 + did := e.Did 561 + rkey := e.Commit.RKey 562 + 563 + var err error 564 + 565 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 566 + l.Info("ingesting record") 567 + 568 + ddb, ok := i.Db.Execer.(*db.DB) 569 + if !ok { 570 + return fmt.Errorf("failed to index string record, invalid db cast") 571 + } 572 + 573 + switch e.Commit.Operation { 574 + case models.CommitOperationCreate, models.CommitOperationUpdate: 575 + raw := json.RawMessage(e.Commit.Record) 576 + record := tangled.String{} 577 + err = json.Unmarshal(raw, &record) 578 + if err != nil { 579 + l.Error("invalid record", "err", err) 580 + return err 581 + } 582 + 583 + string := db.StringFromRecord(did, rkey, record) 584 + 585 + if err = string.Validate(); err != nil { 586 + l.Error("invalid record", "err", err) 587 + return err 588 + } 589 + 590 + if err = db.AddString(ddb, string); err != nil { 591 + l.Error("failed to add string", "err", err) 592 + return err 593 + } 594 + 595 + return nil 596 + 597 + case models.CommitOperationDelete: 598 + if err := db.DeleteString( 599 + ddb, 600 + db.FilterEq("did", did), 601 + db.FilterEq("rkey", rkey), 602 + ); err != nil { 603 + l.Error("failed to delete", "err", err) 604 + return fmt.Errorf("failed to delete string record: %w", err) 605 + } 606 + 607 + return nil 608 + } 609 + 610 + return nil 611 + }
+2 -10
appview/middleware/middleware.go
··· 167 167 } 168 168 } 169 169 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 170 func (mw Middleware) ResolveIdent() middlewareFunc { 181 171 excluded := []string{"favicon.ico"} 182 172 ··· 187 177 next.ServeHTTP(w, req) 188 178 return 189 179 } 180 + 181 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 190 182 191 183 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 184 if err != nil {
+141
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 9 11 "strings" 12 + "time" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/gorilla/sessions" 13 16 "github.com/lestrrat-go/jwx/v2/jwk" 14 17 "github.com/posthog/posthog-go" 15 18 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 19 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 20 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 21 "tangled.sh/tangled.sh/core/appview/config" 18 22 "tangled.sh/tangled.sh/core/appview/db" ··· 23 27 "tangled.sh/tangled.sh/core/idresolver" 24 28 "tangled.sh/tangled.sh/core/knotclient" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 294 299 295 300 log.Println("session saved successfully") 296 301 go o.addToDefaultKnot(oauthRequest.Did) 302 + go o.addToDefaultSpindle(oauthRequest.Did) 297 303 298 304 if !o.config.Core.Dev { 299 305 err = o.posthog.Enqueue(posthog.Capture{ ··· 330 336 return nil, err 331 337 } 332 338 return pubKey, nil 339 + } 340 + 341 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 342 + // use the tangled.sh app password to get an accessJwt 343 + // and create an sh.tangled.spindle.member record with that 344 + 345 + defaultSpindle := "spindle.tangled.sh" 346 + appPassword := o.config.Core.AppPassword 347 + 348 + spindleMembers, err := db.GetSpindleMembers( 349 + o.db, 350 + db.FilterEq("instance", "spindle.tangled.sh"), 351 + db.FilterEq("subject", did), 352 + ) 353 + if err != nil { 354 + log.Printf("failed to get spindle members for did %s: %v", did, err) 355 + return 356 + } 357 + 358 + if len(spindleMembers) != 0 { 359 + log.Printf("did %s is already a member of the default spindle", did) 360 + return 361 + } 362 + 363 + // TODO: hardcoded tangled handle and did for now 364 + tangledHandle := "tangled.sh" 365 + tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli" 366 + 367 + if appPassword == "" { 368 + log.Println("no app password configured, skipping spindle member addition") 369 + return 370 + } 371 + 372 + log.Printf("adding %s to default spindle", did) 373 + 374 + resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid) 375 + if err != nil { 376 + log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err) 377 + return 378 + } 379 + 380 + pdsEndpoint := resolved.PDSEndpoint() 381 + if pdsEndpoint == "" { 382 + log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid) 383 + return 384 + } 385 + 386 + sessionPayload := map[string]string{ 387 + "identifier": tangledHandle, 388 + "password": appPassword, 389 + } 390 + sessionBytes, err := json.Marshal(sessionPayload) 391 + if err != nil { 392 + log.Printf("failed to marshal session payload: %v", err) 393 + return 394 + } 395 + 396 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 397 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 398 + if err != nil { 399 + log.Printf("failed to create session request: %v", err) 400 + return 401 + } 402 + sessionReq.Header.Set("Content-Type", "application/json") 403 + 404 + client := &http.Client{Timeout: 30 * time.Second} 405 + sessionResp, err := client.Do(sessionReq) 406 + if err != nil { 407 + log.Printf("failed to create session: %v", err) 408 + return 409 + } 410 + defer sessionResp.Body.Close() 411 + 412 + if sessionResp.StatusCode != http.StatusOK { 413 + log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode) 414 + return 415 + } 416 + 417 + var session struct { 418 + AccessJwt string `json:"accessJwt"` 419 + } 420 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 421 + log.Printf("failed to decode session response: %v", err) 422 + return 423 + } 424 + 425 + record := tangled.SpindleMember{ 426 + LexiconTypeID: "sh.tangled.spindle.member", 427 + Subject: did, 428 + Instance: defaultSpindle, 429 + CreatedAt: time.Now().Format(time.RFC3339), 430 + } 431 + 432 + recordBytes, err := json.Marshal(record) 433 + if err != nil { 434 + log.Printf("failed to marshal spindle member record: %v", err) 435 + return 436 + } 437 + 438 + payload := map[string]interface{}{ 439 + "repo": tangledDid, 440 + "collection": tangled.SpindleMemberNSID, 441 + "rkey": tid.TID(), 442 + "record": json.RawMessage(recordBytes), 443 + } 444 + 445 + payloadBytes, err := json.Marshal(payload) 446 + if err != nil { 447 + log.Printf("failed to marshal request payload: %v", err) 448 + return 449 + } 450 + 451 + url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 452 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 453 + if err != nil { 454 + log.Printf("failed to create HTTP request: %v", err) 455 + return 456 + } 457 + 458 + req.Header.Set("Content-Type", "application/json") 459 + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) 460 + 461 + resp, err := client.Do(req) 462 + if err != nil { 463 + log.Printf("failed to add user to default spindle: %v", err) 464 + return 465 + } 466 + defer resp.Body.Close() 467 + 468 + if resp.StatusCode != http.StatusOK { 469 + log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode) 470 + return 471 + } 472 + 473 + log.Printf("successfully added %s to default spindle", did) 333 474 } 334 475 335 476 func (o *OAuthHandler) addToDefaultKnot(did string) {
+79 -4
appview/pages/pages.go
··· 31 31 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 32 32 "github.com/alecthomas/chroma/v2/lexers" 33 33 "github.com/alecthomas/chroma/v2/styles" 34 + "github.com/bluesky-social/indigo/atproto/identity" 34 35 "github.com/bluesky-social/indigo/atproto/syntax" 35 36 "github.com/go-git/go-git/v5/plumbing" 36 37 "github.com/go-git/go-git/v5/plumbing/object" ··· 262 263 return p.executePlain("user/login", w, params) 263 264 } 264 265 265 - type SignupParams struct{} 266 + func (p *Pages) Signup(w io.Writer) error { 267 + return p.executePlain("user/signup", w, nil) 268 + } 266 269 267 - func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 - return p.executePlain("user/completeSignup", w, params) 270 + func (p *Pages) CompleteSignup(w io.Writer) error { 271 + return p.executePlain("user/completeSignup", w, nil) 269 272 } 270 273 271 274 type TermsOfServiceParams struct { ··· 413 416 UserDid string 414 417 UserHandle string 415 418 FollowStatus db.FollowStatus 416 - AvatarUri string 417 419 Followers int 418 420 Following int 419 421 ··· 1140 1142 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1141 1143 params.Active = "pipelines" 1142 1144 return p.executeRepo("repo/pipelines/workflow", w, params) 1145 + } 1146 + 1147 + type PutStringParams struct { 1148 + LoggedInUser *oauth.User 1149 + Action string 1150 + 1151 + // this is supplied in the case of editing an existing string 1152 + String db.String 1153 + } 1154 + 1155 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1156 + return p.execute("strings/put", w, params) 1157 + } 1158 + 1159 + type StringsDashboardParams struct { 1160 + LoggedInUser *oauth.User 1161 + Card ProfileCard 1162 + Strings []db.String 1163 + } 1164 + 1165 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1166 + return p.execute("strings/dashboard", w, params) 1167 + } 1168 + 1169 + type SingleStringParams struct { 1170 + LoggedInUser *oauth.User 1171 + ShowRendered bool 1172 + RenderToggle bool 1173 + RenderedContents template.HTML 1174 + String db.String 1175 + Stats db.StringStats 1176 + Owner identity.Identity 1177 + } 1178 + 1179 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1180 + var style *chroma.Style = styles.Get("catpuccin-latte") 1181 + 1182 + if params.ShowRendered { 1183 + switch markup.GetFormat(params.String.Filename) { 1184 + case markup.FormatMarkdown: 1185 + p.rctx.RendererType = markup.RendererTypeDefault 1186 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1187 + params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1188 + } 1189 + } 1190 + 1191 + c := params.String.Contents 1192 + formatter := chromahtml.New( 1193 + chromahtml.InlineCode(false), 1194 + chromahtml.WithLineNumbers(true), 1195 + chromahtml.WithLinkableLineNumbers(true, "L"), 1196 + chromahtml.Standalone(false), 1197 + chromahtml.WithClasses(true), 1198 + ) 1199 + 1200 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1201 + if lexer == nil { 1202 + lexer = lexers.Fallback 1203 + } 1204 + 1205 + iterator, err := lexer.Tokenise(nil, c) 1206 + if err != nil { 1207 + return fmt.Errorf("chroma tokenize: %w", err) 1208 + } 1209 + 1210 + var code bytes.Buffer 1211 + err = formatter.Format(&code, style, iterator) 1212 + if err != nil { 1213 + return fmt.Errorf("chroma format: %w", err) 1214 + } 1215 + 1216 + params.String.Contents = code.String() 1217 + return p.execute("strings/string", w, params) 1143 1218 } 1144 1219 1145 1220 func (p *Pages) Static() http.Handler {
+42 -8
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <div class="mb-2"> 5 - <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Terms of Service</a> 6 - &nbsp;โ€ข&nbsp; 7 - <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Privacy Policy</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $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" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 8 19 </div> 9 - <div> 10 - <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> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 11 33 </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 12 45 </div> 46 + </div> 13 47 </div> 14 48 {{ end }}
+25 -16
appview/pages/templates/layouts/topbar.html
··· 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 10 + <div id="right-items" class="flex items-center gap-2"> 23 11 {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 12 + {{ block "newButton" . }} {{ end }} 27 13 {{ block "dropDown" . }} {{ end }} 28 14 {{ else }} 29 15 <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 30 20 {{ end }} 31 21 </div> 32 22 </div> 33 23 </nav> 34 24 {{ end }} 35 25 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left"> 28 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 + {{ i "plus" "w-4 h-4" }} new 30 + </summary> 31 + <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"> 32 + <a href="/repo/new" class="flex items-center gap-2"> 33 + {{ i "book-plus" "w-4 h-4" }} 34 + new repository 35 + </a> 36 + <a href="/strings/new" class="flex items-center gap-2"> 37 + {{ i "line-squiggle" "w-4 h-4" }} 38 + new string 39 + </a> 40 + </div> 41 + </details> 42 + {{ end }} 43 + 36 44 {{ define "dropDown" }} 37 45 <details class="relative inline-block text-left"> 38 46 <summary ··· 46 54 > 47 55 <a href="/{{ $user }}">profile</a> 48 56 <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 49 58 <a href="/knots">knots</a> 50 59 <a href="/spindles">spindles</a> 51 60 <a href="/settings">settings</a>
+3 -1
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }} privacy policy {{ end }} 2 2 {{ define "content" }} 3 3 <div class="max-w-4xl mx-auto px-4 py-8"> 4 - <div class="prose prose-gray dark:prose-invert max-w-none"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 5 6 <h1>Privacy Policy</h1> 6 7 7 8 <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> ··· 125 126 126 127 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 127 128 <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 128 130 </div> 129 131 </div> 130 132 </div>
+3 -1
appview/pages/templates/legal/terms.html
··· 2 2 3 3 {{ define "content" }} 4 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="prose prose-gray dark:prose-invert max-w-none"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 7 <h1>Terms of Service</h1> 7 8 8 9 <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> ··· 63 64 64 65 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 65 66 <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 66 68 </div> 67 69 </div> 68 70 </div>
+4 -2
appview/pages/templates/repo/empty.html
··· 32 32 <div class="py-6 w-fit flex flex-col gap-4"> 33 33 <p>This is an empty repository. To get started:</p> 34 34 {{ $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" }} 35 - <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 - <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> 35 + <p><span class="{{$bullet}}">1</span>Add an SSH public key to your account from the <a href="/settings" class="underline">settings</a> page. 36 + If you don't have one, you can generate a new SSH key pair using the following command: <code>ssh-keygen -t ed25519 -C "you@example.com"</code> 37 + </p> 38 + <p><span class="{{$bullet}}">2</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 37 39 <p><span class="{{$bullet}}">3</span>Push!</p> 38 40 </div> 39 41 </div>
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 19 20 20 {{ define "sidebar" }} 21 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 22 26 {{ with .Pipeline }} 23 27 {{ $id := .Id }} 24 28 <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"> 25 29 {{ range $name, $all := .Statuses }} 26 30 <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"> 27 31 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 29 33 {{ $lastStatus := $all.Latest }} 30 34 {{ $kind := $lastStatus.Status.String }} 31 35
+33 -23
appview/pages/templates/repo/settings/pipelines.html
··· 20 20 <div class="col-span-1 md:col-span-2"> 21 21 <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 - Choose a spindle to execute your workflows on. Spindles can be 24 - selfhosted, 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 25 <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 26 26 click to learn more. 27 27 </a> 28 28 </p> 29 29 </div> 30 - <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"> 31 - <select 32 - id="spindle" 33 - name="spindle" 34 - required 35 - class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 36 - {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 37 - <option value="" disabled selected > 38 - Choose a spindle 39 - </option> 40 - {{ range $.Spindles }} 41 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 42 - {{ . }} 30 + {{ if not $.RepoInfo.Roles.IsOwner }} 31 + <div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 32 + {{ or $.CurrentSpindle "No spindle configured" }} 33 + </div> 34 + {{ else }} 35 + <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"> 36 + <select 37 + id="spindle" 38 + name="spindle" 39 + required 40 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 + {{/* 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? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 44 + Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 43 48 </option> 44 - {{ end }} 45 - </select> 46 - <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 47 - {{ i "check" "size-4" }} 48 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 - </button> 50 - </form> 49 + {{ range $.Spindles }} 50 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 51 + {{ . }} 52 + </option> 53 + {{ end }} 54 + </select> 55 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 56 + {{ i "check" "size-4" }} 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + </form> 60 + {{ end }} 51 61 </div> 52 62 {{ end }} 53 63 ··· 77 87 {{ end }} 78 88 79 89 {{ define "addSecretButton" }} 80 - <button 90 + <button 81 91 class="btn flex items-center gap-2" 82 92 popovertarget="add-secret-modal" 83 93 popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <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"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <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"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
+3 -2
appview/pages/templates/repo/tree.html
··· 61 61 62 62 {{ if .IsFile }} 63 63 {{ $icon = "file" }} 64 - {{ $iconStyle = "size-4" }} 64 + {{ $iconStyle = "flex-shrink-0 size-4" }} 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }}{{ .Name }} 68 + {{ i $icon $iconStyle }} 69 + <span class="truncate">{{ .Name }}</span> 69 70 </div> 70 71 </a> 71 72 </div>
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+89
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename with extension" 17 + required 18 + value="{{ .String.Filename }}" 19 + 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" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 + rows="20" 36 + placeholder="Paste your string here!" 37 + required>{{ .String.Contents }}</textarea> 38 + <div class="flex justify-between items-center"> 39 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 40 + <span id="line-count">0 lines</span> 41 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 42 + <span id="byte-count">0 bytes</span> 43 + </div> 44 + <div id="actions" class="flex gap-2 items-center"> 45 + {{ if eq .Action "edit" }} 46 + <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 " 47 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 48 + {{ i "x" "size-4" }} 49 + <span class="hidden md:inline">cancel</span> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </a> 52 + {{ end }} 53 + <button 54 + type="submit" 55 + id="new-button" 56 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 57 + > 58 + <span class="inline-flex items-center gap-2"> 59 + {{ i "arrow-up" "w-4 h-4" }} 60 + publish 61 + </span> 62 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 63 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 64 + </span> 65 + </button> 66 + </div> 67 + </div> 68 + <script> 69 + (function() { 70 + const textarea = document.getElementById('content-textarea'); 71 + const lineCount = document.getElementById('line-count'); 72 + const byteCount = document.getElementById('byte-count'); 73 + function updateStats() { 74 + const content = textarea.value; 75 + const lines = content === '' ? 0 : content.split('\n').length; 76 + const bytes = new TextEncoder().encode(content).length; 77 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 78 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 79 + } 80 + textarea.addEventListener('input', updateStats); 81 + textarea.addEventListener('paste', () => { 82 + setTimeout(updateStats, 0); 83 + }); 84 + updateStats(); 85 + })(); 86 + </script> 87 + <div id="error" class="error dark:text-red-400"></div> 88 + </form> 89 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+85
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span class="flex items-center"> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 + {{ end }} 52 + 53 + {{ with .String.Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 + {{ end }} 58 + </span> 59 + </section> 60 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 + <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"> 62 + <span>{{ .String.Filename }}</span> 63 + <div> 64 + <span>{{ .Stats.LineCount }} lines</span> 65 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 66 + <span>{{ byteFmt .Stats.ByteCount }}</span> 67 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 68 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 69 + {{ if .RenderToggle }} 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 72 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 73 + </a> 74 + {{ end }} 75 + </div> 76 + </div> 77 + <div class="overflow-auto relative"> 78 + {{ if .ShowRendered }} 79 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 + {{ else }} 81 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 82 + {{ end }} 83 + </div> 84 + </section> 85 + {{ end }}
+2 -2
appview/pages/templates/timeline.html
··· 34 34 </p> 35 35 36 36 <div class="flex gap-6 items-center"> 37 - <a href="/login" class="no-underline hover:no-underline "> 38 - <button class="btn flex gap-2 px-4 items-center"> 37 + <a href="/signup" class="no-underline hover:no-underline "> 38 + <button class="btn-create flex gap-2 px-4 items-center"> 39 39 join now {{ i "arrow-right" "size-4" }} 40 40 </button> 41 41 </a>
+5 -5
appview/pages/templates/user/completeSignup.html
··· 38 38 tightly-knit social coding. 39 39 </h2> 40 40 <form 41 - class="mt-4 max-w-sm mx-auto" 41 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 42 42 hx-post="/signup/complete" 43 43 hx-swap="none" 44 44 hx-disabled-elt="#complete-signup-button" ··· 58 58 </span> 59 59 </div> 60 60 61 - <div class="flex flex-col mt-4"> 62 - <label for="username">desired username</label> 61 + <div class="flex flex-col"> 62 + <label for="username">username</label> 63 63 <input 64 64 type="text" 65 65 id="username" ··· 73 73 </span> 74 74 </div> 75 75 76 - <div class="flex flex-col mt-4"> 76 + <div class="flex flex-col"> 77 77 <label for="password">password</label> 78 78 <input 79 79 type="password" ··· 88 88 </div> 89 89 90 90 <button 91 - class="btn-create w-full my-2 mt-6" 91 + class="btn-create w-full my-2 mt-6 text-base" 92 92 type="submit" 93 93 id="complete-signup-button" 94 94 tabindex="4"
+1 -3
appview/pages/templates/user/fragments/profileCard.html
··· 2 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 10 <p title="{{ didOrHandle .UserDid .UserHandle }}"
+5 -4
appview/pages/templates/user/fragments/repoCard.html
··· 28 28 {{ define "repoStats" }} 29 29 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 30 30 {{ with .Language }} 31 - <div class="flex gap-2 items-center text-sm"> 32 - <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 - <span>{{ . }}</span> 34 - </div> 31 + <div class="flex gap-2 items-center text-sm"> 32 + <div class="size-2 rounded-full" 33 + 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> 34 + <span>{{ . }}</span> 35 + </div> 35 36 {{ end }} 36 37 {{ with .StarCount }} 37 38 <div class="flex gap-1 items-center text-sm">
+11 -79
appview/pages/templates/user/login.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 - <meta 11 - property="og:title" 12 - content="login ยท tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.sh/login" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="login to or sign up for tangled" 21 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 22 10 <script src="/static/htmx.min.js"></script> 23 - <link 24 - rel="stylesheet" 25 - href="/static/tw.css?{{ cssContentHash }}" 26 - type="text/css" 27 - /> 28 - <title>login or sign up &middot; tangled</title> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 35 17 tangled 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 54 - placeholder="foo.tngl.sh" 36 + placeholder="akshay.tngl.sh" 55 37 /> 56 38 <span class="text-sm text-gray-500 mt-1"> 57 39 Use your <a href="https://atproto.com">ATProto</a> ··· 61 43 </div> 62 44 63 45 <button 64 - class="btn w-full my-2 mt-6" 46 + class="btn w-full my-2 mt-6 text-base " 65 47 type="submit" 66 48 id="login-button" 67 49 tabindex="3" ··· 69 51 <span>login</span> 70 52 </button> 71 53 </form> 72 - <hr class="my-4"> 73 - <p class="text-sm text-gray-500 mt-4"> 74 - Alternatively, you may create an account on Tangled below. You will 75 - get a <code>user.tngl.sh</code> handle. 54 + <p class="text-sm text-gray-500"> 55 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 76 56 </p> 77 57 78 - <details class="group"> 79 - 80 - <summary 81 - class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2" 82 - > 83 - create an account 84 - 85 - <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div> 86 - <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div> 87 - </summary> 88 - <form 89 - class="mt-4 max-w-sm mx-auto" 90 - hx-post="/signup" 91 - hx-swap="none" 92 - hx-disabled-elt="#signup-button" 93 - > 94 - <div class="flex flex-col mt-2"> 95 - <label for="email">email</label> 96 - <input 97 - type="email" 98 - id="email" 99 - name="email" 100 - tabindex="4" 101 - required 102 - placeholder="jason@bourne.co" 103 - /> 104 - </div> 105 - <span class="text-sm text-gray-500 mt-1"> 106 - You will receive an email with a code. Enter that, along with your 107 - desired username and password in the next page to complete your registration. 108 - </span> 109 - <button 110 - class="btn w-full my-2 mt-6" 111 - type="submit" 112 - id="signup-button" 113 - tabindex="7" 114 - > 115 - <span>sign up</span> 116 - </button> 117 - </form> 118 - </details> 119 - <p class="text-sm text-gray-500 mt-6"> 120 - Join our <a href="https://chat.tangled.sh">Discord</a> or 121 - IRC channel: 122 - <a href="https://web.libera.chat/#tangled" 123 - ><code>#tangled</code> on Libera Chat</a 124 - >. 125 - </p> 126 58 <p id="login-msg" class="error w-full"></p> 127 59 </main> 128 60 </body>
+53
appview/pages/templates/user/signup.html
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 + <form 19 + class="mt-4 max-w-sm mx-auto" 20 + hx-post="/signup" 21 + hx-swap="none" 22 + hx-disabled-elt="#signup-button" 23 + > 24 + <div class="flex flex-col mt-2"> 25 + <label for="email">email</label> 26 + <input 27 + type="email" 28 + id="email" 29 + name="email" 30 + tabindex="4" 31 + required 32 + placeholder="jason@bourne.co" 33 + /> 34 + </div> 35 + <span class="text-sm text-gray-500 mt-1"> 36 + You will receive an email with an invite code. Enter your 37 + invite code, desired username, and password in the next 38 + page to complete your registration. 39 + </span> 40 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 41 + <span>join now</span> 42 + </button> 43 + </form> 44 + <p class="text-sm text-gray-500"> 45 + Already have an account? <a href="/login" class="underline">Login to Tangled</a>. 46 + </p> 47 + 48 + <p id="signup-msg" class="error w-full"></p> 49 + </main> 50 + </body> 51 + </html> 52 + {{ end }} 53 +
+69 -20
appview/repo/repo.go
··· 39 39 "github.com/go-git/go-git/v5/plumbing" 40 40 41 41 comatproto "github.com/bluesky-social/indigo/api/atproto" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 42 43 lexutil "github.com/bluesky-social/indigo/lex/util" 43 44 ) 44 45 ··· 656 657 } 657 658 658 659 newSpindle := r.FormValue("spindle") 660 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 659 661 client, err := rp.oauth.AuthorizedClient(r) 660 662 if err != nil { 661 663 fail("Failed to authorize. Try again later.", err) 662 664 return 663 665 } 664 666 665 - // ensure that this is a valid spindle for this user 666 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 667 - if err != nil { 668 - fail("Failed to find spindles. Try again later.", err) 669 - return 667 + if !removingSpindle { 668 + // ensure that this is a valid spindle for this user 669 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 670 + if err != nil { 671 + fail("Failed to find spindles. Try again later.", err) 672 + return 673 + } 674 + 675 + if !slices.Contains(validSpindles, newSpindle) { 676 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 677 + return 678 + } 670 679 } 671 680 672 - if !slices.Contains(validSpindles, newSpindle) { 673 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 674 - return 681 + spindlePtr := &newSpindle 682 + if removingSpindle { 683 + spindlePtr = nil 675 684 } 676 685 677 686 // optimistic update 678 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 687 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 679 688 if err != nil { 680 689 fail("Failed to update spindle. Try again later.", err) 681 690 return ··· 698 707 Owner: user.Did, 699 708 CreatedAt: f.CreatedAt, 700 709 Description: &f.Description, 701 - Spindle: &newSpindle, 710 + Spindle: spindlePtr, 702 711 }, 703 712 }, 704 713 }) ··· 708 717 return 709 718 } 710 719 711 - // add this spindle to spindle stream 712 - rp.spindlestream.AddSource( 713 - context.Background(), 714 - eventconsumer.NewSpindleSource(newSpindle), 715 - ) 720 + if !removingSpindle { 721 + // add this spindle to spindle stream 722 + rp.spindlestream.AddSource( 723 + context.Background(), 724 + eventconsumer.NewSpindleSource(newSpindle), 725 + ) 726 + } 716 727 717 728 rp.pages.HxRefresh(w) 718 729 } ··· 741 752 return 742 753 } 743 754 755 + // remove a single leading `@`, to make @handle work with ResolveIdent 756 + collaborator = strings.TrimPrefix(collaborator, "@") 757 + 744 758 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 745 759 if err != nil { 746 760 fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) ··· 751 765 fail("You seem to be adding yourself as a collaborator.", nil) 752 766 return 753 767 } 754 - 755 768 l = l.With("collaborator", collaboratorIdent.Handle) 756 769 l = l.With("knot", f.Knot) 757 - l.Info("adding to knot") 770 + 771 + // announce this relation into the firehose, store into owners' pds 772 + client, err := rp.oauth.AuthorizedClient(r) 773 + if err != nil { 774 + fail("Failed to write to PDS.", err) 775 + return 776 + } 758 777 778 + // emit a record 779 + currentUser := rp.oauth.GetUser(r) 780 + rkey := tid.TID() 781 + createdAt := time.Now() 782 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 783 + Collection: tangled.RepoCollaboratorNSID, 784 + Repo: currentUser.Did, 785 + Rkey: rkey, 786 + Record: &lexutil.LexiconTypeDecoder{ 787 + Val: &tangled.RepoCollaborator{ 788 + Subject: collaboratorIdent.DID.String(), 789 + Repo: string(f.RepoAt), 790 + CreatedAt: createdAt.Format(time.RFC3339), 791 + }}, 792 + }) 793 + // invalid record 794 + if err != nil { 795 + fail("Failed to write record to PDS.", err) 796 + return 797 + } 798 + l = l.With("at-uri", resp.Uri) 799 + l.Info("wrote record to PDS") 800 + 801 + l.Info("adding to knot") 759 802 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 760 803 if err != nil { 761 804 fail("Failed to add to knot.", err) ··· 798 841 return 799 842 } 800 843 801 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 844 + err = db.AddCollaborator(rp.db, db.Collaborator{ 845 + Did: syntax.DID(currentUser.Did), 846 + Rkey: rkey, 847 + SubjectDid: collaboratorIdent.DID, 848 + RepoAt: f.RepoAt, 849 + Created: createdAt, 850 + }) 802 851 if err != nil { 803 852 fail("Failed to add collaborator.", err) 804 853 return ··· 1189 1238 f, err := rp.repoResolver.Resolve(r) 1190 1239 user := rp.oauth.GetUser(r) 1191 1240 1192 - // all spindles that this user is a member of 1193 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1241 + // all spindles that the repo owner is a member of 1242 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1194 1243 if err != nil { 1195 1244 log.Println("failed to fetch spindles", err) 1196 1245 return
+57 -50
appview/signup/signup.go
··· 104 104 105 105 func (s *Signup) Router() http.Handler { 106 106 r := chi.NewRouter() 107 + r.Get("/", s.signup) 107 108 r.Post("/", s.signup) 108 109 r.Get("/complete", s.complete) 109 110 r.Post("/complete", s.complete) ··· 112 113 } 113 114 114 115 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 115 - if s.cf == nil { 116 - http.Error(w, "signup is disabled", http.StatusFailedDependency) 117 - } 118 - emailId := r.FormValue("email") 116 + switch r.Method { 117 + case http.MethodGet: 118 + s.pages.Signup(w) 119 + case http.MethodPost: 120 + if s.cf == nil { 121 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 + } 123 + emailId := r.FormValue("email") 119 124 120 - if !email.IsValidEmail(emailId) { 121 - s.pages.Notice(w, "login-msg", "Invalid email address.") 122 - return 123 - } 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 124 130 125 - exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 126 - if err != nil { 127 - s.l.Error("failed to check email existence", "error", err) 128 - s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 129 - return 130 - } 131 - if exists { 132 - s.pages.Notice(w, "login-msg", "Email already exists.") 133 - return 134 - } 131 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 + if err != nil { 133 + s.l.Error("failed to check email existence", "error", err) 134 + s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 + return 136 + } 137 + if exists { 138 + s.pages.Notice(w, noticeId, "Email already exists.") 139 + return 140 + } 135 141 136 - code, err := s.inviteCodeRequest() 137 - if err != nil { 138 - s.l.Error("failed to create invite code", "error", err) 139 - s.pages.Notice(w, "login-msg", "Failed to create invite code.") 140 - return 141 - } 142 + code, err := s.inviteCodeRequest() 143 + if err != nil { 144 + s.l.Error("failed to create invite code", "error", err) 145 + s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 + return 147 + } 142 148 143 - em := email.Email{ 144 - APIKey: s.config.Resend.ApiKey, 145 - From: s.config.Resend.SentFrom, 146 - To: emailId, 147 - Subject: "Verify your Tangled account", 148 - Text: `Copy and paste this code below to verify your account on Tangled. 149 + em := email.Email{ 150 + APIKey: s.config.Resend.ApiKey, 151 + From: s.config.Resend.SentFrom, 152 + To: emailId, 153 + Subject: "Verify your Tangled account", 154 + Text: `Copy and paste this code below to verify your account on Tangled. 149 155 ` + code, 150 - Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 151 157 <p><code>` + code + `</code></p>`, 152 - } 158 + } 159 + 160 + err = email.SendEmail(em) 161 + if err != nil { 162 + s.l.Error("failed to send email", "error", err) 163 + s.pages.Notice(w, noticeId, "Failed to send email.") 164 + return 165 + } 166 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 + Email: emailId, 168 + InviteCode: code, 169 + }) 170 + if err != nil { 171 + s.l.Error("failed to add inflight signup", "error", err) 172 + s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 173 + return 174 + } 153 175 154 - err = email.SendEmail(em) 155 - if err != nil { 156 - s.l.Error("failed to send email", "error", err) 157 - s.pages.Notice(w, "login-msg", "Failed to send email.") 158 - return 176 + s.pages.HxRedirect(w, "/signup/complete") 159 177 } 160 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 161 - Email: emailId, 162 - InviteCode: code, 163 - }) 164 - if err != nil { 165 - s.l.Error("failed to add inflight signup", "error", err) 166 - s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 167 - return 168 - } 169 - 170 - s.pages.HxRedirect(w, "/signup/complete") 171 178 } 172 179 173 180 func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 174 181 switch r.Method { 175 182 case http.MethodGet: 176 - s.pages.CompleteSignup(w, pages.SignupParams{}) 183 + s.pages.CompleteSignup(w) 177 184 case http.MethodPost: 178 185 username := r.FormValue("username") 179 186 password := r.FormValue("password") ··· 212 219 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 213 220 Type: "TXT", 214 221 Name: "_atproto." + username, 215 - Content: "did=" + did, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 216 223 TTL: 6400, 217 224 Proxied: false, 218 225 })
+4 -4
appview/spindles/spindles.go
··· 619 619 620 620 if string(spindles[0].Owner) != user.Did { 621 621 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 622 - s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 622 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 623 623 return 624 624 } 625 625 626 626 member := r.FormValue("member") 627 627 if member == "" { 628 628 l.Error("empty member") 629 - s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 629 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 630 630 return 631 631 } 632 632 l = l.With("member", member) ··· 634 634 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 635 635 if err != nil { 636 636 l.Error("failed to resolve member identity to handle", "err", err) 637 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 637 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 638 638 return 639 639 } 640 640 if memberId.Handle.IsInvalidHandle() { 641 641 l.Error("failed to resolve member identity to handle") 642 - s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 642 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 643 643 return 644 644 } 645 645
-16
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 142 139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 143 140 } 144 141 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 142 s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 143 LoggedInUser: loggedInUser, 148 144 Repos: pinnedRepos, ··· 151 147 Card: pages.ProfileCard{ 152 148 UserDid: ident.DID.String(), 153 149 UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 150 Profile: profile, 156 151 FollowStatus: followStatus, 157 152 Followers: followers, ··· 194 189 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 195 190 } 196 191 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 198 - 199 192 s.pages.ReposPage(w, pages.ReposPageParams{ 200 193 LoggedInUser: loggedInUser, 201 194 Repos: repos, ··· 203 196 Card: pages.ProfileCard{ 204 197 UserDid: ident.DID.String(), 205 198 UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 199 Profile: profile, 208 200 FollowStatus: followStatus, 209 201 Followers: followers, 210 202 Following: following, 211 203 }, 212 204 }) 213 - } 214 - 215 - func (s *State) GetAvatarUri(handle string) string { 216 - secret := s.config.Avatar.SharedSecret 217 - h := hmac.New(sha256.New, []byte(secret)) 218 - h.Write([]byte(handle)) 219 - signature := hex.EncodeToString(h.Sum(nil)) 220 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 221 205 } 222 206 223 207 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+19 -3
appview/state/router.go
··· 17 17 "tangled.sh/tangled.sh/core/appview/signup" 18 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 20 21 "tangled.sh/tangled.sh/core/log" 21 22 ) 22 23 ··· 67 68 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 68 69 r := chi.NewRouter() 69 70 70 - // strip @ from user 71 - r.Use(middleware.StripLeadingAt) 72 - 73 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 74 72 r.Get("/", s.Profile) 75 73 ··· 136 134 }) 137 135 138 136 r.Mount("/settings", s.SettingsRouter()) 137 + r.Mount("/strings", s.StringsRouter(mw)) 139 138 r.Mount("/knots", s.KnotsRouter(mw)) 140 139 r.Mount("/spindles", s.SpindlesRouter()) 141 140 r.Mount("/signup", s.SignupRouter()) ··· 199 198 } 200 199 201 200 return knots.Router(mw) 201 + } 202 + 203 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 204 + logger := log.New("strings") 205 + 206 + strs := &avstrings.Strings{ 207 + Db: s.db, 208 + OAuth: s.oauth, 209 + Pages: s.pages, 210 + Config: s.config, 211 + Enforcer: s.enforcer, 212 + IdResolver: s.idResolver, 213 + Knotstream: s.knotstream, 214 + Logger: logger, 215 + } 216 + 217 + return strs.Router(mw) 202 218 } 203 219 204 220 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+1
appview/state/state.go
··· 93 93 tangled.ActorProfileNSID, 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 96 97 }, 97 98 nil, 98 99 slog.Default(),
+454
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "slices" 9 + "strconv" 10 + "strings" 11 + "time" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/appview/config" 15 + "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/middleware" 17 + "tangled.sh/tangled.sh/core/appview/oauth" 18 + "tangled.sh/tangled.sh/core/appview/pages" 19 + "tangled.sh/tangled.sh/core/appview/pages/markup" 20 + "tangled.sh/tangled.sh/core/eventconsumer" 21 + "tangled.sh/tangled.sh/core/idresolver" 22 + "tangled.sh/tangled.sh/core/rbac" 23 + "tangled.sh/tangled.sh/core/tid" 24 + 25 + "github.com/bluesky-social/indigo/api/atproto" 26 + "github.com/bluesky-social/indigo/atproto/identity" 27 + "github.com/bluesky-social/indigo/atproto/syntax" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 + "github.com/go-chi/chi/v5" 30 + ) 31 + 32 + type Strings struct { 33 + Db *db.DB 34 + OAuth *oauth.OAuth 35 + Pages *pages.Pages 36 + Config *config.Config 37 + Enforcer *rbac.Enforcer 38 + IdResolver *idresolver.Resolver 39 + Logger *slog.Logger 40 + Knotstream *eventconsumer.Consumer 41 + } 42 + 43 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 44 + r := chi.NewRouter() 45 + 46 + r. 47 + With(mw.ResolveIdent()). 48 + Route("/{user}", func(r chi.Router) { 49 + r.Get("/", s.dashboard) 50 + 51 + r.Route("/{rkey}", func(r chi.Router) { 52 + r.Get("/", s.contents) 53 + r.Delete("/", s.delete) 54 + r.Get("/raw", s.contents) 55 + r.Get("/edit", s.edit) 56 + r.Post("/edit", s.edit) 57 + r. 58 + With(middleware.AuthMiddleware(s.OAuth)). 59 + Post("/comment", s.comment) 60 + }) 61 + }) 62 + 63 + r. 64 + With(middleware.AuthMiddleware(s.OAuth)). 65 + Route("/new", func(r chi.Router) { 66 + r.Get("/", s.create) 67 + r.Post("/", s.create) 68 + }) 69 + 70 + return r 71 + } 72 + 73 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 74 + l := s.Logger.With("handler", "contents") 75 + 76 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 77 + if !ok { 78 + l.Error("malformed middleware") 79 + w.WriteHeader(http.StatusInternalServerError) 80 + return 81 + } 82 + l = l.With("did", id.DID, "handle", id.Handle) 83 + 84 + rkey := chi.URLParam(r, "rkey") 85 + if rkey == "" { 86 + l.Error("malformed url, empty rkey") 87 + w.WriteHeader(http.StatusBadRequest) 88 + return 89 + } 90 + l = l.With("rkey", rkey) 91 + 92 + strings, err := db.GetStrings( 93 + s.Db, 94 + db.FilterEq("did", id.DID), 95 + db.FilterEq("rkey", rkey), 96 + ) 97 + if err != nil { 98 + l.Error("failed to fetch string", "err", err) 99 + w.WriteHeader(http.StatusInternalServerError) 100 + return 101 + } 102 + if len(strings) < 1 { 103 + l.Error("string not found") 104 + s.Pages.Error404(w) 105 + return 106 + } 107 + if len(strings) != 1 { 108 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 109 + w.WriteHeader(http.StatusInternalServerError) 110 + return 111 + } 112 + string := strings[0] 113 + 114 + if path.Base(r.URL.Path) == "raw" { 115 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 116 + if string.Filename != "" { 117 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 118 + } 119 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 120 + 121 + _, err = w.Write([]byte(string.Contents)) 122 + if err != nil { 123 + l.Error("failed to write raw response", "err", err) 124 + } 125 + return 126 + } 127 + 128 + var showRendered, renderToggle bool 129 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 130 + renderToggle = true 131 + showRendered = r.URL.Query().Get("code") != "true" 132 + } 133 + 134 + s.Pages.SingleString(w, pages.SingleStringParams{ 135 + LoggedInUser: s.OAuth.GetUser(r), 136 + RenderToggle: renderToggle, 137 + ShowRendered: showRendered, 138 + String: string, 139 + Stats: string.Stats(), 140 + Owner: id, 141 + }) 142 + } 143 + 144 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 145 + l := s.Logger.With("handler", "dashboard") 146 + 147 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 148 + if !ok { 149 + l.Error("malformed middleware") 150 + w.WriteHeader(http.StatusInternalServerError) 151 + return 152 + } 153 + l = l.With("did", id.DID, "handle", id.Handle) 154 + 155 + all, err := db.GetStrings( 156 + s.Db, 157 + db.FilterEq("did", id.DID), 158 + ) 159 + if err != nil { 160 + l.Error("failed to fetch strings", "err", err) 161 + w.WriteHeader(http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + slices.SortFunc(all, func(a, b db.String) int { 166 + if a.Created.After(b.Created) { 167 + return -1 168 + } else { 169 + return 1 170 + } 171 + }) 172 + 173 + profile, err := db.GetProfile(s.Db, id.DID.String()) 174 + if err != nil { 175 + l.Error("failed to fetch user profile", "err", err) 176 + w.WriteHeader(http.StatusInternalServerError) 177 + return 178 + } 179 + loggedInUser := s.OAuth.GetUser(r) 180 + followStatus := db.IsNotFollowing 181 + if loggedInUser != nil { 182 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 183 + } 184 + 185 + followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 186 + if err != nil { 187 + l.Error("failed to get follow stats", "err", err) 188 + } 189 + 190 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 191 + LoggedInUser: s.OAuth.GetUser(r), 192 + Card: pages.ProfileCard{ 193 + UserDid: id.DID.String(), 194 + UserHandle: id.Handle.String(), 195 + Profile: profile, 196 + FollowStatus: followStatus, 197 + Followers: followers, 198 + Following: following, 199 + }, 200 + Strings: all, 201 + }) 202 + } 203 + 204 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 205 + l := s.Logger.With("handler", "edit") 206 + 207 + user := s.OAuth.GetUser(r) 208 + 209 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 210 + if !ok { 211 + l.Error("malformed middleware") 212 + w.WriteHeader(http.StatusInternalServerError) 213 + return 214 + } 215 + l = l.With("did", id.DID, "handle", id.Handle) 216 + 217 + rkey := chi.URLParam(r, "rkey") 218 + if rkey == "" { 219 + l.Error("malformed url, empty rkey") 220 + w.WriteHeader(http.StatusBadRequest) 221 + return 222 + } 223 + l = l.With("rkey", rkey) 224 + 225 + // get the string currently being edited 226 + all, err := db.GetStrings( 227 + s.Db, 228 + db.FilterEq("did", id.DID), 229 + db.FilterEq("rkey", rkey), 230 + ) 231 + if err != nil { 232 + l.Error("failed to fetch string", "err", err) 233 + w.WriteHeader(http.StatusInternalServerError) 234 + return 235 + } 236 + if len(all) != 1 { 237 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 238 + w.WriteHeader(http.StatusInternalServerError) 239 + return 240 + } 241 + first := all[0] 242 + 243 + // verify that the logged in user owns this string 244 + if user.Did != id.DID.String() { 245 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 246 + w.WriteHeader(http.StatusUnauthorized) 247 + return 248 + } 249 + 250 + switch r.Method { 251 + case http.MethodGet: 252 + // return the form with prefilled fields 253 + s.Pages.PutString(w, pages.PutStringParams{ 254 + LoggedInUser: s.OAuth.GetUser(r), 255 + Action: "edit", 256 + String: first, 257 + }) 258 + case http.MethodPost: 259 + fail := func(msg string, err error) { 260 + l.Error(msg, "err", err) 261 + s.Pages.Notice(w, "error", msg) 262 + } 263 + 264 + filename := r.FormValue("filename") 265 + if filename == "" { 266 + fail("Empty filename.", nil) 267 + return 268 + } 269 + if !strings.Contains(filename, ".") { 270 + // TODO: make this a htmx form validation 271 + fail("No extension provided for filename.", nil) 272 + return 273 + } 274 + 275 + content := r.FormValue("content") 276 + if content == "" { 277 + fail("Empty contents.", nil) 278 + return 279 + } 280 + 281 + description := r.FormValue("description") 282 + 283 + // construct new string from form values 284 + entry := db.String{ 285 + Did: first.Did, 286 + Rkey: first.Rkey, 287 + Filename: filename, 288 + Description: description, 289 + Contents: content, 290 + Created: first.Created, 291 + } 292 + 293 + record := entry.AsRecord() 294 + 295 + client, err := s.OAuth.AuthorizedClient(r) 296 + if err != nil { 297 + fail("Failed to create record.", err) 298 + return 299 + } 300 + 301 + // first replace the existing record in the PDS 302 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 303 + if err != nil { 304 + fail("Failed to updated existing record.", err) 305 + return 306 + } 307 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 308 + Collection: tangled.StringNSID, 309 + Repo: entry.Did.String(), 310 + Rkey: entry.Rkey, 311 + SwapRecord: ex.Cid, 312 + Record: &lexutil.LexiconTypeDecoder{ 313 + Val: &record, 314 + }, 315 + }) 316 + if err != nil { 317 + fail("Failed to updated existing record.", err) 318 + return 319 + } 320 + l := l.With("aturi", resp.Uri) 321 + l.Info("edited string") 322 + 323 + // if that went okay, updated the db 324 + if err = db.AddString(s.Db, entry); err != nil { 325 + fail("Failed to update string.", err) 326 + return 327 + } 328 + 329 + // if that went okay, redir to the string 330 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 331 + } 332 + 333 + } 334 + 335 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 336 + l := s.Logger.With("handler", "create") 337 + user := s.OAuth.GetUser(r) 338 + 339 + switch r.Method { 340 + case http.MethodGet: 341 + s.Pages.PutString(w, pages.PutStringParams{ 342 + LoggedInUser: s.OAuth.GetUser(r), 343 + Action: "new", 344 + }) 345 + case http.MethodPost: 346 + fail := func(msg string, err error) { 347 + l.Error(msg, "err", err) 348 + s.Pages.Notice(w, "error", msg) 349 + } 350 + 351 + filename := r.FormValue("filename") 352 + if filename == "" { 353 + fail("Empty filename.", nil) 354 + return 355 + } 356 + if !strings.Contains(filename, ".") { 357 + // TODO: make this a htmx form validation 358 + fail("No extension provided for filename.", nil) 359 + return 360 + } 361 + 362 + content := r.FormValue("content") 363 + if content == "" { 364 + fail("Empty contents.", nil) 365 + return 366 + } 367 + 368 + description := r.FormValue("description") 369 + 370 + string := db.String{ 371 + Did: syntax.DID(user.Did), 372 + Rkey: tid.TID(), 373 + Filename: filename, 374 + Description: description, 375 + Contents: content, 376 + Created: time.Now(), 377 + } 378 + 379 + record := string.AsRecord() 380 + 381 + client, err := s.OAuth.AuthorizedClient(r) 382 + if err != nil { 383 + fail("Failed to create record.", err) 384 + return 385 + } 386 + 387 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 388 + Collection: tangled.StringNSID, 389 + Repo: user.Did, 390 + Rkey: string.Rkey, 391 + Record: &lexutil.LexiconTypeDecoder{ 392 + Val: &record, 393 + }, 394 + }) 395 + if err != nil { 396 + fail("Failed to create record.", err) 397 + return 398 + } 399 + l := l.With("aturi", resp.Uri) 400 + l.Info("created record") 401 + 402 + // insert into DB 403 + if err = db.AddString(s.Db, string); err != nil { 404 + fail("Failed to create string.", err) 405 + return 406 + } 407 + 408 + // successful 409 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 410 + } 411 + } 412 + 413 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 414 + l := s.Logger.With("handler", "create") 415 + user := s.OAuth.GetUser(r) 416 + fail := func(msg string, err error) { 417 + l.Error(msg, "err", err) 418 + s.Pages.Notice(w, "error", msg) 419 + } 420 + 421 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 422 + if !ok { 423 + l.Error("malformed middleware") 424 + w.WriteHeader(http.StatusInternalServerError) 425 + return 426 + } 427 + l = l.With("did", id.DID, "handle", id.Handle) 428 + 429 + rkey := chi.URLParam(r, "rkey") 430 + if rkey == "" { 431 + l.Error("malformed url, empty rkey") 432 + w.WriteHeader(http.StatusBadRequest) 433 + return 434 + } 435 + 436 + if user.Did != id.DID.String() { 437 + fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 438 + return 439 + } 440 + 441 + if err := db.DeleteString( 442 + s.Db, 443 + db.FilterEq("did", user.Did), 444 + db.FilterEq("rkey", rkey), 445 + ); err != nil { 446 + fail("Failed to delete string.", err) 447 + return 448 + } 449 + 450 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 451 + } 452 + 453 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 454 + }
+2
cmd/gen.go
··· 34 34 tangled.Pipeline_PushTriggerData{}, 35 35 tangled.PipelineStatus{}, 36 36 tangled.Pipeline_Step{}, 37 + tangled.Pipeline_Step_Oidcs_tokens_Elem{}, 37 38 tangled.Pipeline_TriggerMetadata{}, 38 39 tangled.Pipeline_TriggerRepo{}, 39 40 tangled.Pipeline_Workflow{}, ··· 50 51 tangled.RepoPullStatus{}, 51 52 tangled.Spindle{}, 52 53 tangled.SpindleMember{}, 54 + tangled.String{}, 53 55 ); err != nil { 54 56 panic(err) 55 57 }
+9 -10
docs/hacking.md
··· 56 56 `nixosConfiguration` to do so. 57 57 58 58 To begin, head to `http://localhost:3000/knots` in the browser 59 - and generate a knot secret. Replace the existing secret in 60 - `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 - secret. 59 + and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it, 60 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 61 + don't lose it. 62 62 63 63 You can now start a lightweight NixOS VM using 64 64 `nixos-shell` like so: ··· 91 91 92 92 ## running a spindle 93 93 94 - Be sure to change the `owner` field for the spindle in 95 - `nix/vm.nix` to your own DID. The above VM should already 96 - be running a spindle on `localhost:6555`. You can head to 97 - the spindle dashboard on `http://localhost:3000/spindles`, 98 - and register a spindle with hostname `localhost:6555`. It 99 - should instantly be verified. You can then configure each 100 - repository to use this spindle and run CI jobs. 94 + Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID. 95 + The above VM should already be running a spindle on `localhost:6555`. 96 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 97 + and register a spindle with hostname `localhost:6555`. It should instantly 98 + be verified. You can then configure each repository to use this spindle 99 + and run CI jobs. 101 100 102 101 Of interest when debugging spindles: 103 102
+1 -1
docs/knot-hosting.md
··· 89 89 systemctl start knotserver 90 90 ``` 91 91 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 92 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 93 93 knot. Here's an example configuration for Nginx: 94 94 95 95 ```
+1 -1
docs/spindle/openbao.md
··· 114 114 ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 115 116 116 # Generate secret ID 117 - SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id) 117 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 118 119 119 echo "Role ID: $ROLE_ID" 120 120 echo "Secret ID: $SECRET_ID"
+7 -28
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "gitignore": { 4 - "inputs": { 5 - "nixpkgs": [ 6 - "nixpkgs" 7 - ] 8 - }, 9 - "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 15 - "type": "github" 16 - }, 17 - "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 20 - "type": "github" 21 - } 22 - }, 23 3 "flake-utils": { 24 4 "inputs": { 25 5 "systems": "systems" ··· 99 79 "indigo": { 100 80 "flake": false, 101 81 "locked": { 102 - "lastModified": 1745333930, 103 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 82 + "lastModified": 1753693716, 83 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 104 84 "owner": "oppiliappan", 105 85 "repo": "indigo", 106 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 86 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 107 87 "type": "github" 108 88 }, 109 89 "original": { ··· 128 108 "lucide-src": { 129 109 "flake": false, 130 110 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 111 + "lastModified": 1754044466, 112 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 113 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 114 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 115 }, 136 116 "original": { 137 117 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 118 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 119 } 140 120 }, 141 121 "nixpkgs": { ··· 156 136 }, 157 137 "root": { 158 138 "inputs": { 159 - "gitignore": "gitignore", 160 139 "gomod2nix": "gomod2nix", 161 140 "htmx-src": "htmx-src", 162 141 "htmx-ws-src": "htmx-ws-src",
+75 -27
flake.nix
··· 22 22 flake = false; 23 23 }; 24 24 lucide-src = { 25 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 25 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 26 26 flake = false; 27 27 }; 28 28 inter-fonts-src = { ··· 37 37 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 38 38 flake = false; 39 39 }; 40 - gitignore = { 41 - url = "github:hercules-ci/gitignore.nix"; 42 - inputs.nixpkgs.follows = "nixpkgs"; 43 - }; 44 40 }; 45 41 46 42 outputs = { ··· 51 47 htmx-src, 52 48 htmx-ws-src, 53 49 lucide-src, 54 - gitignore, 55 50 inter-fonts-src, 56 51 sqlite-lib-src, 57 52 ibm-plex-mono-src, ··· 62 57 63 58 mkPackageSet = pkgs: 64 59 pkgs.lib.makeScope pkgs.newScope (self: { 65 - inherit (gitignore.lib) gitignoreSource; 60 + src = let 61 + fs = pkgs.lib.fileset; 62 + in 63 + fs.toSource { 64 + root = ./.; 65 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 66 + }; 66 67 buildGoApplication = 67 68 (self.callPackage "${gomod2nix}/builder" { 68 69 gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; ··· 74 75 }; 75 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 78 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 80 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 92 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 93 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 94 96 in { 95 - appview = packages.appview; 96 - lexgen = packages.lexgen; 97 - knot = packages.knot; 98 - knot-unwrapped = packages.knot-unwrapped; 99 - spindle = packages.spindle; 100 - genjwks = packages.genjwks; 101 - sqlite-lib = packages.sqlite-lib; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 102 98 103 99 pkgsStatic-appview = staticPackages.appview; 104 100 pkgsStatic-knot = staticPackages.knot; ··· 120 116 stdenv = pkgs.pkgsStatic.stdenv; 121 117 }; 122 118 in { 123 - default = staticShell { 119 + default = pkgs.mkShell { 124 120 nativeBuildInputs = [ 125 121 pkgs.go 126 122 pkgs.air ··· 131 127 pkgs.tailwindcss 132 128 pkgs.nixos-shell 133 129 pkgs.redis 130 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 134 131 packages'.lexgen 135 132 ]; 136 133 shellHook = '' 137 - mkdir -p appview/pages/static/{fonts,icons} 138 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 139 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 140 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 141 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 142 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 143 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 134 + mkdir -p appview/pages/static 135 + # no preserve is needed because watch-tailwind will want to be able to overwrite 136 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 144 137 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 145 138 ''; 146 139 env.CGO_ENABLED = 1; ··· 148 141 }); 149 142 apps = forAllSystems (system: let 150 143 pkgs = nixpkgsFor."${system}"; 144 + packages' = self.packages.${system}; 151 145 air-watcher = name: arg: 152 146 pkgs.writeShellScriptBin "run" 153 147 '' ··· 166 160 in { 167 161 watch-appview = { 168 162 type = "app"; 169 - program = ''${air-watcher "appview" ""}/bin/run''; 163 + program = toString (pkgs.writeShellScript "watch-appview" '' 164 + echo "copying static files to appview/pages/static..." 165 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 166 + ${air-watcher "appview" ""}/bin/run 167 + ''); 170 168 }; 171 169 watch-knot = { 172 170 type = "app"; ··· 176 174 type = "app"; 177 175 program = ''${tailwind-watcher}/bin/run''; 178 176 }; 179 - vm = { 177 + vm = let 178 + system = 179 + if pkgs.stdenv.hostPlatform.isAarch64 180 + then "aarch64" 181 + else "x86_64"; 182 + 183 + nixos-shell = pkgs.nixos-shell.overrideAttrs (old: { 184 + patches = 185 + (old.patches or []) 186 + ++ [ 187 + # https://github.com/Mic92/nixos-shell/pull/94 188 + (pkgs.fetchpatch { 189 + name = "fix-foreign-vm.patch"; 190 + url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch"; 191 + hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo="; 192 + }) 193 + ]; 194 + }); 195 + in { 180 196 type = "app"; 181 197 program = toString (pkgs.writeShellScript "vm" '' 182 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 198 + ${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux 183 199 ''); 184 200 }; 185 201 gomod2nix = { ··· 188 204 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 189 205 ''); 190 206 }; 207 + lexgen = { 208 + type = "app"; 209 + program = 210 + (pkgs.writeShellApplication { 211 + name = "lexgen"; 212 + text = '' 213 + if ! command -v lexgen > /dev/null; then 214 + echo "error: must be executed from devshell" 215 + exit 1 216 + fi 217 + 218 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 219 + cd "$rootDir" 220 + 221 + rm api/tangled/* 222 + lexgen --build-file lexicon-build-config.json lexicons 223 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 224 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 225 + go run cmd/gen.go 226 + lexgen --build-file lexicon-build-config.json lexicons 227 + rm api/tangled/*.bak 228 + ''; 229 + }) 230 + + /bin/lexgen; 231 + }; 191 232 }); 192 233 193 234 nixosModules.appview = { ··· 217 258 218 259 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 219 260 }; 220 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 261 + nixosConfigurations.vm-x86_64 = import ./nix/vm.nix { 262 + inherit self nixpkgs; 263 + system = "x86_64-linux"; 264 + }; 265 + nixosConfigurations.vm-aarch64 = import ./nix/vm.nix { 266 + inherit self nixpkgs; 267 + system = "aarch64-linux"; 268 + }; 221 269 }; 222 270 }
+4
input.css
··· 70 70 details summary::-webkit-details-marker { 71 71 display: none; 72 72 } 73 + 74 + code { 75 + @apply font-mono p-1 rounded bg-gray-100 dark:bg-gray-700; 76 + } 73 77 } 74 78 75 79 @layer components {
+13
jetstream/jetstream.go
··· 52 52 j.mu.Unlock() 53 53 } 54 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 55 68 type processor func(context.Context, *models.Event) error 56 69 57 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
-8
knotserver/file.go
··· 10 10 "tangled.sh/tangled.sh/core/types" 11 11 ) 12 12 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 13 func countLines(r io.Reader) (int, error) { 21 14 buf := make([]byte, 32*1024) 22 15 bufLen := 0 ··· 52 45 53 46 resp.Lines = lc 54 47 writeJSON(w, resp) 55 - return 56 48 }
+19 -12
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+61 -1
knotserver/ingester.go
··· 213 213 return h.db.InsertEvent(event, h.n) 214 214 } 215 215 216 + // duplicated from add collaborator 217 + func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error { 218 + repoAt, err := syntax.ParseATURI(record.Repo) 219 + if err != nil { 220 + return err 221 + } 222 + 223 + resolver := idresolver.DefaultResolver() 224 + 225 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + if err != nil || subjectId.Handle.IsInvalidHandle() { 227 + return err 228 + } 229 + 230 + // TODO: fix this for good, we need to fetch the record here unfortunately 231 + // resolve this aturi to extract the repo record 232 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 233 + if err != nil || owner.Handle.IsInvalidHandle() { 234 + return fmt.Errorf("failed to resolve handle: %w", err) 235 + } 236 + 237 + xrpcc := xrpc.Client{ 238 + Host: owner.PDSEndpoint(), 239 + } 240 + 241 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 242 + if err != nil { 243 + return err 244 + } 245 + 246 + repo := resp.Value.Val.(*tangled.Repo) 247 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 248 + 249 + // check perms for this user 250 + if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 251 + return fmt.Errorf("insufficient permissions: %w", err) 252 + } 253 + 254 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 255 + return err 256 + } 257 + h.jc.AddDid(subjectId.DID.String()) 258 + 259 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 260 + return err 261 + } 262 + 263 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 264 + } 265 + 216 266 func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 217 267 l := log.FromContext(ctx) 218 268 ··· 266 316 defer func() { 267 317 eventTime := event.TimeUS 268 318 lastTimeUs := eventTime + 1 269 - fmt.Println("lastTimeUs", lastTimeUs) 270 319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 271 320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 272 321 } ··· 292 341 if err := h.processKnotMember(ctx, did, record); err != nil { 293 342 return fmt.Errorf("failed to process knot member: %w", err) 294 343 } 344 + 295 345 case tangled.RepoPullNSID: 296 346 var record tangled.RepoPull 297 347 if err := json.Unmarshal(raw, &record); err != nil { ··· 300 350 if err := h.processPull(ctx, did, record); err != nil { 301 351 return fmt.Errorf("failed to process knot member: %w", err) 302 352 } 353 + 354 + case tangled.RepoCollaboratorNSID: 355 + var record tangled.RepoCollaborator 356 + if err := json.Unmarshal(raw, &record); err != nil { 357 + return fmt.Errorf("failed to unmarshal record: %w", err) 358 + } 359 + if err := h.processCollaborator(ctx, did, record); err != nil { 360 + return fmt.Errorf("failed to process knot member: %w", err) 361 + } 362 + 303 363 } 304 364 305 365 return err
+5 -2
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 145 146 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 147 } 147 148 148 - meta := gr.RefUpdateMeta(line) 149 + var errs error 150 + meta, err := gr.RefUpdateMeta(line) 151 + errors.Join(errs, err) 149 152 150 153 metaRecord := meta.AsRecord() 151 154 ··· 169 172 EventJson: string(eventJson), 170 173 } 171 174 172 - return h.db.InsertEvent(event, h.n) 175 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 173 176 } 174 177 175 178 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+1
knotserver/server.go
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
+17
lexicons/pipeline/pipeline.json
··· 241 241 "type": "ref", 242 242 "ref": "#pair" 243 243 } 244 + }, 245 + "oidcs_tokens": { 246 + "type": "array", 247 + "items": { 248 + "type": "object", 249 + "required": [ 250 + "name" 251 + ], 252 + "properties": { 253 + "name": { 254 + "type": "string" 255 + }, 256 + "aud": { 257 + "type": "string" 258 + } 259 + } 260 + } 244 261 } 245 262 } 246 263 },
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+6
nix/gomod2nix.toml
··· 66 66 [mod."github.com/cloudflare/circl"] 67 67 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 68 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 69 72 [mod."github.com/containerd/errdefs"] 70 73 version = "v1.0.0" 71 74 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 169 172 [mod."github.com/golang/mock"] 170 173 version = "v1.6.0" 171 174 hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 172 178 [mod."github.com/google/uuid"] 173 179 version = "v1.6.0" 174 180 hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
+22
nix/modules/spindle.nix
··· 54 54 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 55 description = "DID of owner (required)"; 56 56 }; 57 + 58 + secrets = { 59 + provider = mkOption { 60 + type = types.str; 61 + default = "sqlite"; 62 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 63 + }; 64 + 65 + openbao = { 66 + proxyAddr = mkOption { 67 + type = types.str; 68 + default = "http://127.0.0.1:8200"; 69 + }; 70 + mount = mkOption { 71 + type = types.str; 72 + default = "spindle"; 73 + }; 74 + }; 75 + }; 57 76 }; 58 77 59 78 pipelines = { ··· 89 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 90 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 91 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 111 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 92 114 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 93 115 "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 94 116 ];
+23
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" {} '' 13 + mkdir -p $out/{fonts,icons} && cd $out 14 + cp -f ${htmx-src} htmx.min.js 15 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 16 + cp -rf ${lucide-src}/*.svg icons/ 17 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 18 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 19 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/ 20 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 21 + # for whatever reason (produces broken css), so we are doing this instead 22 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 23 + ''
+5 -17
nix/pkgs/appview.nix
··· 1 1 { 2 2 buildGoApplication, 3 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 4 + appview-static-files, 10 5 sqlite-lib, 11 - gitignoreSource, 6 + src, 12 7 }: 13 8 buildGoApplication { 14 9 pname = "appview"; 15 10 version = "0.1.0"; 16 - src = gitignoreSource ../..; 17 - inherit modules; 11 + inherit src modules; 18 12 19 13 postUnpack = '' 20 14 pushd source 21 - mkdir -p appview/pages/static/{fonts,icons} 22 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 23 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 24 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 25 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 26 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 27 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 28 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 29 17 popd 30 18 ''; 31 19
+2 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - gitignoreSource, 2 + src, 3 3 buildGoApplication, 4 4 modules, 5 5 }: 6 6 buildGoApplication { 7 7 pname = "genjwks"; 8 8 version = "0.1.0"; 9 - src = gitignoreSource ../..; 10 - inherit modules; 9 + inherit src modules; 11 10 subPackages = ["cmd/genjwks"]; 12 11 doCheck = false; 13 12 CGO_ENABLED = 0;
+2 -3
nix/pkgs/knot-unwrapped.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "knot"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+81 -63
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 3 4 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - self.nixosModules.spindle 10 - ({ 11 - config, 12 - pkgs, 13 - ... 14 - }: { 15 - virtualisation = { 16 - memorySize = 2048; 17 - diskSize = 10 * 1024; 18 - cores = 2; 19 - forwardPorts = [ 20 - # ssh 21 - { 22 - from = "host"; 23 - host.port = 2222; 24 - guest.port = 22; 25 - } 26 - # knot 27 - { 28 - from = "host"; 29 - host.port = 6000; 30 - guest.port = 6000; 31 - } 32 - # spindle 33 - { 34 - from = "host"; 35 - host.port = 6555; 36 - guest.port = 6555; 37 - } 5 + }: let 6 + envVar = name: let 7 + var = builtins.getEnv name; 8 + in 9 + if var == "" 10 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 11 + else var; 12 + in 13 + nixpkgs.lib.nixosSystem { 14 + inherit system; 15 + modules = [ 16 + self.nixosModules.knot 17 + self.nixosModules.spindle 18 + ({ 19 + config, 20 + pkgs, 21 + ... 22 + }: { 23 + nixos-shell = { 24 + inheritPath = false; 25 + mounts = { 26 + mountHome = false; 27 + mountNixProfile = false; 28 + }; 29 + }; 30 + virtualisation = { 31 + memorySize = 2048; 32 + diskSize = 10 * 1024; 33 + cores = 2; 34 + forwardPorts = [ 35 + # ssh 36 + { 37 + from = "host"; 38 + host.port = 2222; 39 + guest.port = 22; 40 + } 41 + # knot 42 + { 43 + from = "host"; 44 + host.port = 6000; 45 + guest.port = 6000; 46 + } 47 + # spindle 48 + { 49 + from = "host"; 50 + host.port = 6555; 51 + guest.port = 6555; 52 + } 53 + ]; 54 + }; 55 + services.getty.autologinUser = "root"; 56 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 57 + systemd.tmpfiles.rules = let 58 + u = config.services.tangled-knot.gitUser; 59 + g = config.services.tangled-knot.gitUser; 60 + in [ 61 + "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 62 + "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}" 38 63 ]; 39 - }; 40 - services.getty.autologinUser = "root"; 41 - environment.systemPackages = with pkgs; [curl vim git]; 42 - systemd.tmpfiles.rules = let 43 - u = config.services.tangled-knot.gitUser; 44 - g = config.services.tangled-knot.gitUser; 45 - in [ 46 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 47 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440" 48 - ]; 49 - services.tangled-knot = { 50 - enable = true; 51 - motd = "Welcome to the development knot!\n"; 52 - server = { 53 - secretFile = "/var/lib/knot/secret"; 54 - hostname = "localhost:6000"; 55 - listenAddr = "0.0.0.0:6000"; 64 + services.tangled-knot = { 65 + enable = true; 66 + motd = "Welcome to the development knot!\n"; 67 + server = { 68 + secretFile = "/var/lib/knot/secret"; 69 + hostname = "localhost:6000"; 70 + listenAddr = "0.0.0.0:6000"; 71 + }; 56 72 }; 57 - }; 58 - services.tangled-spindle = { 59 - enable = true; 60 - server = { 61 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 62 - hostname = "localhost:6555"; 63 - listenAddr = "0.0.0.0:6555"; 64 - dev = true; 73 + services.tangled-spindle = { 74 + enable = true; 75 + server = { 76 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 77 + hostname = "localhost:6555"; 78 + listenAddr = "0.0.0.0:6555"; 79 + dev = true; 80 + secrets = { 81 + provider = "sqlite"; 82 + }; 83 + }; 65 84 }; 66 - }; 67 - }) 68 - ]; 69 - } 85 + }) 86 + ]; 87 + }
+15
spindle/db/db.go
··· 45 45 unique(owner, name) 46 46 ); 47 47 48 + create table if not exists spindle_members ( 49 + -- identifiers for the record 50 + id integer primary key autoincrement, 51 + did text not null, 52 + rkey text not null, 53 + 54 + -- data 55 + instance text not null, 56 + subject text not null, 57 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 58 + 59 + -- constraints 60 + unique (did, instance, subject) 61 + ); 62 + 48 63 -- status event for a single workflow 49 64 create table if not exists events ( 50 65 rkey text not null,
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
+15 -3
spindle/engine/engine.go
··· 25 25 "tangled.sh/tangled.sh/core/spindle/config" 26 26 "tangled.sh/tangled.sh/core/spindle/db" 27 27 "tangled.sh/tangled.sh/core/spindle/models" 28 + "tangled.sh/tangled.sh/core/spindle/oidc" 28 29 "tangled.sh/tangled.sh/core/spindle/secrets" 29 30 ) 30 31 ··· 41 42 n *notifier.Notifier 42 43 cfg *config.Config 43 44 vault secrets.Manager 45 + oidc oidc.OidcTokenGenerator 44 46 45 47 cleanupMu sync.Mutex 46 48 cleanup map[string][]cleanupFunc 47 49 } 48 50 49 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) { 51 + func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager, oidc *oidc.OidcTokenGenerator) (*Engine, error) { 50 52 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 51 53 if err != nil { 52 54 return nil, err ··· 61 63 n: n, 62 64 cfg: cfg, 63 65 vault: vault, 66 + oidc: *oidc, 64 67 } 65 68 66 69 e.cleanup = make(map[string][]cleanupFunc) ··· 124 127 ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 125 128 defer cancel() 126 129 127 - err = e.StartSteps(ctx, wid, w, allSecrets) 130 + err = e.StartSteps(ctx, wid, w, allSecrets, pipeline, pipelineId) 128 131 if err != nil { 129 132 if errors.Is(err, ErrTimedOut) { 130 133 dbErr := e.db.StatusTimeout(wid, e.n) ··· 202 205 // ONLY marks pipeline as failed if container's exit code is non-zero. 203 206 // All other errors are bubbled up. 204 207 // Fixed version of the step execution logic 205 - func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error { 208 + func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret, pipeline *models.Pipeline, pipelineId models.PipelineId) error { 206 209 workflowEnvs := ConstructEnvs(w.Environment) 207 210 for _, s := range secrets { 208 211 workflowEnvs.AddEnv(s.Key, s.Value) ··· 221 224 } 222 225 envs.AddEnv("HOME", workspaceDir) 223 226 e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 227 + 228 + for _, t := range step.OidcTokens { 229 + token, err := e.oidc.CreateToken(t, pipelineId, pipeline.RepoOwner, pipeline.RepoName) 230 + if err != nil { 231 + e.l.Error("failed to get OIDC token", "error", err, "token", t.Name) 232 + return fmt.Errorf("getting OIDC token: %w", err) 233 + } 234 + envs.AddEnv(t.Name, token) 235 + } 224 236 225 237 hostConfig := hostConfig(wid) 226 238 resp, err := e.docker.ContainerCreate(ctx, &container.Config{
+161 -7
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 - "path/filepath" 8 + "time" 8 9 9 10 "tangled.sh/tangled.sh/core/api/tangled" 10 11 "tangled.sh/tangled.sh/core/eventconsumer" 12 + "tangled.sh/tangled.sh/core/idresolver" 11 13 "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 12 15 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/identity" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + "github.com/bluesky-social/indigo/xrpc" 13 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 14 22 ) 15 23 16 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 35 43 s.ingestMember(ctx, e) 36 44 case tangled.RepoNSID: 37 45 s.ingestRepo(ctx, e) 46 + case tangled.RepoCollaboratorNSID: 47 + s.ingestCollaborator(ctx, e) 38 48 } 39 49 40 50 return err ··· 42 52 } 43 53 44 54 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 55 + var err error 45 56 did := e.Did 46 - var err error 57 + rkey := e.Commit.RKey 47 58 48 59 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 49 60 ··· 58 69 } 59 70 60 71 domain := s.cfg.Server.Hostname 61 - if s.cfg.Server.Dev { 62 - domain = s.cfg.Server.ListenAddr 63 - } 64 72 recordInstance := record.Instance 65 73 66 74 if recordInstance != domain { ··· 74 82 return fmt.Errorf("failed to enforce permissions: %w", err) 75 83 } 76 84 85 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 86 + Did: syntax.DID(did), 87 + Rkey: rkey, 88 + Instance: recordInstance, 89 + Subject: syntax.DID(record.Subject), 90 + Created: time.Now(), 91 + }); err != nil { 92 + l.Error("failed to add member", "error", err) 93 + return fmt.Errorf("failed to add member: %w", err) 94 + } 95 + 77 96 if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 78 97 l.Error("failed to add member", "error", err) 79 98 return fmt.Errorf("failed to add member: %w", err) ··· 88 107 89 108 return nil 90 109 110 + case models.CommitOperationDelete: 111 + record, err := db.GetSpindleMember(s.db, did, rkey) 112 + if err != nil { 113 + l.Error("failed to find member", "error", err) 114 + return fmt.Errorf("failed to find member: %w", err) 115 + } 116 + 117 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 118 + l.Error("failed to remove member", "error", err) 119 + return fmt.Errorf("failed to remove member: %w", err) 120 + } 121 + 122 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 123 + l.Error("failed to add member", "error", err) 124 + return fmt.Errorf("failed to add member: %w", err) 125 + } 126 + l.Info("added member from firehose", "member", record.Subject) 127 + 128 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 129 + l.Error("failed to add did", "error", err) 130 + return fmt.Errorf("failed to add did: %w", err) 131 + } 132 + s.jc.RemoveDid(record.Subject.String()) 133 + 91 134 } 92 135 return nil 93 136 } 94 137 95 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 138 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 96 139 var err error 140 + did := e.Did 141 + resolver := idresolver.DefaultResolver() 97 142 98 143 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 99 144 ··· 129 174 return fmt.Errorf("failed to add repo: %w", err) 130 175 } 131 176 177 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 178 + if err != nil { 179 + return err 180 + } 181 + 132 182 // add repo to rbac 133 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil { 183 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 134 184 l.Error("failed to add repo to enforcer", "error", err) 135 185 return fmt.Errorf("failed to add repo: %w", err) 136 186 } 137 187 188 + // add collaborators to rbac 189 + owner, err := resolver.ResolveIdent(ctx, did) 190 + if err != nil || owner.Handle.IsInvalidHandle() { 191 + return err 192 + } 193 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 194 + return err 195 + } 196 + 138 197 // add this knot to the event consumer 139 198 src := eventconsumer.NewKnotSource(record.Knot) 140 199 s.ks.AddSource(context.Background(), src) ··· 144 203 } 145 204 return nil 146 205 } 206 + 207 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 208 + var err error 209 + 210 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 211 + 212 + l.Info("ingesting collaborator record") 213 + 214 + switch e.Commit.Operation { 215 + case models.CommitOperationCreate, models.CommitOperationUpdate: 216 + raw := e.Commit.Record 217 + record := tangled.RepoCollaborator{} 218 + err = json.Unmarshal(raw, &record) 219 + if err != nil { 220 + l.Error("invalid record", "error", err) 221 + return err 222 + } 223 + 224 + resolver := idresolver.DefaultResolver() 225 + 226 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 227 + if err != nil || subjectId.Handle.IsInvalidHandle() { 228 + return err 229 + } 230 + 231 + repoAt, err := syntax.ParseATURI(record.Repo) 232 + if err != nil { 233 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 + return nil 235 + } 236 + 237 + // TODO: get rid of this entirely 238 + // resolve this aturi to extract the repo record 239 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 240 + if err != nil || owner.Handle.IsInvalidHandle() { 241 + return fmt.Errorf("failed to resolve handle: %w", err) 242 + } 243 + 244 + xrpcc := xrpc.Client{ 245 + Host: owner.PDSEndpoint(), 246 + } 247 + 248 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + repo := resp.Value.Val.(*tangled.Repo) 254 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 + 256 + // check perms for this user 257 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 + return fmt.Errorf("insufficient permissions: %w", err) 259 + } 260 + 261 + // add collaborator to rbac 262 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 + l.Error("failed to add repo to enforcer", "error", err) 264 + return fmt.Errorf("failed to add repo: %w", err) 265 + } 266 + 267 + return nil 268 + } 269 + return nil 270 + } 271 + 272 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 + 275 + l.Info("fetching and adding existing collaborators") 276 + 277 + xrpcc := xrpc.Client{ 278 + Host: owner.PDSEndpoint(), 279 + } 280 + 281 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 + if err != nil { 283 + return err 284 + } 285 + 286 + var errs error 287 + for _, r := range resp.Records { 288 + if r == nil { 289 + continue 290 + } 291 + record := r.Value.Val.(*tangled.RepoCollaborator) 292 + 293 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 + l.Error("failed to add repo to enforcer", "error", err) 295 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 + } 297 + } 298 + 299 + return errs 300 + }
+14
spindle/models/pipeline.go
··· 18 18 Name string 19 19 Environment map[string]string 20 20 Kind StepKind 21 + OidcTokens []OidcToken 21 22 } 22 23 23 24 type StepKind int ··· 28 29 // steps defined by the user in the original pipeline 29 30 StepKindUser 30 31 ) 32 + 33 + type OidcToken struct { 34 + Name string 35 + Aud *string 36 + } 31 37 32 38 type Workflow struct { 33 39 Steps []Step ··· 60 66 sstep.Name = tstep.Name 61 67 sstep.Kind = StepKindUser 62 68 swf.Steps = append(swf.Steps, sstep) 69 + 70 + sstep.OidcTokens = make([]OidcToken, 0, len(tstep.Oidcs_tokens)) 71 + for _, ttoken := range tstep.Oidcs_tokens { 72 + sstep.OidcTokens = append(sstep.OidcTokens, OidcToken{ 73 + Name: ttoken.Name, 74 + Aud: ttoken.Aud, 75 + }) 76 + } 63 77 } 64 78 swf.Name = twf.Name 65 79 swf.Environment = workflowEnvToMap(twf.Environment)
+320
spindle/oidc/oidc.go
··· 1 + package oidc 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/rand" 7 + "encoding/json" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "reflect" 12 + "time" 13 + 14 + "github.com/lestrrat-go/jwx/v2/jwa" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 + "github.com/lestrrat-go/jwx/v2/jwt" 17 + "tangled.sh/tangled.sh/core/spindle/models" 18 + ) 19 + 20 + const JWKSPath = "/.well-known/jwks.json" 21 + const WebFingerPath = "/.well-known/webfinger" 22 + 23 + // OidcKeyPair represents an OIDC key pair with both private and public keys 24 + type OidcKeyPair struct { 25 + privateKey *ecdsa.PrivateKey 26 + publicKey *ecdsa.PublicKey 27 + keyID string 28 + jwkKey jwk.Key 29 + } 30 + 31 + // OidcTokenGenerator handles OIDC token generation and key management with rotation 32 + type OidcTokenGenerator struct { 33 + currentKeyPair OidcKeyPair 34 + nextKeyPair *OidcKeyPair 35 + l *slog.Logger 36 + issuer string 37 + claimsSupported []string 38 + } 39 + 40 + // NewOidcTokenGenerator creates a new OIDC token generator with in-memory key management 41 + func NewOidcTokenGenerator(issuer string) (*OidcTokenGenerator, error) { 42 + // Create new keys 43 + currentKeyPair, err := NewOidcKeyPair() 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to generate initial current key pair: %w", err) 46 + } 47 + 48 + // Use reflection to get claim field names from OidcClaims 49 + var claimsSupported []string 50 + claimsType := reflect.TypeOf(OidcClaims{}) 51 + for i := 0; i < claimsType.NumField(); i++ { 52 + tag := claimsType.Field(i).Tag.Get("json") 53 + if tag != "" { 54 + claimsSupported = append(claimsSupported, tag) 55 + } 56 + } 57 + 58 + return &OidcTokenGenerator{ 59 + issuer: issuer, 60 + currentKeyPair: *currentKeyPair, 61 + claimsSupported: claimsSupported, 62 + }, nil 63 + } 64 + 65 + // NewOidcKeyPair generates a new ECDSA key pair for OIDC token signing 66 + func NewOidcKeyPair() (*OidcKeyPair, error) { 67 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 68 + if err != nil { 69 + return nil, fmt.Errorf("failed to generate ECDSA key: %w", err) 70 + } 71 + 72 + keyID := fmt.Sprintf("spindle-%d", time.Now().Unix()) 73 + 74 + // Create JWK from the private key 75 + jwkKey, err := jwk.FromRaw(privKey) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to create JWK from private key: %w", err) 78 + } 79 + 80 + // Set the key ID 81 + if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil { 82 + return nil, fmt.Errorf("failed to set key ID: %w", err) 83 + } 84 + 85 + // Set algorithm 86 + if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { 87 + return nil, fmt.Errorf("failed to set algorithm: %w", err) 88 + } 89 + 90 + // Set usage 91 + if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil { 92 + return nil, fmt.Errorf("failed to set key usage: %w", err) 93 + } 94 + 95 + return &OidcKeyPair{ 96 + privateKey: privKey, 97 + publicKey: &privKey.PublicKey, 98 + keyID: keyID, 99 + jwkKey: jwkKey, 100 + }, nil 101 + } 102 + 103 + func (k *OidcKeyPair) GetKeyID() string { 104 + return k.keyID 105 + } 106 + 107 + // RotateKeys performs key rotation: generates new next key, moves next to current 108 + func (g *OidcTokenGenerator) RotateKeys() error { 109 + // Generate a new key pair for the next key 110 + newNextKeyPair, err := NewOidcKeyPair() 111 + if err != nil { 112 + return fmt.Errorf("failed to generate new next key pair: %w", err) 113 + } 114 + 115 + // Perform rotation: next becomes current, new key becomes next 116 + g.currentKeyPair = *g.nextKeyPair 117 + g.nextKeyPair = newNextKeyPair 118 + 119 + return nil 120 + } 121 + 122 + // OidcClaims represents the claims in an OIDC token 123 + type OidcClaims struct { 124 + // Standard JWT claims 125 + Issuer string `json:"iss"` 126 + Subject string `json:"sub"` 127 + Audience string `json:"aud"` 128 + ExpiresAt int64 `json:"exp"` 129 + NotBefore int64 `json:"nbf"` 130 + IssuedAt int64 `json:"iat"` 131 + JWTID string `json:"jti"` 132 + } 133 + 134 + // CreateToken creates a signed JWT token for the given OidcToken and pipeline context 135 + func (g *OidcTokenGenerator) CreateToken( 136 + oidcToken models.OidcToken, 137 + pipelineId models.PipelineId, 138 + repoOwner, repoName string, 139 + ) (string, error) { 140 + now := time.Now() 141 + exp := now.Add(5 * time.Minute) 142 + 143 + // Determine audience - use the provided audience or default to issuer 144 + audience := g.issuer 145 + if oidcToken.Aud != nil && *oidcToken.Aud != "" { 146 + audience = *oidcToken.Aud 147 + } 148 + 149 + pipelineUri := pipelineId.AtUri() 150 + 151 + // Create claims 152 + claims := OidcClaims{ 153 + Issuer: g.issuer, 154 + // Hardcode the did as did:web of the issuer. At some point knots will have their own DIDs which will be used here 155 + Subject: pipelineUri.String(), 156 + Audience: audience, 157 + ExpiresAt: exp.Unix(), 158 + NotBefore: now.Unix(), 159 + IssuedAt: now.Unix(), 160 + // Repo owner, name, and id should be global unique but we add timestamp to ensure uniqueness 161 + JWTID: fmt.Sprintf("%s/%s-%s-%d", repoOwner, repoName, pipelineUri.RecordKey(), now.Unix()), 162 + } 163 + 164 + // Create JWT token 165 + token := jwt.New() 166 + 167 + // Set all claims 168 + if err := token.Set(jwt.IssuerKey, claims.Issuer); err != nil { 169 + return "", fmt.Errorf("failed to set issuer: %w", err) 170 + } 171 + if err := token.Set(jwt.SubjectKey, claims.Subject); err != nil { 172 + return "", fmt.Errorf("failed to set subject: %w", err) 173 + } 174 + if err := token.Set(jwt.AudienceKey, claims.Audience); err != nil { 175 + return "", fmt.Errorf("failed to set audience: %w", err) 176 + } 177 + if err := token.Set(jwt.ExpirationKey, claims.ExpiresAt); err != nil { 178 + return "", fmt.Errorf("failed to set expiration: %w", err) 179 + } 180 + if err := token.Set(jwt.NotBeforeKey, claims.NotBefore); err != nil { 181 + return "", fmt.Errorf("failed to set not before: %w", err) 182 + } 183 + if err := token.Set(jwt.IssuedAtKey, claims.IssuedAt); err != nil { 184 + return "", fmt.Errorf("failed to set issued at: %w", err) 185 + } 186 + if err := token.Set(jwt.JwtIDKey, claims.JWTID); err != nil { 187 + return "", fmt.Errorf("failed to set JWT ID: %w", err) 188 + } 189 + 190 + // Sign the token with the current key 191 + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, g.currentKeyPair.jwkKey)) 192 + if err != nil { 193 + return "", fmt.Errorf("failed to sign token: %w", err) 194 + } 195 + 196 + return string(signedToken), nil 197 + } 198 + 199 + // JWKSHandler serves the JWKS endpoint (const JWKSPath) 200 + func (g *OidcTokenGenerator) JWKSHandler(w http.ResponseWriter, r *http.Request) { 201 + pubJWK, err := jwk.PublicKeyOf(g.currentKeyPair.jwkKey) 202 + if err != nil { 203 + http.Error(w, fmt.Sprintf("failed to extract current public key from JWK: %v", err), http.StatusInternalServerError) 204 + return 205 + } 206 + var keys []jwk.Key 207 + keys = append(keys, pubJWK) 208 + 209 + // Add next key if available 210 + if g.nextKeyPair != nil { 211 + pubJWK, err := jwk.PublicKeyOf(g.nextKeyPair.jwkKey) 212 + if err != nil { 213 + http.Error(w, fmt.Sprintf("failed to extract next public key from JWK: %v", err), http.StatusInternalServerError) 214 + return 215 + } 216 + keys = append(keys, pubJWK) 217 + } 218 + 219 + if len(keys) == 0 { 220 + http.Error(w, "no keys available for JWKS", http.StatusInternalServerError) 221 + return 222 + } 223 + 224 + jwks := map[string]interface{}{ 225 + "keys": keys, 226 + } 227 + 228 + w.Header().Set("Content-Type", "application/json") 229 + if err := json.NewEncoder(w).Encode(jwks); err != nil { 230 + http.Error(w, fmt.Sprintf("failed to encode JWKS: %v", err), http.StatusInternalServerError) 231 + } 232 + } 233 + 234 + // DiscoveryHandler serves the OIDC discovery endpoint (/.well-known/openid-configuration) 235 + func (g *OidcTokenGenerator) DiscoveryHandler(w http.ResponseWriter, r *http.Request) { 236 + 237 + responseTypesSupported := []string{ 238 + "id_token", 239 + } 240 + 241 + subjectTypesSupported := []string{ 242 + "public", 243 + } 244 + 245 + idTokenSigningAlgValuesSupported := []string{ 246 + jwa.RS256.String(), 247 + } 248 + 249 + scopesSupported := []string{ 250 + "openid", 251 + } 252 + 253 + discovery := map[string]interface{}{ 254 + "issuer": g.issuer, 255 + "jwks_uri": fmt.Sprintf("%s%s", g.issuer, JWKSPath), 256 + "claims_supported": g.claimsSupported, 257 + "response_types_supported": responseTypesSupported, 258 + "subject_types_supported": subjectTypesSupported, 259 + "id_token_signing_alg_values_supported": idTokenSigningAlgValuesSupported, 260 + "scopes_supported": scopesSupported, 261 + } 262 + w.Header().Set("Content-Type", "application/json") 263 + if err := json.NewEncoder(w).Encode(discovery); err != nil { 264 + http.Error(w, fmt.Sprintf("failed to encode discovery document: %v", err), http.StatusInternalServerError) 265 + } 266 + } 267 + 268 + // WebFingerResponse represents the WebFinger response format 269 + type WebFingerResponse struct { 270 + Subject string `json:"subject"` 271 + Links []WebFingerLink `json:"links"` 272 + } 273 + 274 + // WebFingerLink represents a link in the WebFinger response 275 + type WebFingerLink struct { 276 + Rel string `json:"rel"` 277 + Href string `json:"href"` 278 + } 279 + 280 + // WebFingerHandler serves the WebFinger endpoint for issuer discovery (/.well-known/webfinger) 281 + // This implements OpenID Connect Discovery 1.0 Section 2 - OpenID Provider Issuer Discovery 282 + func (g *OidcTokenGenerator) WebFingerHandler(w http.ResponseWriter, r *http.Request) { 283 + // Parse query parameters 284 + resource := r.URL.Query().Get("resource") 285 + rel := r.URL.Query().Get("rel") 286 + 287 + // Check if this is an OpenID Connect issuer discovery request 288 + expectedRel := "http://openid.net/specs/connect/1.0/issuer" 289 + if rel != "" && rel != expectedRel { 290 + http.Error(w, "unsupported rel parameter", http.StatusBadRequest) 291 + return 292 + } 293 + 294 + if resource == "" { 295 + http.Error(w, "resource parameter is required", http.StatusBadRequest) 296 + return 297 + } 298 + 299 + // Check if the resource matches the issuer 300 + if resource != g.issuer { 301 + http.Error(w, "issuer not found", http.StatusNotFound) 302 + return 303 + } 304 + 305 + // Create the WebFinger response 306 + response := WebFingerResponse{ 307 + Subject: resource, 308 + Links: []WebFingerLink{ 309 + { 310 + Rel: expectedRel, 311 + Href: g.issuer, 312 + }, 313 + }, 314 + } 315 + 316 + w.Header().Set("Content-Type", "application/jrd+json") 317 + if err := json.NewEncoder(w).Encode(response); err != nil { 318 + http.Error(w, fmt.Sprintf("failed to encode WebFinger response: %v", err), http.StatusInternalServerError) 319 + } 320 + }
+1 -1
spindle/secrets/openbao.go
··· 132 132 return ErrKeyNotFound 133 133 } 134 134 135 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 136 if err != nil { 137 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 138 }
+27 -4
spindle/server.go
··· 21 21 "tangled.sh/tangled.sh/core/spindle/db" 22 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 23 "tangled.sh/tangled.sh/core/spindle/models" 24 + "tangled.sh/tangled.sh/core/spindle/oidc" 24 25 "tangled.sh/tangled.sh/core/spindle/queue" 25 26 "tangled.sh/tangled.sh/core/spindle/secrets" 26 27 "tangled.sh/tangled.sh/core/spindle/xrpc" ··· 93 94 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 94 95 } 95 96 96 - eng, err := engine.New(ctx, cfg, d, &n, vault) 97 + oidc, err := oidc.NewOidcTokenGenerator(fmt.Sprintf("https://%s", cfg.Server.Hostname)) 98 + if err != nil { 99 + return fmt.Errorf("failed to create OIDC token generator: %w", err) 100 + } 101 + 102 + eng, err := engine.New(ctx, cfg, d, &n, vault, oidc) 97 103 if err != nil { 98 104 return err 99 105 } 100 106 101 - jq := queue.NewQueue(100, 2) 107 + jq := queue.NewQueue(100, 5) 102 108 103 109 collections := []string{ 104 110 tangled.SpindleMemberNSID, 105 111 tangled.RepoNSID, 112 + tangled.RepoCollaboratorNSID, 106 113 } 107 114 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 108 115 if err != nil { ··· 110 117 } 111 118 jc.AddDid(cfg.Server.Owner) 112 119 120 + // Check if the spindle knows about any Dids; 121 + dids, err := d.GetAllDids() 122 + if err != nil { 123 + return fmt.Errorf("failed to get all dids: %w", err) 124 + } 125 + for _, d := range dids { 126 + jc.AddDid(d) 127 + } 128 + 113 129 resolver := idresolver.DefaultResolver() 114 130 115 131 spindle := Spindle{ ··· 178 194 }() 179 195 180 196 logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 181 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 197 + logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router(oidc))) 182 198 183 199 return nil 184 200 } 185 201 186 - func (s *Spindle) Router() http.Handler { 202 + func (s *Spindle) Router(oidcg *oidc.OidcTokenGenerator) http.Handler { 187 203 mux := chi.NewRouter() 188 204 189 205 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ··· 194 210 w.Write([]byte(s.cfg.Server.Owner)) 195 211 }) 196 212 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 213 + mux.HandleFunc(oidc.JWKSPath, oidcg.JWKSHandler) 214 + mux.HandleFunc("/.well-known/oidc-configuration", oidcg.DiscoveryHandler) 215 + mux.HandleFunc(oidc.WebFingerPath, oidcg.WebFingerHandler) 197 216 198 217 mux.Mount("/xrpc", s.XrpcRouter()) 199 218 return mux ··· 230 249 231 250 if tpl.TriggerMetadata.Repo == nil { 232 251 return fmt.Errorf("no repo data found") 252 + } 253 + 254 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 255 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 233 256 } 234 257 235 258 // filter by repos