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
+672 -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: ··· 5854 6094 5855 6095 return nil 5856 6096 } 6097 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 6098 + if t == nil { 6099 + _, err := w.Write(cbg.CborNull) 6100 + return err 6101 + } 6102 + 6103 + cw := cbg.NewCborWriter(w) 6104 + 6105 + if _, err := cw.Write([]byte{164}); err != nil { 6106 + return err 6107 + } 6108 + 6109 + // t.Repo (string) (string) 6110 + if len("repo") > 1000000 { 6111 + return xerrors.Errorf("Value in field \"repo\" was too long") 6112 + } 6113 + 6114 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 6115 + return err 6116 + } 6117 + if _, err := cw.WriteString(string("repo")); err != nil { 6118 + return err 6119 + } 6120 + 6121 + if len(t.Repo) > 1000000 { 6122 + return xerrors.Errorf("Value in field t.Repo was too long") 6123 + } 6124 + 6125 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 6126 + return err 6127 + } 6128 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 6129 + return err 6130 + } 6131 + 6132 + // t.LexiconTypeID (string) (string) 6133 + if len("$type") > 1000000 { 6134 + return xerrors.Errorf("Value in field \"$type\" was too long") 6135 + } 6136 + 6137 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 6138 + return err 6139 + } 6140 + if _, err := cw.WriteString(string("$type")); err != nil { 6141 + return err 6142 + } 6143 + 6144 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 6145 + return err 6146 + } 6147 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 6148 + return err 6149 + } 6150 + 6151 + // t.Subject (string) (string) 6152 + if len("subject") > 1000000 { 6153 + return xerrors.Errorf("Value in field \"subject\" was too long") 6154 + } 6155 + 6156 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 6157 + return err 6158 + } 6159 + if _, err := cw.WriteString(string("subject")); err != nil { 6160 + return err 6161 + } 6162 + 6163 + if len(t.Subject) > 1000000 { 6164 + return xerrors.Errorf("Value in field t.Subject was too long") 6165 + } 6166 + 6167 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 6168 + return err 6169 + } 6170 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 6171 + return err 6172 + } 6173 + 6174 + // t.CreatedAt (string) (string) 6175 + if len("createdAt") > 1000000 { 6176 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 6177 + } 6178 + 6179 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6180 + return err 6181 + } 6182 + if _, err := cw.WriteString(string("createdAt")); err != nil { 6183 + return err 6184 + } 6185 + 6186 + if len(t.CreatedAt) > 1000000 { 6187 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 6188 + } 6189 + 6190 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6191 + return err 6192 + } 6193 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6194 + return err 6195 + } 6196 + return nil 6197 + } 6198 + 6199 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 6200 + *t = RepoCollaborator{} 6201 + 6202 + cr := cbg.NewCborReader(r) 6203 + 6204 + maj, extra, err := cr.ReadHeader() 6205 + if err != nil { 6206 + return err 6207 + } 6208 + defer func() { 6209 + if err == io.EOF { 6210 + err = io.ErrUnexpectedEOF 6211 + } 6212 + }() 6213 + 6214 + if maj != cbg.MajMap { 6215 + return fmt.Errorf("cbor input should be of type map") 6216 + } 6217 + 6218 + if extra > cbg.MaxLength { 6219 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 6220 + } 6221 + 6222 + n := extra 6223 + 6224 + nameBuf := make([]byte, 9) 6225 + for i := uint64(0); i < n; i++ { 6226 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6227 + if err != nil { 6228 + return err 6229 + } 6230 + 6231 + if !ok { 6232 + // Field doesn't exist on this type, so ignore it 6233 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 6234 + return err 6235 + } 6236 + continue 6237 + } 6238 + 6239 + switch string(nameBuf[:nameLen]) { 6240 + // t.Repo (string) (string) 6241 + case "repo": 6242 + 6243 + { 6244 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6245 + if err != nil { 6246 + return err 6247 + } 6248 + 6249 + t.Repo = string(sval) 6250 + } 6251 + // t.LexiconTypeID (string) (string) 6252 + case "$type": 6253 + 6254 + { 6255 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6256 + if err != nil { 6257 + return err 6258 + } 6259 + 6260 + t.LexiconTypeID = string(sval) 6261 + } 6262 + // t.Subject (string) (string) 6263 + case "subject": 6264 + 6265 + { 6266 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6267 + if err != nil { 6268 + return err 6269 + } 6270 + 6271 + t.Subject = string(sval) 6272 + } 6273 + // t.CreatedAt (string) (string) 6274 + case "createdAt": 6275 + 6276 + { 6277 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6278 + if err != nil { 6279 + return err 6280 + } 6281 + 6282 + t.CreatedAt = string(sval) 6283 + } 6284 + 6285 + default: 6286 + // Field doesn't exist on this type, so ignore it 6287 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6288 + return err 6289 + } 6290 + } 6291 + } 6292 + 6293 + return nil 6294 + } 5857 6295 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5858 6296 if t == nil { 5859 6297 _, err := w.Write(cbg.CborNull) ··· 8225 8663 8226 8664 return nil 8227 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 + }
+25
api/tangled/repocollaborator.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.collaborator 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoCollaboratorNSID = "sh.tangled.repo.collaborator" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) 17 + } // 18 + // RECORDTYPE: RepoCollaborator 19 + type RepoCollaborator struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // repo: repo to add this user to 23 + Repo string `json:"repo" cborgen:"repo"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 + }
+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 + }
+9 -5
appview/config/config.go
··· 10 10 ) 11 11 12 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default spindle 21 + AppPassword string `env:"APP_PASSWORD"` 18 22 } 19 23 20 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) {
+95 -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) 272 + } 273 + 274 + type TermsOfServiceParams struct { 275 + LoggedInUser *oauth.User 276 + } 277 + 278 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 279 + return p.execute("legal/terms", w, params) 280 + } 281 + 282 + type PrivacyPolicyParams struct { 283 + LoggedInUser *oauth.User 284 + } 285 + 286 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 287 + return p.execute("legal/privacy", w, params) 269 288 } 270 289 271 290 type TimelineParams struct { ··· 397 416 UserDid string 398 417 UserHandle string 399 418 FollowStatus db.FollowStatus 400 - AvatarUri string 401 419 Followers int 402 420 Following int 403 421 ··· 1124 1142 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1125 1143 params.Active = "pipelines" 1126 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) 1127 1218 } 1128 1219 1129 1220 func (p *Pages) Static() http.Handler {
+44 -3
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 - <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> 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> 19 + </div> 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> 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> 5 45 </div> 46 + </div> 6 47 </div> 7 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>
+133
appview/pages/templates/legal/privacy.html
··· 1 + {{ define "title" }} privacy policy {{ end }} 2 + {{ define "content" }} 3 + <div class="max-w-4xl mx-auto px-4 py-8"> 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"> 6 + <h1>Privacy Policy</h1> 7 + 8 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 9 + 10 + <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p> 11 + 12 + <h2>1. Information We Collect</h2> 13 + 14 + <h3>Account Information</h3> 15 + <p>When you create an account, we collect:</p> 16 + <ul> 17 + <li>Your chosen username</li> 18 + <li>Email address</li> 19 + <li>Profile information you choose to provide</li> 20 + <li>Authentication data</li> 21 + </ul> 22 + 23 + <h3>Content and Activity</h3> 24 + <p>We store:</p> 25 + <ul> 26 + <li>Code repositories and associated metadata</li> 27 + <li>Issues, pull requests, and comments</li> 28 + <li>Activity logs and usage patterns</li> 29 + <li>Public keys for authentication</li> 30 + </ul> 31 + 32 + <h2>2. Data Location and Hosting</h2> 33 + <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6"> 34 + <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3> 35 + <p class="text-blue-700 dark:text-blue-300"> 36 + <strong>All Tangled service data is hosted within the European Union.</strong> Specifically: 37 + </p> 38 + <ul class="text-blue-700 dark:text-blue-300 mt-2"> 39 + <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li> 40 + <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li> 41 + <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li> 42 + </ul> 43 + </div> 44 + 45 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6"> 46 + <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3> 47 + <p class="text-yellow-700 dark:text-yellow-300"> 48 + <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure. 49 + </p> 50 + </div> 51 + 52 + <h2>3. Third-Party Data Processors</h2> 53 + <p>We only share your data with the following third-party processors:</p> 54 + 55 + <h3>Resend (Email Services)</h3> 56 + <ul> 57 + <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li> 58 + <li><strong>Data Shared:</strong> Email address and necessary message content</li> 59 + <li><strong>Location:</strong> EU-compliant email delivery service</li> 60 + </ul> 61 + 62 + <h3>Cloudflare (Image Caching)</h3> 63 + <ul> 64 + <li><strong>Purpose:</strong> Caching and optimizing image delivery</li> 65 + <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li> 66 + <li><strong>Location:</strong> Global CDN with EU data protection compliance</li> 67 + </ul> 68 + 69 + <h2>4. How We Use Your Information</h2> 70 + <p>We use your information to:</p> 71 + <ul> 72 + <li>Provide and maintain the Service</li> 73 + <li>Process your transactions and requests</li> 74 + <li>Send you technical notices and support messages</li> 75 + <li>Improve and develop new features</li> 76 + <li>Ensure security and prevent fraud</li> 77 + <li>Comply with legal obligations</li> 78 + </ul> 79 + 80 + <h2>5. Data Sharing and Disclosure</h2> 81 + <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p> 82 + <ul> 83 + <li>With the third-party processors listed above</li> 84 + <li>When required by law or legal process</li> 85 + <li>To protect our rights, property, or safety, or that of our users</li> 86 + <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li> 87 + </ul> 88 + 89 + <h2>6. Data Security</h2> 90 + <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p> 91 + 92 + <h2>7. Data Retention</h2> 93 + <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p> 94 + 95 + <h2>8. Your Rights</h2> 96 + <p>Under applicable data protection laws, you have the right to:</p> 97 + <ul> 98 + <li>Access your personal information</li> 99 + <li>Correct inaccurate information</li> 100 + <li>Request deletion of your information</li> 101 + <li>Object to processing of your information</li> 102 + <li>Data portability</li> 103 + <li>Withdraw consent (where applicable)</li> 104 + </ul> 105 + 106 + <h2>9. Cookies and Tracking</h2> 107 + <p>We use cookies and similar technologies to:</p> 108 + <ul> 109 + <li>Maintain your login session</li> 110 + <li>Remember your preferences</li> 111 + <li>Analyze usage patterns to improve the Service</li> 112 + </ul> 113 + <p>You can control cookie settings through your browser preferences.</p> 114 + 115 + <h2>10. Children's Privacy</h2> 116 + <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p> 117 + 118 + <h2>11. International Data Transfers</h2> 119 + <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p> 120 + 121 + <h2>12. Changes to This Privacy Policy</h2> 122 + <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p> 123 + 124 + <h2>13. Contact Information</h2> 125 + <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p> 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"> 128 + <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 130 + </div> 131 + </div> 132 + </div> 133 + {{ end }}
+71
appview/pages/templates/legal/terms.html
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 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"> 7 + <h1>Terms of Service</h1> 8 + 9 + <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> 10 + 11 + <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p> 12 + 13 + <h2>1. Acceptance of Terms</h2> 14 + <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p> 15 + 16 + <h2>2. Account Registration</h2> 17 + <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p> 18 + 19 + <h2>3. Account Termination</h2> 20 + <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6"> 21 + <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3> 22 + <p class="text-red-700 dark:text-red-300"> 23 + <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users. 24 + </p> 25 + <p class="text-red-700 dark:text-red-300 mt-2"> 26 + Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion. 27 + </p> 28 + </div> 29 + 30 + <h2>4. Acceptable Use</h2> 31 + <p>You agree not to use the Service to:</p> 32 + <ul> 33 + <li>Violate any applicable laws or regulations</li> 34 + <li>Infringe upon the rights of others</li> 35 + <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li> 36 + <li>Engage in spam, phishing, or other deceptive practices</li> 37 + <li>Attempt to gain unauthorized access to the Service or other users' accounts</li> 38 + <li>Interfere with or disrupt the Service or servers connected to the Service</li> 39 + </ul> 40 + 41 + <h2>5. Content and Intellectual Property</h2> 42 + <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p> 43 + 44 + <h2>6. Privacy</h2> 45 + <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p> 46 + 47 + <h2>7. Disclaimers</h2> 48 + <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p> 49 + 50 + <h2>8. Limitation of Liability</h2> 51 + <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p> 52 + 53 + <h2>9. Indemnification</h2> 54 + <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p> 55 + 56 + <h2>10. Governing Law</h2> 57 + <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p> 58 + 59 + <h2>11. Changes to Terms</h2> 60 + <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p> 61 + 62 + <h2>12. Contact Information</h2> 63 + <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p> 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"> 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> 68 + </div> 69 + </div> 70 + </div> 71 + {{ end }}
+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>
+6 -6
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" ··· 51 51 name="code" 52 52 tabindex="1" 53 53 required 54 - placeholder="pds-tngl-sh-foo-bar" 54 + placeholder="tngl-sh-foo-bar" 55 55 /> 56 56 <span class="text-sm text-gray-500 mt-1"> 57 57 Enter the code sent to your email. 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
+148 -64
appview/signup/signup.go
··· 1 1 package signup 2 2 3 3 import ( 4 + "bufio" 4 5 "fmt" 5 6 "log/slog" 6 7 "net/http" 8 + "os" 9 + "strings" 7 10 8 11 "github.com/go-chi/chi/v5" 9 12 "github.com/posthog/posthog-go" ··· 14 17 "tangled.sh/tangled.sh/core/appview/pages" 15 18 "tangled.sh/tangled.sh/core/appview/state/userutil" 16 19 "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/idresolver" 17 21 ) 18 22 19 23 type Signup struct { 20 - config *config.Config 21 - db *db.DB 22 - cf *dns.Cloudflare 23 - posthog posthog.Client 24 - xrpc *xrpcclient.Client 25 - idResolver *idresolver.Resolver 26 - pages *pages.Pages 27 - l *slog.Logger 24 + config *config.Config 25 + db *db.DB 26 + cf *dns.Cloudflare 27 + posthog posthog.Client 28 + xrpc *xrpcclient.Client 29 + idResolver *idresolver.Resolver 30 + pages *pages.Pages 31 + l *slog.Logger 32 + disallowedNicknames map[string]bool 28 33 } 29 34 30 - func New(cfg *config.Config, cf *dns.Cloudflare, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 35 + func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 36 + var cf *dns.Cloudflare 37 + if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 38 + var err error 39 + cf, err = dns.NewCloudflare(cfg) 40 + if err != nil { 41 + l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 42 + } 43 + } 44 + 45 + disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + 31 47 return &Signup{ 32 - config: cfg, 33 - db: database, 34 - cf: cf, 35 - posthog: pc, 36 - idResolver: idResolver, 37 - pages: pages, 38 - l: l, 48 + config: cfg, 49 + db: database, 50 + posthog: pc, 51 + idResolver: idResolver, 52 + cf: cf, 53 + pages: pages, 54 + l: l, 55 + disallowedNicknames: disallowedNicknames, 39 56 } 40 57 } 41 58 59 + func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 60 + disallowed := make(map[string]bool) 61 + 62 + if filepath == "" { 63 + logger.Debug("no disallowed nicknames file configured") 64 + return disallowed 65 + } 66 + 67 + file, err := os.Open(filepath) 68 + if err != nil { 69 + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 70 + return disallowed 71 + } 72 + defer file.Close() 73 + 74 + scanner := bufio.NewScanner(file) 75 + lineNum := 0 76 + for scanner.Scan() { 77 + lineNum++ 78 + line := strings.TrimSpace(scanner.Text()) 79 + if line == "" || strings.HasPrefix(line, "#") { 80 + continue // skip empty lines and comments 81 + } 82 + 83 + nickname := strings.ToLower(line) 84 + if userutil.IsValidSubdomain(nickname) { 85 + disallowed[nickname] = true 86 + } else { 87 + logger.Warn("invalid nickname format in disallowed nicknames file", 88 + "file", filepath, "line", lineNum, "nickname", nickname) 89 + } 90 + } 91 + 92 + if err := scanner.Err(); err != nil { 93 + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 94 + } 95 + 96 + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 97 + return disallowed 98 + } 99 + 100 + // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 101 + func (s *Signup) isNicknameAllowed(nickname string) bool { 102 + return !s.disallowedNicknames[strings.ToLower(nickname)] 103 + } 104 + 42 105 func (s *Signup) Router() http.Handler { 43 106 r := chi.NewRouter() 107 + r.Get("/", s.signup) 44 108 r.Post("/", s.signup) 45 109 r.Get("/complete", s.complete) 46 110 r.Post("/complete", s.complete) ··· 49 113 } 50 114 51 115 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 52 - 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") 53 124 54 - if !email.IsValidEmail(emailId) { 55 - s.pages.Notice(w, "login-msg", "Invalid email address.") 56 - return 57 - } 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 58 130 59 - exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 60 - if err != nil { 61 - s.l.Error("failed to check email existence", "error", err) 62 - s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 63 - return 64 - } 65 - if exists { 66 - s.pages.Notice(w, "login-msg", "Email already exists.") 67 - return 68 - } 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 + } 69 141 70 - code, err := s.inviteCodeRequest() 71 - if err != nil { 72 - s.l.Error("failed to create invite code", "error", err) 73 - s.pages.Notice(w, "login-msg", "Failed to create invite code.") 74 - return 75 - } 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 + } 76 148 77 - em := email.Email{ 78 - APIKey: s.config.Resend.ApiKey, 79 - From: s.config.Resend.SentFrom, 80 - To: emailId, 81 - Subject: "Verify your Tangled account", 82 - 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. 83 155 ` + code, 84 - 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> 85 157 <p><code>` + code + `</code></p>`, 86 - } 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 + } 87 175 88 - err = email.SendEmail(em) 89 - if err != nil { 90 - s.l.Error("failed to send email", "error", err) 91 - s.pages.Notice(w, "login-msg", "Failed to send email.") 92 - return 176 + s.pages.HxRedirect(w, "/signup/complete") 93 177 } 94 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 95 - Email: emailId, 96 - InviteCode: code, 97 - }) 98 - if err != nil { 99 - s.l.Error("failed to add inflight signup", "error", err) 100 - s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 101 - return 102 - } 103 - 104 - s.pages.HxRedirect(w, "/signup/complete") 105 178 } 106 179 107 180 func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 108 181 switch r.Method { 109 182 case http.MethodGet: 110 - s.pages.CompleteSignup(w, pages.SignupParams{}) 183 + s.pages.CompleteSignup(w) 111 184 case http.MethodPost: 112 185 username := r.FormValue("username") 113 186 password := r.FormValue("password") ··· 118 191 return 119 192 } 120 193 194 + if !s.isNicknameAllowed(username) { 195 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 196 + return 197 + } 198 + 121 199 email, err := db.GetEmailForCode(s.db, code) 122 200 if err != nil { 123 201 s.l.Error("failed to get email for code", "error", err) ··· 132 210 return 133 211 } 134 212 213 + if s.cf == nil { 214 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 215 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 216 + return 217 + } 218 + 135 219 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 136 220 Type: "TXT", 137 221 Name: "_atproto." + username, 138 - Content: "did=" + did, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 139 223 TTL: 6400, 140 224 Proxied: false, 141 225 }) 142 226 if err != nil { 143 227 s.l.Error("failed to create DNS record", "error", err) 144 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 228 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 145 229 return 146 230 } 147 231
+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) {
+22 -4
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 ··· 66 67 67 68 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 68 69 r := chi.NewRouter() 69 - 70 - // strip @ from user 71 - r.Use(middleware.StripLeadingAt) 72 70 73 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 74 72 r.Get("/", s.Profile) ··· 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()) 142 141 r.Mount("/", s.OAuthRouter()) 143 142 144 143 r.Get("/keys/{user}", s.Keys) 144 + r.Get("/terms", s.TermsOfService) 145 + r.Get("/privacy", s.PrivacyPolicy) 145 146 146 147 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 147 148 s.pages.Error404(w) ··· 199 200 return knots.Router(mw) 200 201 } 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) 218 + } 219 + 202 220 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 203 221 issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 204 222 return issues.Router(mw) ··· 223 241 func (s *State) SignupRouter() http.Handler { 224 242 logger := log.New("signup") 225 243 226 - sig := signup.New(s.config, s.cf, s.db, s.posthog, s.idResolver, s.pages, logger) 244 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 227 245 return sig.Router() 228 246 }
+15 -8
appview/state/state.go
··· 20 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 21 "tangled.sh/tangled.sh/core/appview/config" 22 22 "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/dns" 24 23 "tangled.sh/tangled.sh/core/appview/notify" 25 24 "tangled.sh/tangled.sh/core/appview/oauth" 26 25 "tangled.sh/tangled.sh/core/appview/pages" ··· 47 46 jc *jetstream.JetstreamClient 48 47 config *config.Config 49 48 repoResolver *reporesolver.RepoResolver 50 - cf *dns.Cloudflare 51 49 knotstream *eventconsumer.Consumer 52 50 spindlestream *eventconsumer.Consumer 53 51 } ··· 95 93 tangled.ActorProfileNSID, 96 94 tangled.SpindleMemberNSID, 97 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 98 97 }, 99 98 nil, 100 99 slog.Default(), ··· 139 138 } 140 139 notifier := notify.NewMergedNotifier(notifiers...) 141 140 142 - cf, err := dns.NewCloudflare(config) 143 - if err != nil { 144 - return nil, fmt.Errorf("failed to create Cloudflare client: %w", err) 145 - } 146 - 147 141 state := &State{ 148 142 d, 149 143 notifier, ··· 156 150 jc, 157 151 config, 158 152 repoResolver, 159 - cf, 160 153 knotstream, 161 154 spindlestream, 162 155 } 163 156 164 157 return state, nil 158 + } 159 + 160 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 161 + user := s.oauth.GetUser(r) 162 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 163 + LoggedInUser: user, 164 + }) 165 + } 166 + 167 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 168 + user := s.oauth.GetUser(r) 169 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 170 + LoggedInUser: user, 171 + }) 165 172 } 166 173 167 174 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
+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 + }
+3
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{}, 40 41 tangled.PublicKey{}, 41 42 tangled.Repo{}, 42 43 tangled.RepoArtifact{}, 44 + tangled.RepoCollaborator{}, 43 45 tangled.RepoIssue{}, 44 46 tangled.RepoIssueComment{}, 45 47 tangled.RepoIssueState{}, ··· 49 51 tangled.RepoPullStatus{}, 50 52 tangled.Spindle{}, 51 53 tangled.SpindleMember{}, 54 + tangled.String{}, 52 55 ); err != nil { 53 56 panic(err) 54 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 ```
+193 -38
docs/spindle/openbao.md
··· 1 1 # spindle secrets with openbao 2 2 3 3 This document covers setting up Spindle to use OpenBao for secrets 4 - management instead of the default SQLite backend. 4 + management via OpenBao Proxy instead of the default SQLite backend. 5 + 6 + ## overview 7 + 8 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 + authentication automatically using AppRole credentials, while Spindle 10 + connects to the local proxy instead of directly to the OpenBao server. 11 + 12 + This approach provides better security, automatic token renewal, and 13 + simplified application code. 5 14 6 15 ## installation 7 16 8 17 Install OpenBao from nixpkgs: 9 18 10 19 ```bash 11 - nix-env -iA nixpkgs.openbao 20 + nix shell nixpkgs#openbao # for a local server 12 21 ``` 13 22 14 - ## local development setup 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 15 28 16 29 Start OpenBao in dev mode: 17 30 18 31 ```bash 19 - bao server -dev 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 20 33 ``` 21 34 22 - This starts OpenBao on `http://localhost:8200` with a root token. Save 23 - the root token from the output -- you'll need it. 35 + This starts OpenBao on `http://localhost:8201` with a root token. 24 36 25 37 Set up environment for bao CLI: 26 38 27 39 ```bash 28 40 export BAO_ADDR=http://localhost:8200 29 - export BAO_TOKEN=hvs.your-root-token-here 41 + export BAO_TOKEN=root 30 42 ``` 31 43 44 + ### production 45 + 46 + You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 + achieved using Nix. 49 + 50 + Then, initialize the bao server: 51 + ```bash 52 + bao operator init -key-shares=1 -key-threshold=1 53 + ``` 54 + 55 + This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 + ```bash 57 + bao operator unseal <unseal_key> 58 + ``` 59 + 60 + All steps below remain the same across both dev and production setups. 61 + 62 + ### configure openbao server 63 + 32 64 Create the spindle KV mount: 33 65 34 66 ```bash 35 67 bao secrets enable -path=spindle -version=2 kv 36 68 ``` 37 69 38 - Set up AppRole authentication: 70 + Set up AppRole authentication and policy: 39 71 40 72 Create a policy file `spindle-policy.hcl`: 41 73 42 74 ```hcl 75 + # Full access to spindle KV v2 data 43 76 path "spindle/data/*" { 44 - capabilities = ["create", "read", "update", "delete", "list"] 77 + capabilities = ["create", "read", "update", "delete"] 45 78 } 46 79 80 + # Access to metadata for listing and management 47 81 path "spindle/metadata/*" { 48 - capabilities = ["list", "read", "delete"] 82 + capabilities = ["list", "read", "delete", "update"] 49 83 } 50 84 51 - path "spindle/*" { 85 + # Allow listing at root level 86 + path "spindle/" { 52 87 capabilities = ["list"] 53 88 } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 54 94 ``` 55 95 56 96 Apply the policy and create an AppRole: ··· 61 101 bao write auth/approle/role/spindle \ 62 102 token_policies="spindle-policy" \ 63 103 token_ttl=1h \ 64 - token_max_ttl=4h 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 65 108 ``` 66 109 67 110 Get the credentials: 68 111 69 112 ```bash 70 - bao read auth/approle/role/spindle/role-id 71 - bao write -f auth/approle/role/spindle/secret-id 113 + # Get role ID (static) 114 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 + 116 + # Generate secret ID 117 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 + 119 + echo "Role ID: $ROLE_ID" 120 + echo "Secret ID: $SECRET_ID" 121 + ``` 122 + 123 + ### create proxy configuration 124 + 125 + Create the credential files: 126 + 127 + ```bash 128 + # Create directory for OpenBao files 129 + mkdir -p /tmp/openbao 130 + 131 + # Save credentials 132 + echo "$ROLE_ID" > /tmp/openbao/role-id 133 + echo "$SECRET_ID" > /tmp/openbao/secret-id 134 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 + ``` 136 + 137 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 + 139 + ```hcl 140 + # OpenBao server connection 141 + vault { 142 + address = "http://localhost:8200" 143 + } 144 + 145 + # Auto-Auth using AppRole 146 + auto_auth { 147 + method "approle" { 148 + mount_path = "auth/approle" 149 + config = { 150 + role_id_file_path = "/tmp/openbao/role-id" 151 + secret_id_file_path = "/tmp/openbao/secret-id" 152 + } 153 + } 154 + 155 + # Optional: write token to file for debugging 156 + sink "file" { 157 + config = { 158 + path = "/tmp/openbao/token" 159 + mode = 0640 160 + } 161 + } 162 + } 163 + 164 + # Proxy listener for Spindle 165 + listener "tcp" { 166 + address = "127.0.0.1:8201" 167 + tls_disable = true 168 + } 169 + 170 + # Enable API proxy with auto-auth token 171 + api_proxy { 172 + use_auto_auth_token = true 173 + } 174 + 175 + # Enable response caching 176 + cache { 177 + use_auto_auth_token = true 178 + } 179 + 180 + # Logging 181 + log_level = "info" 72 182 ``` 73 183 74 - Configure Spindle: 184 + ### start the proxy 185 + 186 + Start OpenBao Proxy: 187 + 188 + ```bash 189 + bao proxy -config=/tmp/openbao/proxy.hcl 190 + ``` 191 + 192 + The proxy will authenticate with OpenBao and start listening on 193 + `127.0.0.1:8201`. 194 + 195 + ### configure spindle 75 196 76 197 Set these environment variables for Spindle: 77 198 78 199 ```bash 79 200 export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 80 - export SPINDLE_SERVER_SECRETS_OPENBAO_ADDR=http://localhost:8200 81 - export SPINDLE_SERVER_SECRETS_OPENBAO_ROLE_ID=your-role-id-from-above 82 - export SPINDLE_SERVER_SECRETS_OPENBAO_SECRET_ID=your-secret-id-from-above 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 83 202 export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 84 203 ``` 85 204 86 205 Start Spindle: 87 206 88 - Spindle will now use OpenBao for secrets storage with automatic token 89 - renewal. 207 + Spindle will now connect to the local proxy, which handles all 208 + authentication automatically. 209 + 210 + ## production setup for proxy 211 + 212 + For production, you'll want to run the proxy as a service: 213 + 214 + Place your production configuration in `/etc/openbao/proxy.hcl` with 215 + proper TLS settings for the vault connection. 90 216 91 217 ## verifying setup 92 218 93 - List all secrets: 219 + Test the proxy directly: 94 220 95 221 ```bash 96 - bao kv list spindle/ 222 + # Check proxy health 223 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 + 225 + # Test token lookup through proxy 226 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 97 227 ``` 98 228 99 - Add a test secret via Spindle API, then check it exists: 229 + Test OpenBao operations through the server: 100 230 101 231 ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 102 236 bao kv list spindle/repos/ 103 - ``` 104 237 105 - Get a specific secret: 106 - 107 - ```bash 238 + # Get a specific secret 108 239 bao kv get spindle/repos/your_repo_path/SECRET_NAME 109 240 ``` 110 241 111 242 ## how it works 112 243 244 + - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 + - The proxy authenticates with OpenBao using AppRole credentials 246 + - All Spindle requests go through the proxy, which injects authentication tokens 113 247 - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 114 - - Each repository gets its own namespace 115 - - Repository paths like `at://did:plc:alice/myrepo` become 116 - `at_did_plc_alice_myrepo` 117 - - The system automatically handles token renewal using AppRole 118 - authentication 119 - - On shutdown, Spindle cleanly stops the token renewal process 248 + - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 + - The proxy handles all token renewal automatically 250 + - Spindle no longer manages tokens or authentication directly 120 251 121 252 ## troubleshooting 122 253 123 - **403 errors**: Check that your BAO_TOKEN is set and the spindle mount 124 - exists 254 + **Connection refused**: Check that the OpenBao Proxy is running and 255 + listening on the configured address. 256 + 257 + **403 errors**: Verify the AppRole credentials are correct and the policy 258 + has the necessary permissions. 125 259 126 260 **404 route errors**: The spindle KV mount probably doesn't exist - run 127 - the mount creation step again 261 + the mount creation step again. 128 262 129 - **Token expired**: The AppRole system should handle this automatically, 130 - but you can check token status with `bao token lookup` 263 + **Proxy authentication failures**: Check the proxy logs and verify the 264 + role-id and secret-id files are readable and contain valid credentials. 265 + 266 + **Secret not found after writing**: This can indicate policy permission 267 + issues. Verify the policy includes both `spindle/data/*` and 268 + `spindle/metadata/*` paths with appropriate capabilities. 269 + 270 + Check proxy logs: 271 + 272 + ```bash 273 + # If running as systemd service 274 + journalctl -u openbao-proxy -f 275 + 276 + # If running directly, check the console output 277 + ``` 278 + 279 + Test AppRole authentication manually: 280 + 281 + ```bash 282 + bao write auth/approle/login \ 283 + role_id="$(cat /tmp/openbao/role-id)" \ 284 + secret_id="$(cat /tmp/openbao/secret-id)" 285 + ```
+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)
-37
lexicons/addSecret.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.addSecret", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Add a CI secret", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "key", 15 - "value" 16 - ], 17 - "properties": { 18 - "repo": { 19 - "type": "string", 20 - "format": "at-uri" 21 - }, 22 - "key": { 23 - "type": "string", 24 - "maxLength": 50, 25 - "minLength": 1 26 - }, 27 - "value": { 28 - "type": "string", 29 - "maxLength": 200, 30 - "minLength": 1 31 - } 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
-29
lexicons/defaultBranch.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.setDefaultBranch", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Set the default branch for a repository", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "defaultBranch" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "defaultBranch": { 22 - "type": "string" 23 - } 24 - } 25 - } 26 - } 27 - } 28 - } 29 - }
-67
lexicons/listSecrets.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.listSecrets", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": [ 10 - "repo" 11 - ], 12 - "properties": { 13 - "repo": { 14 - "type": "string", 15 - "format": "at-uri" 16 - } 17 - } 18 - }, 19 - "output": { 20 - "encoding": "application/json", 21 - "schema": { 22 - "type": "object", 23 - "required": [ 24 - "secrets" 25 - ], 26 - "properties": { 27 - "secrets": { 28 - "type": "array", 29 - "items": { 30 - "type": "ref", 31 - "ref": "#secret" 32 - } 33 - } 34 - } 35 - } 36 - } 37 - }, 38 - "secret": { 39 - "type": "object", 40 - "required": [ 41 - "repo", 42 - "key", 43 - "createdAt", 44 - "createdBy" 45 - ], 46 - "properties": { 47 - "repo": { 48 - "type": "string", 49 - "format": "at-uri" 50 - }, 51 - "key": { 52 - "type": "string", 53 - "maxLength": 50, 54 - "minLength": 1 55 - }, 56 - "createdAt": { 57 - "type": "string", 58 - "format": "datetime" 59 - }, 60 - "createdBy": { 61 - "type": "string", 62 - "format": "did" 63 - } 64 - } 65 - } 66 - } 67 - }
+280
lexicons/pipeline/pipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "dependencies", 153 + "steps", 154 + "environment", 155 + "clone" 156 + ], 157 + "properties": { 158 + "name": { 159 + "type": "string" 160 + }, 161 + "dependencies": { 162 + "type": "array", 163 + "items": { 164 + "type": "ref", 165 + "ref": "#dependency" 166 + } 167 + }, 168 + "steps": { 169 + "type": "array", 170 + "items": { 171 + "type": "ref", 172 + "ref": "#step" 173 + } 174 + }, 175 + "environment": { 176 + "type": "array", 177 + "items": { 178 + "type": "ref", 179 + "ref": "#pair" 180 + } 181 + }, 182 + "clone": { 183 + "type": "ref", 184 + "ref": "#cloneOpts" 185 + } 186 + } 187 + }, 188 + "dependency": { 189 + "type": "object", 190 + "required": [ 191 + "registry", 192 + "packages" 193 + ], 194 + "properties": { 195 + "registry": { 196 + "type": "string" 197 + }, 198 + "packages": { 199 + "type": "array", 200 + "items": { 201 + "type": "string" 202 + } 203 + } 204 + } 205 + }, 206 + "cloneOpts": { 207 + "type": "object", 208 + "required": [ 209 + "skip", 210 + "depth", 211 + "submodules" 212 + ], 213 + "properties": { 214 + "skip": { 215 + "type": "boolean" 216 + }, 217 + "depth": { 218 + "type": "integer" 219 + }, 220 + "submodules": { 221 + "type": "boolean" 222 + } 223 + } 224 + }, 225 + "step": { 226 + "type": "object", 227 + "required": [ 228 + "name", 229 + "command" 230 + ], 231 + "properties": { 232 + "name": { 233 + "type": "string" 234 + }, 235 + "command": { 236 + "type": "string" 237 + }, 238 + "environment": { 239 + "type": "array", 240 + "items": { 241 + "type": "ref", 242 + "ref": "#pair" 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 + } 261 + } 262 + } 263 + }, 264 + "pair": { 265 + "type": "object", 266 + "required": [ 267 + "key", 268 + "value" 269 + ], 270 + "properties": { 271 + "key": { 272 + "type": "string" 273 + }, 274 + "value": { 275 + "type": "string" 276 + } 277 + } 278 + } 279 + } 280 + }
-263
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "triggerMetadata", 14 - "workflows" 15 - ], 16 - "properties": { 17 - "triggerMetadata": { 18 - "type": "ref", 19 - "ref": "#triggerMetadata" 20 - }, 21 - "workflows": { 22 - "type": "array", 23 - "items": { 24 - "type": "ref", 25 - "ref": "#workflow" 26 - } 27 - } 28 - } 29 - } 30 - }, 31 - "triggerMetadata": { 32 - "type": "object", 33 - "required": [ 34 - "kind", 35 - "repo" 36 - ], 37 - "properties": { 38 - "kind": { 39 - "type": "string", 40 - "enum": [ 41 - "push", 42 - "pull_request", 43 - "manual" 44 - ] 45 - }, 46 - "repo": { 47 - "type": "ref", 48 - "ref": "#triggerRepo" 49 - }, 50 - "push": { 51 - "type": "ref", 52 - "ref": "#pushTriggerData" 53 - }, 54 - "pullRequest": { 55 - "type": "ref", 56 - "ref": "#pullRequestTriggerData" 57 - }, 58 - "manual": { 59 - "type": "ref", 60 - "ref": "#manualTriggerData" 61 - } 62 - } 63 - }, 64 - "triggerRepo": { 65 - "type": "object", 66 - "required": [ 67 - "knot", 68 - "did", 69 - "repo", 70 - "defaultBranch" 71 - ], 72 - "properties": { 73 - "knot": { 74 - "type": "string" 75 - }, 76 - "did": { 77 - "type": "string", 78 - "format": "did" 79 - }, 80 - "repo": { 81 - "type": "string" 82 - }, 83 - "defaultBranch": { 84 - "type": "string" 85 - } 86 - } 87 - }, 88 - "pushTriggerData": { 89 - "type": "object", 90 - "required": [ 91 - "ref", 92 - "newSha", 93 - "oldSha" 94 - ], 95 - "properties": { 96 - "ref": { 97 - "type": "string" 98 - }, 99 - "newSha": { 100 - "type": "string", 101 - "minLength": 40, 102 - "maxLength": 40 103 - }, 104 - "oldSha": { 105 - "type": "string", 106 - "minLength": 40, 107 - "maxLength": 40 108 - } 109 - } 110 - }, 111 - "pullRequestTriggerData": { 112 - "type": "object", 113 - "required": [ 114 - "sourceBranch", 115 - "targetBranch", 116 - "sourceSha", 117 - "action" 118 - ], 119 - "properties": { 120 - "sourceBranch": { 121 - "type": "string" 122 - }, 123 - "targetBranch": { 124 - "type": "string" 125 - }, 126 - "sourceSha": { 127 - "type": "string", 128 - "minLength": 40, 129 - "maxLength": 40 130 - }, 131 - "action": { 132 - "type": "string" 133 - } 134 - } 135 - }, 136 - "manualTriggerData": { 137 - "type": "object", 138 - "properties": { 139 - "inputs": { 140 - "type": "array", 141 - "items": { 142 - "type": "ref", 143 - "ref": "#pair" 144 - } 145 - } 146 - } 147 - }, 148 - "workflow": { 149 - "type": "object", 150 - "required": [ 151 - "name", 152 - "dependencies", 153 - "steps", 154 - "environment", 155 - "clone" 156 - ], 157 - "properties": { 158 - "name": { 159 - "type": "string" 160 - }, 161 - "dependencies": { 162 - "type": "array", 163 - "items": { 164 - "type": "ref", 165 - "ref": "#dependency" 166 - } 167 - }, 168 - "steps": { 169 - "type": "array", 170 - "items": { 171 - "type": "ref", 172 - "ref": "#step" 173 - } 174 - }, 175 - "environment": { 176 - "type": "array", 177 - "items": { 178 - "type": "ref", 179 - "ref": "#pair" 180 - } 181 - }, 182 - "clone": { 183 - "type": "ref", 184 - "ref": "#cloneOpts" 185 - } 186 - } 187 - }, 188 - "dependency": { 189 - "type": "object", 190 - "required": [ 191 - "registry", 192 - "packages" 193 - ], 194 - "properties": { 195 - "registry": { 196 - "type": "string" 197 - }, 198 - "packages": { 199 - "type": "array", 200 - "items": { 201 - "type": "string" 202 - } 203 - } 204 - } 205 - }, 206 - "cloneOpts": { 207 - "type": "object", 208 - "required": [ 209 - "skip", 210 - "depth", 211 - "submodules" 212 - ], 213 - "properties": { 214 - "skip": { 215 - "type": "boolean" 216 - }, 217 - "depth": { 218 - "type": "integer" 219 - }, 220 - "submodules": { 221 - "type": "boolean" 222 - } 223 - } 224 - }, 225 - "step": { 226 - "type": "object", 227 - "required": [ 228 - "name", 229 - "command" 230 - ], 231 - "properties": { 232 - "name": { 233 - "type": "string" 234 - }, 235 - "command": { 236 - "type": "string" 237 - }, 238 - "environment": { 239 - "type": "array", 240 - "items": { 241 - "type": "ref", 242 - "ref": "#pair" 243 - } 244 - } 245 - } 246 - }, 247 - "pair": { 248 - "type": "object", 249 - "required": [ 250 - "key", 251 - "value" 252 - ], 253 - "properties": { 254 - "key": { 255 - "type": "string" 256 - }, 257 - "value": { 258 - "type": "string" 259 - } 260 - } 261 - } 262 - } 263 - }
-31
lexicons/removeSecret.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.removeSecret", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Remove a CI secret", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": [ 13 - "repo", 14 - "key" 15 - ], 16 - "properties": { 17 - "repo": { 18 - "type": "string", 19 - "format": "at-uri" 20 - }, 21 - "key": { 22 - "type": "string", 23 - "maxLength": 50, 24 - "minLength": 1 25 - } 26 - } 27 - } 28 - } 29 - } 30 - } 31 - }
+37
lexicons/repo/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+52
lexicons/repo/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+29
lexicons/repo/defaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+67
lexicons/repo/listSecrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+31
lexicons/repo/removeSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+54
lexicons/repo/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "format": "datetime", 38 + "minGraphemes": 1, 39 + "maxGraphemes": 140 40 + }, 41 + "source": { 42 + "type": "string", 43 + "format": "uri", 44 + "description": "source of the repo" 45 + }, 46 + "createdAt": { 47 + "type": "string", 48 + "format": "datetime" 49 + } 50 + } 51 + } 52 + } 53 + } 54 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
+25
lexicons/spindle/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - } 25 -
+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 + }
+2 -4
spindle/config/config.go
··· 28 28 } 29 29 30 30 type OpenBaoConfig struct { 31 - Addr string `env:"ADDR"` 32 - RoleID string `env:"ROLE_ID"` 33 - SecretID string `env:"SECRET_ID"` 34 - Mount string `env:"MOUNT, default=spindle"` 31 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 32 + Mount string `env:"MOUNT, default=spindle"` 35 33 } 36 34 37 35 type Pipelines struct {
+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 + }
+56 -150
spindle/secrets/openbao.go
··· 6 6 "log/slog" 7 7 "path" 8 8 "strings" 9 - "sync" 10 9 "time" 11 10 12 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 16 15 type OpenBaoManager struct { 17 16 client *vault.Client 18 17 mountPath string 19 - roleID string 20 - secretID string 21 - stopCh chan struct{} 22 - tokenMu sync.RWMutex 23 18 logger *slog.Logger 24 19 } 25 20 ··· 31 26 } 32 27 } 33 28 34 - func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 35 - if address == "" { 36 - return nil, fmt.Errorf("address cannot be empty") 37 - } 38 - if roleID == "" { 39 - return nil, fmt.Errorf("role_id cannot be empty") 40 - } 41 - if secretID == "" { 42 - return nil, fmt.Errorf("secret_id cannot be empty") 29 + // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 + // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 + // The proxy handles all authentication automatically via Auto-Auth 32 + func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 + if proxyAddress == "" { 34 + return nil, fmt.Errorf("proxy address cannot be empty") 43 35 } 44 36 45 37 config := vault.DefaultConfig() 46 - config.Address = address 38 + config.Address = proxyAddress 47 39 48 40 client, err := vault.NewClient(config) 49 41 if err != nil { 50 42 return nil, fmt.Errorf("failed to create openbao client: %w", err) 51 43 } 52 44 53 - // Authenticate using AppRole 54 - err = authenticateAppRole(client, roleID, secretID) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err) 57 - } 58 - 59 45 manager := &OpenBaoManager{ 60 46 client: client, 61 47 mountPath: "spindle", // default KV v2 mount path 62 - roleID: roleID, 63 - secretID: secretID, 64 - stopCh: make(chan struct{}), 65 48 logger: logger, 66 49 } 67 50 ··· 69 52 opt(manager) 70 53 } 71 54 72 - go manager.tokenRenewalLoop() 73 - 74 - return manager, nil 75 - } 76 - 77 - // authenticateAppRole authenticates the client using AppRole method 78 - func authenticateAppRole(client *vault.Client, roleID, secretID string) error { 79 - authData := map[string]interface{}{ 80 - "role_id": roleID, 81 - "secret_id": secretID, 82 - } 83 - 84 - resp, err := client.Logical().Write("auth/approle/login", authData) 85 - if err != nil { 86 - return fmt.Errorf("failed to login with AppRole: %w", err) 87 - } 88 - 89 - if resp == nil || resp.Auth == nil { 90 - return fmt.Errorf("no auth info returned from AppRole login") 91 - } 92 - 93 - client.SetToken(resp.Auth.ClientToken) 94 - return nil 95 - } 96 - 97 - // stop stops the token renewal goroutine 98 - func (v *OpenBaoManager) Stop() { 99 - close(v.stopCh) 100 - } 101 - 102 - // tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens 103 - func (v *OpenBaoManager) tokenRenewalLoop() { 104 - ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds 105 - defer ticker.Stop() 106 - 107 - for { 108 - select { 109 - case <-v.stopCh: 110 - return 111 - case <-ticker.C: 112 - ctx := context.Background() 113 - if err := v.ensureValidToken(ctx); err != nil { 114 - v.logger.Error("openbao token renewal failed", "error", err) 115 - } 116 - } 117 - } 118 - } 119 - 120 - // ensureValidToken checks if the current token is valid and renews or re-authenticates if needed 121 - func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error { 122 - v.tokenMu.Lock() 123 - defer v.tokenMu.Unlock() 124 - 125 - // check current token info 126 - tokenInfo, err := v.client.Auth().Token().LookupSelf() 127 - if err != nil { 128 - // token is invalid, need to re-authenticate 129 - v.logger.Warn("token lookup failed, re-authenticating", "error", err) 130 - return v.reAuthenticate() 131 - } 132 - 133 - if tokenInfo == nil || tokenInfo.Data == nil { 134 - return v.reAuthenticate() 135 - } 136 - 137 - // check TTL 138 - ttlRaw, ok := tokenInfo.Data["ttl"] 139 - if !ok { 140 - return v.reAuthenticate() 141 - } 142 - 143 - var ttl int64 144 - switch t := ttlRaw.(type) { 145 - case int64: 146 - ttl = t 147 - case float64: 148 - ttl = int64(t) 149 - case int: 150 - ttl = int64(t) 151 - default: 152 - return v.reAuthenticate() 153 - } 154 - 155 - // if TTL is less than 5 minutes, try to renew 156 - if ttl < 300 { 157 - v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl) 158 - 159 - renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h 160 - if err != nil { 161 - v.logger.Warn("token renewal failed, re-authenticating", "error", err) 162 - return v.reAuthenticate() 163 - } 164 - 165 - if renewResp == nil || renewResp.Auth == nil { 166 - v.logger.Warn("token renewal returned no auth info, re-authenticating") 167 - return v.reAuthenticate() 168 - } 169 - 170 - v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration) 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 171 57 } 172 58 173 - return nil 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 174 61 } 175 62 176 - // reAuthenticate performs a fresh authentication using AppRole 177 - func (v *OpenBaoManager) reAuthenticate() error { 178 - v.logger.Info("re-authenticating with approle") 63 + // testConnection verifies that we can connect to the proxy 64 + func (v *OpenBaoManager) testConnection() error { 65 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 + defer cancel() 179 67 180 - err := authenticateAppRole(v.client, v.roleID, v.secretID) 68 + // try token self-lookup as a quick way to verify proxy works 69 + // and is authenticated 70 + _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 181 71 if err != nil { 182 - return fmt.Errorf("re-authentication failed: %w", err) 72 + return fmt.Errorf("proxy connection test failed: %w", err) 183 73 } 184 74 185 - v.logger.Info("re-authentication successful") 186 75 return nil 187 76 } 188 77 189 78 func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 190 - v.tokenMu.RLock() 191 - defer v.tokenMu.RUnlock() 192 79 if err := ValidateKey(secret.Key); err != nil { 193 80 return err 194 81 } 195 82 196 83 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 197 - 198 - fmt.Println(v.mountPath, secretPath) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 199 85 86 + // Check if secret already exists 200 87 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 201 88 if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 202 90 return ErrKeyAlreadyPresent 203 91 } 204 92 ··· 210 98 "created_by": secret.CreatedBy.String(), 211 99 } 212 100 213 - _, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 101 + v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 + resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 214 103 if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 215 105 return fmt.Errorf("failed to store secret in openbao: %w", err) 216 106 } 217 107 108 + v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 + 110 + v.logger.Debug("verifying secret was written", "path", secretPath) 111 + readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 + if err != nil { 113 + v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 + return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 + } 116 + 117 + if readBack == nil || readBack.Data == nil { 118 + v.logger.Error("secret verification returned empty data", "path", secretPath) 119 + return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 + } 121 + 122 + v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 218 123 return nil 219 124 } 220 125 221 126 func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 222 - v.tokenMu.RLock() 223 - defer v.tokenMu.RUnlock() 224 127 secretPath := v.buildSecretPath(secret.Repo, secret.Key) 225 128 129 + // check if secret exists 226 130 existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 227 131 if err != nil || existing == nil { 228 132 return ErrKeyNotFound 229 133 } 230 134 231 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 232 136 if err != nil { 233 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 234 138 } 235 139 140 + v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 236 141 return nil 237 142 } 238 143 239 144 func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 240 - v.tokenMu.RLock() 241 - defer v.tokenMu.RUnlock() 242 145 repoPath := v.buildRepoPath(repo) 243 146 244 - secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 147 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 245 148 if err != nil { 246 149 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 247 150 return []LockedSecret{}, nil ··· 266 169 continue 267 170 } 268 171 269 - secretPath := path.Join(repoPath, key) 172 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 270 173 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 271 174 if err != nil { 272 - continue // Skip secrets we can't read 175 + v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 + continue 273 177 } 274 178 275 179 if secretData == nil || secretData.Data == nil { ··· 308 212 secrets = append(secrets, secret) 309 213 } 310 214 215 + v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 311 216 return secrets, nil 312 217 } 313 218 314 219 func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 315 - v.tokenMu.RLock() 316 - defer v.tokenMu.RUnlock() 317 220 repoPath := v.buildRepoPath(repo) 318 221 319 - secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 222 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 320 223 if err != nil { 321 224 if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 322 225 return []UnlockedSecret{}, nil ··· 341 244 continue 342 245 } 343 246 344 - secretPath := path.Join(repoPath, key) 247 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 345 248 secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 346 249 if err != nil { 250 + v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 347 251 continue 348 252 } 349 253 ··· 355 259 356 260 valueStr, ok := data["value"].(string) 357 261 if !ok { 358 - continue // skip secrets without values 262 + v.logger.Warn("secret missing value", "path", secretPath) 263 + continue 359 264 } 360 265 361 266 createdAtStr, ok := data["created_at"].(string) ··· 389 294 secrets = append(secrets, secret) 390 295 } 391 296 297 + v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 392 298 return secrets, nil 393 299 } 394 300 395 - // buildRepoPath creates an OpenBao path for a repository 301 + // buildRepoPath creates a safe path for a repository 396 302 func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 397 303 // convert DidSlashRepo to a safe path by replacing special characters 398 304 repoPath := strings.ReplaceAll(string(repo), "/", "_") ··· 401 307 return fmt.Sprintf("repos/%s", repoPath) 402 308 } 403 309 404 - // buildSecretPath creates an OpenBao path for a specific secret 310 + // buildSecretPath creates a path for a specific secret 405 311 func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 406 312 return path.Join(v.buildRepoPath(repo), key) 407 313 }
+59 -84
spindle/secrets/openbao_test.go
··· 16 16 secrets map[string]UnlockedSecret // key: repo_key format 17 17 shouldError bool 18 18 errorToReturn error 19 - stopped bool 20 19 } 21 20 22 21 func NewMockOpenBaoManager() *MockOpenBaoManager { ··· 31 30 func (m *MockOpenBaoManager) ClearError() { 32 31 m.shouldError = false 33 32 m.errorToReturn = nil 34 - } 35 - 36 - func (m *MockOpenBaoManager) Stop() { 37 - m.stopped = true 38 - } 39 - 40 - func (m *MockOpenBaoManager) IsStopped() bool { 41 - return m.stopped 42 33 } 43 34 44 35 func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { ··· 118 109 } 119 110 } 120 111 112 + // Test MockOpenBaoManager interface compliance 113 + func TestMockOpenBaoManagerInterface(t *testing.T) { 114 + var _ Manager = (*MockOpenBaoManager)(nil) 115 + } 116 + 121 117 func TestOpenBaoManagerInterface(t *testing.T) { 122 118 var _ Manager = (*OpenBaoManager)(nil) 123 119 } ··· 125 121 func TestNewOpenBaoManager(t *testing.T) { 126 122 tests := []struct { 127 123 name string 128 - address string 129 - roleID string 130 - secretID string 124 + proxyAddr string 131 125 opts []OpenBaoManagerOpt 132 126 expectError bool 133 127 errorContains string 134 128 }{ 135 129 { 136 - name: "empty address", 137 - address: "", 138 - roleID: "test-role-id", 139 - secretID: "test-secret-id", 130 + name: "empty proxy address", 131 + proxyAddr: "", 140 132 opts: nil, 141 133 expectError: true, 142 - errorContains: "address cannot be empty", 134 + errorContains: "proxy address cannot be empty", 143 135 }, 144 136 { 145 - name: "empty role_id", 146 - address: "http://localhost:8200", 147 - roleID: "", 148 - secretID: "test-secret-id", 137 + name: "valid proxy address", 138 + proxyAddr: "http://localhost:8200", 149 139 opts: nil, 150 - expectError: true, 151 - errorContains: "role_id cannot be empty", 140 + expectError: true, // Will fail because no real proxy is running 141 + errorContains: "failed to connect to bao proxy", 152 142 }, 153 143 { 154 - name: "empty secret_id", 155 - address: "http://localhost:8200", 156 - roleID: "test-role-id", 157 - secretID: "", 158 - opts: nil, 159 - expectError: true, 160 - errorContains: "secret_id cannot be empty", 144 + name: "with mount path option", 145 + proxyAddr: "http://localhost:8200", 146 + opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")}, 147 + expectError: true, // Will fail because no real proxy is running 148 + errorContains: "failed to connect to bao proxy", 161 149 }, 162 150 } 163 151 164 152 for _, tt := range tests { 165 153 t.Run(tt.name, func(t *testing.T) { 166 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 167 - manager, err := NewOpenBaoManager(tt.address, tt.roleID, tt.secretID, logger, tt.opts...) 155 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 168 156 169 157 if tt.expectError { 170 158 assert.Error(t, err) 171 159 assert.Nil(t, manager) 172 160 assert.Contains(t, err.Error(), tt.errorContains) 173 161 } else { 174 - // For valid configurations, we expect an error during authentication 175 - // since we're not connecting to a real OpenBao server 176 - assert.Error(t, err) 177 - assert.Nil(t, manager) 162 + assert.NoError(t, err) 163 + assert.NotNil(t, manager) 178 164 } 179 165 }) 180 166 } ··· 253 239 assert.Equal(t, "custom-mount", manager.mountPath) 254 240 } 255 241 256 - func TestOpenBaoManager_Stop(t *testing.T) { 257 - // Create a manager with minimal setup 258 - manager := &OpenBaoManager{ 259 - mountPath: "test", 260 - stopCh: make(chan struct{}), 261 - } 262 - 263 - // Verify the manager implements Stopper interface 264 - var stopper Stopper = manager 265 - assert.NotNil(t, stopper) 266 - 267 - // Call Stop and verify it doesn't panic 268 - assert.NotPanics(t, func() { 269 - manager.Stop() 270 - }) 271 - 272 - // Verify the channel was closed 273 - select { 274 - case <-manager.stopCh: 275 - // Channel was closed as expected 276 - default: 277 - t.Error("Expected stop channel to be closed after Stop()") 278 - } 279 - } 280 - 281 - func TestOpenBaoManager_StopperInterface(t *testing.T) { 282 - manager := &OpenBaoManager{} 283 - 284 - // Verify that OpenBaoManager implements the Stopper interface 285 - _, ok := interface{}(manager).(Stopper) 286 - assert.True(t, ok, "OpenBaoManager should implement Stopper interface") 287 - } 288 - 289 - // Test MockOpenBaoManager interface compliance 290 - func TestMockOpenBaoManagerInterface(t *testing.T) { 291 - var _ Manager = (*MockOpenBaoManager)(nil) 292 - var _ Stopper = (*MockOpenBaoManager)(nil) 293 - } 294 - 295 242 func TestMockOpenBaoManager_AddSecret(t *testing.T) { 296 243 tests := []struct { 297 244 name string ··· 563 510 assert.NoError(t, err) 564 511 } 565 512 566 - func TestMockOpenBaoManager_Stop(t *testing.T) { 567 - mock := NewMockOpenBaoManager() 568 - 569 - assert.False(t, mock.IsStopped()) 570 - 571 - mock.Stop() 572 - 573 - assert.True(t, mock.IsStopped()) 574 - } 575 - 576 513 func TestMockOpenBaoManager_Integration(t *testing.T) { 577 514 tests := []struct { 578 515 name string ··· 628 565 }) 629 566 } 630 567 } 568 + 569 + func TestOpenBaoManager_ProxyConfiguration(t *testing.T) { 570 + tests := []struct { 571 + name string 572 + proxyAddr string 573 + description string 574 + }{ 575 + { 576 + name: "default_localhost", 577 + proxyAddr: "http://127.0.0.1:8200", 578 + description: "Should connect to default localhost proxy", 579 + }, 580 + { 581 + name: "custom_host", 582 + proxyAddr: "http://bao-proxy:8200", 583 + description: "Should connect to custom proxy host", 584 + }, 585 + { 586 + name: "https_proxy", 587 + proxyAddr: "https://127.0.0.1:8200", 588 + description: "Should connect to HTTPS proxy", 589 + }, 590 + } 591 + 592 + for _, tt := range tests { 593 + t.Run(tt.name, func(t *testing.T) { 594 + t.Log("Testing scenario:", tt.description) 595 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 596 + 597 + // All these will fail because no real proxy is running 598 + // but we can test that the configuration is properly accepted 599 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 600 + assert.Error(t, err) // Expected because no real proxy 601 + assert.Nil(t, manager) 602 + assert.Contains(t, err.Error(), "failed to connect to bao proxy") 603 + }) 604 + } 605 + }
+13 -6
spindle/secrets/policy.hcl
··· 1 - # KV v2 data operations 2 - path "spindle/data/*" { 1 + # Allow full access to the spindle KV mount 2 + path "spindle/*" { 3 3 capabilities = ["create", "read", "update", "delete", "list"] 4 4 } 5 5 6 - # KV v2 metadata operations (needed for listing) 6 + path "spindle/data/*" { 7 + capabilities = ["create", "read", "update", "delete"] 8 + } 9 + 7 10 path "spindle/metadata/*" { 8 11 capabilities = ["list", "read", "delete"] 9 12 } 10 13 11 - # Root path access (needed for mount-level operations) 12 - path "spindle/*" { 13 - capabilities = ["list"] 14 + # Allow listing mounts (for connection testing) 15 + path "sys/mounts" { 16 + capabilities = ["read"] 14 17 } 15 18 19 + # Allow token self-lookup (for health checks) 20 + path "auth/token/lookup-self" { 21 + capabilities = ["read"] 22 + }
+31 -16
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" ··· 71 72 var vault secrets.Manager 72 73 switch cfg.Server.Secrets.Provider { 73 74 case "openbao": 74 - if cfg.Server.Secrets.OpenBao.Addr == "" { 75 - return fmt.Errorf("openbao address is required when using openbao secrets provider") 76 - } 77 - if cfg.Server.Secrets.OpenBao.RoleID == "" { 78 - return fmt.Errorf("openbao role_id is required when using openbao secrets provider") 79 - } 80 - if cfg.Server.Secrets.OpenBao.SecretID == "" { 81 - return fmt.Errorf("openbao secret_id is required when using openbao secrets provider") 75 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 76 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 82 77 } 83 78 vault, err = secrets.NewOpenBaoManager( 84 - cfg.Server.Secrets.OpenBao.Addr, 85 - cfg.Server.Secrets.OpenBao.RoleID, 86 - cfg.Server.Secrets.OpenBao.SecretID, 79 + cfg.Server.Secrets.OpenBao.ProxyAddr, 87 80 logger, 88 81 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 89 82 ) 90 83 if err != nil { 91 84 return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 92 85 } 93 - logger.Info("using openbao secrets provider", "address", cfg.Server.Secrets.OpenBao.Addr, "mount", cfg.Server.Secrets.OpenBao.Mount) 86 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 94 87 case "sqlite", "": 95 88 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 96 89 if err != nil { ··· 101 94 return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 102 95 } 103 96 104 - 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) 105 103 if err != nil { 106 104 return err 107 105 } 108 106 109 - jq := queue.NewQueue(100, 2) 107 + jq := queue.NewQueue(100, 5) 110 108 111 109 collections := []string{ 112 110 tangled.SpindleMemberNSID, 113 111 tangled.RepoNSID, 112 + tangled.RepoCollaboratorNSID, 114 113 } 115 114 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 116 115 if err != nil { 117 116 return fmt.Errorf("failed to setup jetstream client: %w", err) 118 117 } 119 118 jc.AddDid(cfg.Server.Owner) 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 + } 120 128 121 129 resolver := idresolver.DefaultResolver() 122 130 ··· 186 194 }() 187 195 188 196 logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 189 - 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))) 190 198 191 199 return nil 192 200 } 193 201 194 - func (s *Spindle) Router() http.Handler { 202 + func (s *Spindle) Router(oidcg *oidc.OidcTokenGenerator) http.Handler { 195 203 mux := chi.NewRouter() 196 204 197 205 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { ··· 202 210 w.Write([]byte(s.cfg.Server.Owner)) 203 211 }) 204 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) 205 216 206 217 mux.Mount("/xrpc", s.XrpcRouter()) 207 218 return mux ··· 238 249 239 250 if tpl.TriggerMetadata.Repo == nil { 240 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) 241 256 } 242 257 243 258 // filter by repos