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

Compare changes

Choose any two refs to compare.

+430
api/tangled/cbor_gen.go
··· 5854 5854 5855 5855 return nil 5856 5856 } 5857 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5858 + if t == nil { 5859 + _, err := w.Write(cbg.CborNull) 5860 + return err 5861 + } 5862 + 5863 + cw := cbg.NewCborWriter(w) 5864 + 5865 + if _, err := cw.Write([]byte{164}); err != nil { 5866 + return err 5867 + } 5868 + 5869 + // t.Repo (string) (string) 5870 + if len("repo") > 1000000 { 5871 + return xerrors.Errorf("Value in field \"repo\" was too long") 5872 + } 5873 + 5874 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5875 + return err 5876 + } 5877 + if _, err := cw.WriteString(string("repo")); err != nil { 5878 + return err 5879 + } 5880 + 5881 + if len(t.Repo) > 1000000 { 5882 + return xerrors.Errorf("Value in field t.Repo was too long") 5883 + } 5884 + 5885 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5886 + return err 5887 + } 5888 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5889 + return err 5890 + } 5891 + 5892 + // t.LexiconTypeID (string) (string) 5893 + if len("$type") > 1000000 { 5894 + return xerrors.Errorf("Value in field \"$type\" was too long") 5895 + } 5896 + 5897 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5898 + return err 5899 + } 5900 + if _, err := cw.WriteString(string("$type")); err != nil { 5901 + return err 5902 + } 5903 + 5904 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5905 + return err 5906 + } 5907 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5908 + return err 5909 + } 5910 + 5911 + // t.Subject (string) (string) 5912 + if len("subject") > 1000000 { 5913 + return xerrors.Errorf("Value in field \"subject\" was too long") 5914 + } 5915 + 5916 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5917 + return err 5918 + } 5919 + if _, err := cw.WriteString(string("subject")); err != nil { 5920 + return err 5921 + } 5922 + 5923 + if len(t.Subject) > 1000000 { 5924 + return xerrors.Errorf("Value in field t.Subject was too long") 5925 + } 5926 + 5927 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5928 + return err 5929 + } 5930 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5931 + return err 5932 + } 5933 + 5934 + // t.CreatedAt (string) (string) 5935 + if len("createdAt") > 1000000 { 5936 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5937 + } 5938 + 5939 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5940 + return err 5941 + } 5942 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5943 + return err 5944 + } 5945 + 5946 + if len(t.CreatedAt) > 1000000 { 5947 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5948 + } 5949 + 5950 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5951 + return err 5952 + } 5953 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5954 + return err 5955 + } 5956 + return nil 5957 + } 5958 + 5959 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5960 + *t = RepoCollaborator{} 5961 + 5962 + cr := cbg.NewCborReader(r) 5963 + 5964 + maj, extra, err := cr.ReadHeader() 5965 + if err != nil { 5966 + return err 5967 + } 5968 + defer func() { 5969 + if err == io.EOF { 5970 + err = io.ErrUnexpectedEOF 5971 + } 5972 + }() 5973 + 5974 + if maj != cbg.MajMap { 5975 + return fmt.Errorf("cbor input should be of type map") 5976 + } 5977 + 5978 + if extra > cbg.MaxLength { 5979 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5980 + } 5981 + 5982 + n := extra 5983 + 5984 + nameBuf := make([]byte, 9) 5985 + for i := uint64(0); i < n; i++ { 5986 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5987 + if err != nil { 5988 + return err 5989 + } 5990 + 5991 + if !ok { 5992 + // Field doesn't exist on this type, so ignore it 5993 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5994 + return err 5995 + } 5996 + continue 5997 + } 5998 + 5999 + switch string(nameBuf[:nameLen]) { 6000 + // t.Repo (string) (string) 6001 + case "repo": 6002 + 6003 + { 6004 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6005 + if err != nil { 6006 + return err 6007 + } 6008 + 6009 + t.Repo = string(sval) 6010 + } 6011 + // t.LexiconTypeID (string) (string) 6012 + case "$type": 6013 + 6014 + { 6015 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6016 + if err != nil { 6017 + return err 6018 + } 6019 + 6020 + t.LexiconTypeID = string(sval) 6021 + } 6022 + // t.Subject (string) (string) 6023 + case "subject": 6024 + 6025 + { 6026 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6027 + if err != nil { 6028 + return err 6029 + } 6030 + 6031 + t.Subject = string(sval) 6032 + } 6033 + // t.CreatedAt (string) (string) 6034 + case "createdAt": 6035 + 6036 + { 6037 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6038 + if err != nil { 6039 + return err 6040 + } 6041 + 6042 + t.CreatedAt = string(sval) 6043 + } 6044 + 6045 + default: 6046 + // Field doesn't exist on this type, so ignore it 6047 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 6048 + return err 6049 + } 6050 + } 6051 + } 6052 + 6053 + return nil 6054 + } 5857 6055 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5858 6056 if t == nil { 5859 6057 _, err := w.Write(cbg.CborNull) ··· 8225 8423 8226 8424 return nil 8227 8425 } 8426 + func (t *String) MarshalCBOR(w io.Writer) error { 8427 + if t == nil { 8428 + _, err := w.Write(cbg.CborNull) 8429 + return err 8430 + } 8431 + 8432 + cw := cbg.NewCborWriter(w) 8433 + 8434 + if _, err := cw.Write([]byte{165}); err != nil { 8435 + return err 8436 + } 8437 + 8438 + // t.LexiconTypeID (string) (string) 8439 + if len("$type") > 1000000 { 8440 + return xerrors.Errorf("Value in field \"$type\" was too long") 8441 + } 8442 + 8443 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 8444 + return err 8445 + } 8446 + if _, err := cw.WriteString(string("$type")); err != nil { 8447 + return err 8448 + } 8449 + 8450 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 8451 + return err 8452 + } 8453 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 8454 + return err 8455 + } 8456 + 8457 + // t.Contents (string) (string) 8458 + if len("contents") > 1000000 { 8459 + return xerrors.Errorf("Value in field \"contents\" was too long") 8460 + } 8461 + 8462 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 8463 + return err 8464 + } 8465 + if _, err := cw.WriteString(string("contents")); err != nil { 8466 + return err 8467 + } 8468 + 8469 + if len(t.Contents) > 1000000 { 8470 + return xerrors.Errorf("Value in field t.Contents was too long") 8471 + } 8472 + 8473 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 8474 + return err 8475 + } 8476 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 8477 + return err 8478 + } 8479 + 8480 + // t.Filename (string) (string) 8481 + if len("filename") > 1000000 { 8482 + return xerrors.Errorf("Value in field \"filename\" was too long") 8483 + } 8484 + 8485 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 8486 + return err 8487 + } 8488 + if _, err := cw.WriteString(string("filename")); err != nil { 8489 + return err 8490 + } 8491 + 8492 + if len(t.Filename) > 1000000 { 8493 + return xerrors.Errorf("Value in field t.Filename was too long") 8494 + } 8495 + 8496 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 8497 + return err 8498 + } 8499 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 8500 + return err 8501 + } 8502 + 8503 + // t.CreatedAt (string) (string) 8504 + if len("createdAt") > 1000000 { 8505 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 8506 + } 8507 + 8508 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 8509 + return err 8510 + } 8511 + if _, err := cw.WriteString(string("createdAt")); err != nil { 8512 + return err 8513 + } 8514 + 8515 + if len(t.CreatedAt) > 1000000 { 8516 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 8517 + } 8518 + 8519 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 8520 + return err 8521 + } 8522 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8523 + return err 8524 + } 8525 + 8526 + // t.Description (string) (string) 8527 + if len("description") > 1000000 { 8528 + return xerrors.Errorf("Value in field \"description\" was too long") 8529 + } 8530 + 8531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 8532 + return err 8533 + } 8534 + if _, err := cw.WriteString(string("description")); err != nil { 8535 + return err 8536 + } 8537 + 8538 + if len(t.Description) > 1000000 { 8539 + return xerrors.Errorf("Value in field t.Description was too long") 8540 + } 8541 + 8542 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 8543 + return err 8544 + } 8545 + if _, err := cw.WriteString(string(t.Description)); err != nil { 8546 + return err 8547 + } 8548 + return nil 8549 + } 8550 + 8551 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 8552 + *t = String{} 8553 + 8554 + cr := cbg.NewCborReader(r) 8555 + 8556 + maj, extra, err := cr.ReadHeader() 8557 + if err != nil { 8558 + return err 8559 + } 8560 + defer func() { 8561 + if err == io.EOF { 8562 + err = io.ErrUnexpectedEOF 8563 + } 8564 + }() 8565 + 8566 + if maj != cbg.MajMap { 8567 + return fmt.Errorf("cbor input should be of type map") 8568 + } 8569 + 8570 + if extra > cbg.MaxLength { 8571 + return fmt.Errorf("String: map struct too large (%d)", extra) 8572 + } 8573 + 8574 + n := extra 8575 + 8576 + nameBuf := make([]byte, 11) 8577 + for i := uint64(0); i < n; i++ { 8578 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8579 + if err != nil { 8580 + return err 8581 + } 8582 + 8583 + if !ok { 8584 + // Field doesn't exist on this type, so ignore it 8585 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 8586 + return err 8587 + } 8588 + continue 8589 + } 8590 + 8591 + switch string(nameBuf[:nameLen]) { 8592 + // t.LexiconTypeID (string) (string) 8593 + case "$type": 8594 + 8595 + { 8596 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8597 + if err != nil { 8598 + return err 8599 + } 8600 + 8601 + t.LexiconTypeID = string(sval) 8602 + } 8603 + // t.Contents (string) (string) 8604 + case "contents": 8605 + 8606 + { 8607 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8608 + if err != nil { 8609 + return err 8610 + } 8611 + 8612 + t.Contents = string(sval) 8613 + } 8614 + // t.Filename (string) (string) 8615 + case "filename": 8616 + 8617 + { 8618 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8619 + if err != nil { 8620 + return err 8621 + } 8622 + 8623 + t.Filename = string(sval) 8624 + } 8625 + // t.CreatedAt (string) (string) 8626 + case "createdAt": 8627 + 8628 + { 8629 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8630 + if err != nil { 8631 + return err 8632 + } 8633 + 8634 + t.CreatedAt = string(sval) 8635 + } 8636 + // t.Description (string) (string) 8637 + case "description": 8638 + 8639 + { 8640 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8641 + if err != nil { 8642 + return err 8643 + } 8644 + 8645 + t.Description = string(sval) 8646 + } 8647 + 8648 + default: 8649 + // Field doesn't exist on this type, so ignore it 8650 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 8651 + return err 8652 + } 8653 + } 8654 + } 8655 + 8656 + return nil 8657 + }
+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 + }
+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 + }
+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 + }
+70
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 ··· 583 598 alter table repos add column spindle text; 584 599 `) 585 600 return nil 601 + }) 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 586 656 }) 587 657 588 658 return &DB{db}, nil
-34
appview/db/repos.go
··· 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) ··· 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 + }
+56
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 } ··· 549 551 550 552 return nil 551 553 } 554 + 555 + func (i *Ingester) ingestString(e *models.Event) error { 556 + did := e.Did 557 + rkey := e.Commit.RKey 558 + 559 + var err error 560 + 561 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 562 + l.Info("ingesting record") 563 + 564 + ddb, ok := i.Db.Execer.(*db.DB) 565 + if !ok { 566 + return fmt.Errorf("failed to index string record, invalid db cast") 567 + } 568 + 569 + switch e.Commit.Operation { 570 + case models.CommitOperationCreate, models.CommitOperationUpdate: 571 + raw := json.RawMessage(e.Commit.Record) 572 + record := tangled.String{} 573 + err = json.Unmarshal(raw, &record) 574 + if err != nil { 575 + l.Error("invalid record", "err", err) 576 + return err 577 + } 578 + 579 + string := db.StringFromRecord(did, rkey, record) 580 + 581 + if err = string.Validate(); err != nil { 582 + l.Error("invalid record", "err", err) 583 + return err 584 + } 585 + 586 + if err = db.AddString(ddb, string); err != nil { 587 + l.Error("failed to add string", "err", err) 588 + return err 589 + } 590 + 591 + return nil 592 + 593 + case models.CommitOperationDelete: 594 + if err := db.DeleteString( 595 + ddb, 596 + db.FilterEq("did", did), 597 + db.FilterEq("rkey", rkey), 598 + ); err != nil { 599 + l.Error("failed to delete", "err", err) 600 + return fmt.Errorf("failed to delete string record: %w", err) 601 + } 602 + 603 + return nil 604 + } 605 + 606 + return nil 607 + }
+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 {
+79 -4
appview/pages/pages.go
··· 31 31 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 32 32 "github.com/alecthomas/chroma/v2/lexers" 33 33 "github.com/alecthomas/chroma/v2/styles" 34 + "github.com/bluesky-social/indigo/atproto/identity" 34 35 "github.com/bluesky-social/indigo/atproto/syntax" 35 36 "github.com/go-git/go-git/v5/plumbing" 36 37 "github.com/go-git/go-git/v5/plumbing/object" ··· 262 263 return p.executePlain("user/login", w, params) 263 264 } 264 265 265 - type SignupParams struct{} 266 + func (p *Pages) Signup(w io.Writer) error { 267 + return p.executePlain("user/signup", w, nil) 268 + } 266 269 267 - func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 - return p.executePlain("user/completeSignup", w, params) 270 + func (p *Pages) CompleteSignup(w io.Writer) error { 271 + return p.executePlain("user/completeSignup", w, nil) 269 272 } 270 273 271 274 type TermsOfServiceParams struct { ··· 413 416 UserDid string 414 417 UserHandle string 415 418 FollowStatus db.FollowStatus 416 - AvatarUri string 417 419 Followers int 418 420 Following int 419 421 ··· 1140 1142 func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { 1141 1143 params.Active = "pipelines" 1142 1144 return p.executeRepo("repo/pipelines/workflow", w, params) 1145 + } 1146 + 1147 + type PutStringParams struct { 1148 + LoggedInUser *oauth.User 1149 + Action string 1150 + 1151 + // this is supplied in the case of editing an existing string 1152 + String db.String 1153 + } 1154 + 1155 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1156 + return p.execute("strings/put", w, params) 1157 + } 1158 + 1159 + type StringsDashboardParams struct { 1160 + LoggedInUser *oauth.User 1161 + Card ProfileCard 1162 + Strings []db.String 1163 + } 1164 + 1165 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1166 + return p.execute("strings/dashboard", w, params) 1167 + } 1168 + 1169 + type SingleStringParams struct { 1170 + LoggedInUser *oauth.User 1171 + ShowRendered bool 1172 + RenderToggle bool 1173 + RenderedContents template.HTML 1174 + String db.String 1175 + Stats db.StringStats 1176 + Owner identity.Identity 1177 + } 1178 + 1179 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1180 + var style *chroma.Style = styles.Get("catpuccin-latte") 1181 + 1182 + if params.ShowRendered { 1183 + switch markup.GetFormat(params.String.Filename) { 1184 + case markup.FormatMarkdown: 1185 + p.rctx.RendererType = markup.RendererTypeDefault 1186 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1187 + params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 1188 + } 1189 + } 1190 + 1191 + c := params.String.Contents 1192 + formatter := chromahtml.New( 1193 + chromahtml.InlineCode(false), 1194 + chromahtml.WithLineNumbers(true), 1195 + chromahtml.WithLinkableLineNumbers(true, "L"), 1196 + chromahtml.Standalone(false), 1197 + chromahtml.WithClasses(true), 1198 + ) 1199 + 1200 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1201 + if lexer == nil { 1202 + lexer = lexers.Fallback 1203 + } 1204 + 1205 + iterator, err := lexer.Tokenise(nil, c) 1206 + if err != nil { 1207 + return fmt.Errorf("chroma tokenize: %w", err) 1208 + } 1209 + 1210 + var code bytes.Buffer 1211 + err = formatter.Format(&code, style, iterator) 1212 + if err != nil { 1213 + return fmt.Errorf("chroma format: %w", err) 1214 + } 1215 + 1216 + params.String.Contents = code.String() 1217 + return p.execute("strings/string", w, params) 1143 1218 } 1144 1219 1145 1220 func (p *Pages) Static() http.Handler {
+42 -8
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <div class="mb-2"> 5 - <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Terms of Service</a> 6 - &nbsp;โ€ข&nbsp; 7 - <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 underline">Privacy Policy</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 8 19 </div> 9 - <div> 10 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 11 33 </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 12 45 </div> 46 + </div> 13 47 </div> 14 48 {{ end }}
+25 -16
appview/pages/templates/layouts/topbar.html
··· 6 6 tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 9 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex items-center gap-4"> 10 + <div id="right-items" class="flex items-center gap-2"> 23 11 {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white"> 25 - {{ i "plus" "w-4 h-4" }} 26 - </a> 12 + {{ block "newButton" . }} {{ end }} 27 13 {{ block "dropDown" . }} {{ end }} 28 14 {{ else }} 29 15 <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 30 20 {{ end }} 31 21 </div> 32 22 </div> 33 23 </nav> 34 24 {{ end }} 35 25 26 + {{ define "newButton" }} 27 + <details class="relative inline-block text-left"> 28 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 + {{ i "plus" "w-4 h-4" }} new 30 + </summary> 31 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 + <a href="/repo/new" class="flex items-center gap-2"> 33 + {{ i "book-plus" "w-4 h-4" }} 34 + new repository 35 + </a> 36 + <a href="/strings/new" class="flex items-center gap-2"> 37 + {{ i "line-squiggle" "w-4 h-4" }} 38 + new string 39 + </a> 40 + </div> 41 + </details> 42 + {{ end }} 43 + 36 44 {{ define "dropDown" }} 37 45 <details class="relative inline-block text-left"> 38 46 <summary ··· 46 54 > 47 55 <a href="/{{ $user }}">profile</a> 48 56 <a href="/{{ $user }}?tab=repos">repositories</a> 57 + <a href="/strings/{{ $user }}">strings</a> 49 58 <a href="/knots">knots</a> 50 59 <a href="/spindles">spindles</a> 51 60 <a href="/settings">settings</a>
+3 -1
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }} privacy policy {{ end }} 2 2 {{ define "content" }} 3 3 <div class="max-w-4xl mx-auto px-4 py-8"> 4 - <div class="prose prose-gray dark:prose-invert max-w-none"> 4 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 + <div class="prose prose-gray dark:prose-invert max-w-none"> 5 6 <h1>Privacy Policy</h1> 6 7 7 8 <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> ··· 125 126 126 127 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 127 128 <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p> 129 + </div> 128 130 </div> 129 131 </div> 130 132 </div>
+3 -1
appview/pages/templates/legal/terms.html
··· 2 2 3 3 {{ define "content" }} 4 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="prose prose-gray dark:prose-invert max-w-none"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 6 7 <h1>Terms of Service</h1> 7 8 8 9 <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p> ··· 63 64 64 65 <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400"> 65 66 <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p> 67 + </div> 66 68 </div> 67 69 </div> 68 70 </div>
+27 -22
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 + <option value="" disabled> 42 + Choose a spindle 43 43 </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> 44 + {{ range $.Spindles }} 45 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 46 + {{ . }} 47 + </option> 48 + {{ end }} 49 + </select> 50 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 51 + {{ i "check" "size-4" }} 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </button> 54 + </form> 55 + {{ end }} 51 56 </div> 52 57 {{ end }} 53 58
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+89
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename with extension" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 + rows="20" 36 + placeholder="Paste your string here!" 37 + required>{{ .String.Contents }}</textarea> 38 + <div class="flex justify-between items-center"> 39 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 40 + <span id="line-count">0 lines</span> 41 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 42 + <span id="byte-count">0 bytes</span> 43 + </div> 44 + <div id="actions" class="flex gap-2 items-center"> 45 + {{ if eq .Action "edit" }} 46 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 47 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 48 + {{ i "x" "size-4" }} 49 + <span class="hidden md:inline">cancel</span> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </a> 52 + {{ end }} 53 + <button 54 + type="submit" 55 + id="new-button" 56 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 57 + > 58 + <span class="inline-flex items-center gap-2"> 59 + {{ i "arrow-up" "w-4 h-4" }} 60 + publish 61 + </span> 62 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 63 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 64 + </span> 65 + </button> 66 + </div> 67 + </div> 68 + <script> 69 + (function() { 70 + const textarea = document.getElementById('content-textarea'); 71 + const lineCount = document.getElementById('line-count'); 72 + const byteCount = document.getElementById('byte-count'); 73 + function updateStats() { 74 + const content = textarea.value; 75 + const lines = content === '' ? 0 : content.split('\n').length; 76 + const bytes = new TextEncoder().encode(content).length; 77 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 78 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 79 + } 80 + textarea.addEventListener('input', updateStats); 81 + textarea.addEventListener('paste', () => { 82 + setTimeout(updateStats, 0); 83 + }); 84 + updateStats(); 85 + })(); 86 + </script> 87 + <div id="error" class="error dark:text-red-400"></div> 88 + </form> 89 + {{ end }}
+17
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "topbar" }} 4 + {{ template "layouts/topbar" $ }} 5 + {{ end }} 6 + 7 + {{ define "content" }} 8 + <div class="px-6 py-2 mb-4"> 9 + {{ if eq .Action "new" }} 10 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 11 + <p class="">Store and share code snippets with ease.</p> 12 + {{ else }} 13 + <p class="text-xl font-bold dark:text-white">Edit string</p> 14 + {{ end }} 15 + </div> 16 + {{ template "strings/fragments/form" . }} 17 + {{ end }}
+85
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "topbar" }} 12 + {{ template "layouts/topbar" $ }} 13 + {{ end }} 14 + 15 + {{ define "content" }} 16 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 18 + <div class="text-lg flex items-center justify-between"> 19 + <div> 20 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 + <span class="select-none">/</span> 22 + <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + </div> 24 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 + <div class="flex gap-2 text-base"> 26 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 27 + hx-boost="true" 28 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 29 + {{ i "pencil" "size-4" }} 30 + <span class="hidden md:inline">edit</span> 31 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 + </a> 33 + <button 34 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 35 + title="Delete string" 36 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 37 + hx-swap="none" 38 + hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?" 39 + > 40 + {{ i "trash-2" "size-4" }} 41 + <span class="hidden md:inline">delete</span> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </button> 44 + </div> 45 + {{ end }} 46 + </div> 47 + <span class="flex items-center"> 48 + {{ with .String.Description }} 49 + {{ . }} 50 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 + {{ end }} 52 + 53 + {{ with .String.Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 + {{ end }} 58 + </span> 59 + </section> 60 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 62 + <span>{{ .String.Filename }}</span> 63 + <div> 64 + <span>{{ .Stats.LineCount }} lines</span> 65 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 66 + <span>{{ byteFmt .Stats.ByteCount }}</span> 67 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 68 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 69 + {{ if .RenderToggle }} 70 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 71 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 72 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 73 + </a> 74 + {{ end }} 75 + </div> 76 + </div> 77 + <div class="overflow-auto relative"> 78 + {{ if .ShowRendered }} 79 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 80 + {{ else }} 81 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 82 + {{ end }} 83 + </div> 84 + </section> 85 + {{ end }}
+2 -2
appview/pages/templates/timeline.html
··· 34 34 </p> 35 35 36 36 <div class="flex gap-6 items-center"> 37 - <a href="/login" class="no-underline hover:no-underline "> 38 - <button class="btn flex gap-2 px-4 items-center"> 37 + <a href="/signup" class="no-underline hover:no-underline "> 38 + <button class="btn-create flex gap-2 px-4 items-center"> 39 39 join now {{ i "arrow-right" "size-4" }} 40 40 </button> 41 41 </a>
+5 -5
appview/pages/templates/user/completeSignup.html
··· 38 38 tightly-knit social coding. 39 39 </h2> 40 40 <form 41 - class="mt-4 max-w-sm mx-auto" 41 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 42 42 hx-post="/signup/complete" 43 43 hx-swap="none" 44 44 hx-disabled-elt="#complete-signup-button" ··· 58 58 </span> 59 59 </div> 60 60 61 - <div class="flex flex-col mt-4"> 62 - <label for="username">desired username</label> 61 + <div class="flex flex-col"> 62 + <label for="username">username</label> 63 63 <input 64 64 type="text" 65 65 id="username" ··· 73 73 </span> 74 74 </div> 75 75 76 - <div class="flex flex-col mt-4"> 76 + <div class="flex flex-col"> 77 77 <label for="password">password</label> 78 78 <input 79 79 type="password" ··· 88 88 </div> 89 89 90 90 <button 91 - class="btn-create w-full my-2 mt-6" 91 + class="btn-create w-full my-2 mt-6 text-base" 92 92 type="submit" 93 93 id="complete-signup-button" 94 94 tabindex="4"
+1 -3
appview/pages/templates/user/fragments/profileCard.html
··· 2 2 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 10 <p title="{{ didOrHandle .UserDid .UserHandle }}"
+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 +
+41 -5
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 ··· 751 752 fail("You seem to be adding yourself as a collaborator.", nil) 752 753 return 753 754 } 754 - 755 755 l = l.With("collaborator", collaboratorIdent.Handle) 756 756 l = l.With("knot", f.Knot) 757 - l.Info("adding to knot") 757 + 758 + // announce this relation into the firehose, store into owners' pds 759 + client, err := rp.oauth.AuthorizedClient(r) 760 + if err != nil { 761 + fail("Failed to write to PDS.", err) 762 + return 763 + } 764 + 765 + // emit a record 766 + currentUser := rp.oauth.GetUser(r) 767 + rkey := tid.TID() 768 + createdAt := time.Now() 769 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 770 + Collection: tangled.RepoCollaboratorNSID, 771 + Repo: currentUser.Did, 772 + Rkey: rkey, 773 + Record: &lexutil.LexiconTypeDecoder{ 774 + Val: &tangled.RepoCollaborator{ 775 + Subject: collaboratorIdent.DID.String(), 776 + Repo: string(f.RepoAt), 777 + CreatedAt: createdAt.Format(time.RFC3339), 778 + }}, 779 + }) 780 + // invalid record 781 + if err != nil { 782 + fail("Failed to write record to PDS.", err) 783 + return 784 + } 785 + l = l.With("at-uri", resp.Uri) 786 + l.Info("wrote record to PDS") 758 787 788 + l.Info("adding to knot") 759 789 secret, err := db.GetRegistrationKey(rp.db, f.Knot) 760 790 if err != nil { 761 791 fail("Failed to add to knot.", err) ··· 798 828 return 799 829 } 800 830 801 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 831 + err = db.AddCollaborator(rp.db, db.Collaborator{ 832 + Did: syntax.DID(currentUser.Did), 833 + Rkey: rkey, 834 + SubjectDid: collaboratorIdent.DID, 835 + RepoAt: f.RepoAt, 836 + Created: createdAt, 837 + }) 802 838 if err != nil { 803 839 fail("Failed to add collaborator.", err) 804 840 return ··· 1189 1225 f, err := rp.repoResolver.Resolve(r) 1190 1226 user := rp.oauth.GetUser(r) 1191 1227 1192 - // all spindles that this user is a member of 1193 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1228 + // all spindles that the repo owner is a member of 1229 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1194 1230 if err != nil { 1195 1231 log.Println("failed to fetch spindles", err) 1196 1232 return
+56 -49
appview/signup/signup.go
··· 104 104 105 105 func (s *Signup) Router() http.Handler { 106 106 r := chi.NewRouter() 107 + r.Get("/", s.signup) 107 108 r.Post("/", s.signup) 108 109 r.Get("/complete", s.complete) 109 110 r.Post("/complete", s.complete) ··· 112 113 } 113 114 114 115 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 115 - if s.cf == nil { 116 - http.Error(w, "signup is disabled", http.StatusFailedDependency) 117 - } 118 - emailId := r.FormValue("email") 116 + switch r.Method { 117 + case http.MethodGet: 118 + s.pages.Signup(w) 119 + case http.MethodPost: 120 + if s.cf == nil { 121 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 + } 123 + emailId := r.FormValue("email") 119 124 120 - if !email.IsValidEmail(emailId) { 121 - s.pages.Notice(w, "login-msg", "Invalid email address.") 122 - return 123 - } 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 124 130 125 - exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 126 - if err != nil { 127 - s.l.Error("failed to check email existence", "error", err) 128 - s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 129 - return 130 - } 131 - if exists { 132 - s.pages.Notice(w, "login-msg", "Email already exists.") 133 - return 134 - } 131 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 + if err != nil { 133 + s.l.Error("failed to check email existence", "error", err) 134 + s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 + return 136 + } 137 + if exists { 138 + s.pages.Notice(w, noticeId, "Email already exists.") 139 + return 140 + } 135 141 136 - code, err := s.inviteCodeRequest() 137 - if err != nil { 138 - s.l.Error("failed to create invite code", "error", err) 139 - s.pages.Notice(w, "login-msg", "Failed to create invite code.") 140 - return 141 - } 142 + code, err := s.inviteCodeRequest() 143 + if err != nil { 144 + s.l.Error("failed to create invite code", "error", err) 145 + s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 + return 147 + } 142 148 143 - em := email.Email{ 144 - APIKey: s.config.Resend.ApiKey, 145 - From: s.config.Resend.SentFrom, 146 - To: emailId, 147 - Subject: "Verify your Tangled account", 148 - Text: `Copy and paste this code below to verify your account on Tangled. 149 + em := email.Email{ 150 + APIKey: s.config.Resend.ApiKey, 151 + From: s.config.Resend.SentFrom, 152 + To: emailId, 153 + Subject: "Verify your Tangled account", 154 + Text: `Copy and paste this code below to verify your account on Tangled. 149 155 ` + code, 150 - Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 151 157 <p><code>` + code + `</code></p>`, 152 - } 158 + } 153 159 154 - err = email.SendEmail(em) 155 - if err != nil { 156 - s.l.Error("failed to send email", "error", err) 157 - s.pages.Notice(w, "login-msg", "Failed to send email.") 158 - return 159 - } 160 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 161 - Email: emailId, 162 - InviteCode: code, 163 - }) 164 - if err != nil { 165 - s.l.Error("failed to add inflight signup", "error", err) 166 - s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 167 - return 168 - } 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 + } 169 175 170 - s.pages.HxRedirect(w, "/signup/complete") 176 + s.pages.HxRedirect(w, "/signup/complete") 177 + } 171 178 } 172 179 173 180 func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 174 181 switch r.Method { 175 182 case http.MethodGet: 176 - s.pages.CompleteSignup(w, pages.SignupParams{}) 183 + s.pages.CompleteSignup(w) 177 184 case http.MethodPost: 178 185 username := r.FormValue("username") 179 186 password := r.FormValue("password")
-16
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 142 139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 143 140 } 144 141 145 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 146 142 s.pages.ProfilePage(w, pages.ProfilePageParams{ 147 143 LoggedInUser: loggedInUser, 148 144 Repos: pinnedRepos, ··· 151 147 Card: pages.ProfileCard{ 152 148 UserDid: ident.DID.String(), 153 149 UserHandle: ident.Handle.String(), 154 - AvatarUri: profileAvatarUri, 155 150 Profile: profile, 156 151 FollowStatus: followStatus, 157 152 Followers: followers, ··· 194 189 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 195 190 } 196 191 197 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 198 - 199 192 s.pages.ReposPage(w, pages.ReposPageParams{ 200 193 LoggedInUser: loggedInUser, 201 194 Repos: repos, ··· 203 196 Card: pages.ProfileCard{ 204 197 UserDid: ident.DID.String(), 205 198 UserHandle: ident.Handle.String(), 206 - AvatarUri: profileAvatarUri, 207 199 Profile: profile, 208 200 FollowStatus: followStatus, 209 201 Followers: followers, 210 202 Following: following, 211 203 }, 212 204 }) 213 - } 214 - 215 - func (s *State) GetAvatarUri(handle string) string { 216 - secret := s.config.Avatar.SharedSecret 217 - h := hmac.New(sha256.New, []byte(secret)) 218 - h.Write([]byte(handle)) 219 - signature := hex.EncodeToString(h.Sum(nil)) 220 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 221 205 } 222 206 223 207 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+19 -3
appview/state/router.go
··· 17 17 "tangled.sh/tangled.sh/core/appview/signup" 18 18 "tangled.sh/tangled.sh/core/appview/spindles" 19 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 20 21 "tangled.sh/tangled.sh/core/log" 21 22 ) 22 23 ··· 67 68 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 68 69 r := chi.NewRouter() 69 70 70 - // strip @ from user 71 - r.Use(middleware.StripLeadingAt) 72 - 73 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 74 72 r.Get("/", s.Profile) 75 73 ··· 136 134 }) 137 135 138 136 r.Mount("/settings", s.SettingsRouter()) 137 + r.Mount("/strings", s.StringsRouter(mw)) 139 138 r.Mount("/knots", s.KnotsRouter(mw)) 140 139 r.Mount("/spindles", s.SpindlesRouter()) 141 140 r.Mount("/signup", s.SignupRouter()) ··· 199 198 } 200 199 201 200 return knots.Router(mw) 201 + } 202 + 203 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 204 + logger := log.New("strings") 205 + 206 + strs := &avstrings.Strings{ 207 + Db: s.db, 208 + OAuth: s.oauth, 209 + Pages: s.pages, 210 + Config: s.config, 211 + Enforcer: s.enforcer, 212 + IdResolver: s.idResolver, 213 + Knotstream: s.knotstream, 214 + Logger: logger, 215 + } 216 + 217 + return strs.Router(mw) 202 218 } 203 219 204 220 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+1
appview/state/state.go
··· 93 93 tangled.ActorProfileNSID, 94 94 tangled.SpindleMemberNSID, 95 95 tangled.SpindleNSID, 96 + tangled.StringNSID, 96 97 }, 97 98 nil, 98 99 slog.Default(),
+449
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("incorrect number of records returned", "len(strings)", len(strings)) 104 + w.WriteHeader(http.StatusInternalServerError) 105 + return 106 + } 107 + string := strings[0] 108 + 109 + if path.Base(r.URL.Path) == "raw" { 110 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 111 + if string.Filename != "" { 112 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 113 + } 114 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 115 + 116 + _, err = w.Write([]byte(string.Contents)) 117 + if err != nil { 118 + l.Error("failed to write raw response", "err", err) 119 + } 120 + return 121 + } 122 + 123 + var showRendered, renderToggle bool 124 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 125 + renderToggle = true 126 + showRendered = r.URL.Query().Get("code") != "true" 127 + } 128 + 129 + s.Pages.SingleString(w, pages.SingleStringParams{ 130 + LoggedInUser: s.OAuth.GetUser(r), 131 + RenderToggle: renderToggle, 132 + ShowRendered: showRendered, 133 + String: string, 134 + Stats: string.Stats(), 135 + Owner: id, 136 + }) 137 + } 138 + 139 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 140 + l := s.Logger.With("handler", "dashboard") 141 + 142 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 143 + if !ok { 144 + l.Error("malformed middleware") 145 + w.WriteHeader(http.StatusInternalServerError) 146 + return 147 + } 148 + l = l.With("did", id.DID, "handle", id.Handle) 149 + 150 + all, err := db.GetStrings( 151 + s.Db, 152 + db.FilterEq("did", id.DID), 153 + ) 154 + if err != nil { 155 + l.Error("failed to fetch strings", "err", err) 156 + w.WriteHeader(http.StatusInternalServerError) 157 + return 158 + } 159 + 160 + slices.SortFunc(all, func(a, b db.String) int { 161 + if a.Created.After(b.Created) { 162 + return -1 163 + } else { 164 + return 1 165 + } 166 + }) 167 + 168 + profile, err := db.GetProfile(s.Db, id.DID.String()) 169 + if err != nil { 170 + l.Error("failed to fetch user profile", "err", err) 171 + w.WriteHeader(http.StatusInternalServerError) 172 + return 173 + } 174 + loggedInUser := s.OAuth.GetUser(r) 175 + followStatus := db.IsNotFollowing 176 + if loggedInUser != nil { 177 + followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 178 + } 179 + 180 + followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 181 + if err != nil { 182 + l.Error("failed to get follow stats", "err", err) 183 + } 184 + 185 + s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 186 + LoggedInUser: s.OAuth.GetUser(r), 187 + Card: pages.ProfileCard{ 188 + UserDid: id.DID.String(), 189 + UserHandle: id.Handle.String(), 190 + Profile: profile, 191 + FollowStatus: followStatus, 192 + Followers: followers, 193 + Following: following, 194 + }, 195 + Strings: all, 196 + }) 197 + } 198 + 199 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 200 + l := s.Logger.With("handler", "edit") 201 + 202 + user := s.OAuth.GetUser(r) 203 + 204 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 205 + if !ok { 206 + l.Error("malformed middleware") 207 + w.WriteHeader(http.StatusInternalServerError) 208 + return 209 + } 210 + l = l.With("did", id.DID, "handle", id.Handle) 211 + 212 + rkey := chi.URLParam(r, "rkey") 213 + if rkey == "" { 214 + l.Error("malformed url, empty rkey") 215 + w.WriteHeader(http.StatusBadRequest) 216 + return 217 + } 218 + l = l.With("rkey", rkey) 219 + 220 + // get the string currently being edited 221 + all, err := db.GetStrings( 222 + s.Db, 223 + db.FilterEq("did", id.DID), 224 + db.FilterEq("rkey", rkey), 225 + ) 226 + if err != nil { 227 + l.Error("failed to fetch string", "err", err) 228 + w.WriteHeader(http.StatusInternalServerError) 229 + return 230 + } 231 + if len(all) != 1 { 232 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 233 + w.WriteHeader(http.StatusInternalServerError) 234 + return 235 + } 236 + first := all[0] 237 + 238 + // verify that the logged in user owns this string 239 + if user.Did != id.DID.String() { 240 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 241 + w.WriteHeader(http.StatusUnauthorized) 242 + return 243 + } 244 + 245 + switch r.Method { 246 + case http.MethodGet: 247 + // return the form with prefilled fields 248 + s.Pages.PutString(w, pages.PutStringParams{ 249 + LoggedInUser: s.OAuth.GetUser(r), 250 + Action: "edit", 251 + String: first, 252 + }) 253 + case http.MethodPost: 254 + fail := func(msg string, err error) { 255 + l.Error(msg, "err", err) 256 + s.Pages.Notice(w, "error", msg) 257 + } 258 + 259 + filename := r.FormValue("filename") 260 + if filename == "" { 261 + fail("Empty filename.", nil) 262 + return 263 + } 264 + if !strings.Contains(filename, ".") { 265 + // TODO: make this a htmx form validation 266 + fail("No extension provided for filename.", nil) 267 + return 268 + } 269 + 270 + content := r.FormValue("content") 271 + if content == "" { 272 + fail("Empty contents.", nil) 273 + return 274 + } 275 + 276 + description := r.FormValue("description") 277 + 278 + // construct new string from form values 279 + entry := db.String{ 280 + Did: first.Did, 281 + Rkey: first.Rkey, 282 + Filename: filename, 283 + Description: description, 284 + Contents: content, 285 + Created: first.Created, 286 + } 287 + 288 + record := entry.AsRecord() 289 + 290 + client, err := s.OAuth.AuthorizedClient(r) 291 + if err != nil { 292 + fail("Failed to create record.", err) 293 + return 294 + } 295 + 296 + // first replace the existing record in the PDS 297 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 298 + if err != nil { 299 + fail("Failed to updated existing record.", err) 300 + return 301 + } 302 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 303 + Collection: tangled.StringNSID, 304 + Repo: entry.Did.String(), 305 + Rkey: entry.Rkey, 306 + SwapRecord: ex.Cid, 307 + Record: &lexutil.LexiconTypeDecoder{ 308 + Val: &record, 309 + }, 310 + }) 311 + if err != nil { 312 + fail("Failed to updated existing record.", err) 313 + return 314 + } 315 + l := l.With("aturi", resp.Uri) 316 + l.Info("edited string") 317 + 318 + // if that went okay, updated the db 319 + if err = db.AddString(s.Db, entry); err != nil { 320 + fail("Failed to update string.", err) 321 + return 322 + } 323 + 324 + // if that went okay, redir to the string 325 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 326 + } 327 + 328 + } 329 + 330 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 331 + l := s.Logger.With("handler", "create") 332 + user := s.OAuth.GetUser(r) 333 + 334 + switch r.Method { 335 + case http.MethodGet: 336 + s.Pages.PutString(w, pages.PutStringParams{ 337 + LoggedInUser: s.OAuth.GetUser(r), 338 + Action: "new", 339 + }) 340 + case http.MethodPost: 341 + fail := func(msg string, err error) { 342 + l.Error(msg, "err", err) 343 + s.Pages.Notice(w, "error", msg) 344 + } 345 + 346 + filename := r.FormValue("filename") 347 + if filename == "" { 348 + fail("Empty filename.", nil) 349 + return 350 + } 351 + if !strings.Contains(filename, ".") { 352 + // TODO: make this a htmx form validation 353 + fail("No extension provided for filename.", nil) 354 + return 355 + } 356 + 357 + content := r.FormValue("content") 358 + if content == "" { 359 + fail("Empty contents.", nil) 360 + return 361 + } 362 + 363 + description := r.FormValue("description") 364 + 365 + string := db.String{ 366 + Did: syntax.DID(user.Did), 367 + Rkey: tid.TID(), 368 + Filename: filename, 369 + Description: description, 370 + Contents: content, 371 + Created: time.Now(), 372 + } 373 + 374 + record := string.AsRecord() 375 + 376 + client, err := s.OAuth.AuthorizedClient(r) 377 + if err != nil { 378 + fail("Failed to create record.", err) 379 + return 380 + } 381 + 382 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 383 + Collection: tangled.StringNSID, 384 + Repo: user.Did, 385 + Rkey: string.Rkey, 386 + Record: &lexutil.LexiconTypeDecoder{ 387 + Val: &record, 388 + }, 389 + }) 390 + if err != nil { 391 + fail("Failed to create record.", err) 392 + return 393 + } 394 + l := l.With("aturi", resp.Uri) 395 + l.Info("created record") 396 + 397 + // insert into DB 398 + if err = db.AddString(s.Db, string); err != nil { 399 + fail("Failed to create string.", err) 400 + return 401 + } 402 + 403 + // successful 404 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 405 + } 406 + } 407 + 408 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 409 + l := s.Logger.With("handler", "create") 410 + user := s.OAuth.GetUser(r) 411 + fail := func(msg string, err error) { 412 + l.Error(msg, "err", err) 413 + s.Pages.Notice(w, "error", msg) 414 + } 415 + 416 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 417 + if !ok { 418 + l.Error("malformed middleware") 419 + w.WriteHeader(http.StatusInternalServerError) 420 + return 421 + } 422 + l = l.With("did", id.DID, "handle", id.Handle) 423 + 424 + rkey := chi.URLParam(r, "rkey") 425 + if rkey == "" { 426 + l.Error("malformed url, empty rkey") 427 + w.WriteHeader(http.StatusBadRequest) 428 + return 429 + } 430 + 431 + if user.Did != id.DID.String() { 432 + fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 433 + return 434 + } 435 + 436 + if err := db.DeleteString( 437 + s.Db, 438 + db.FilterEq("did", user.Did), 439 + db.FilterEq("rkey", rkey), 440 + ); err != nil { 441 + fail("Failed to delete string.", err) 442 + return 443 + } 444 + 445 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 446 + } 447 + 448 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 449 + }
+2
cmd/gen.go
··· 40 40 tangled.PublicKey{}, 41 41 tangled.Repo{}, 42 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 43 44 tangled.RepoIssue{}, 44 45 tangled.RepoIssueComment{}, 45 46 tangled.RepoIssueState{}, ··· 49 50 tangled.RepoPullStatus{}, 50 51 tangled.Spindle{}, 51 52 tangled.SpindleMember{}, 53 + tangled.String{}, 52 54 ); err != nil { 53 55 panic(err) 54 56 }
+22 -22
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1694529238, 9 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 3 21 "gitignore": { 4 22 "inputs": { 5 23 "nixpkgs": [ ··· 17 35 "original": { 18 36 "owner": "hercules-ci", 19 37 "repo": "gitignore.nix", 20 - "type": "github" 21 - } 22 - }, 23 - "flake-utils": { 24 - "inputs": { 25 - "systems": "systems" 26 - }, 27 - "locked": { 28 - "lastModified": 1694529238, 29 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 30 - "owner": "numtide", 31 - "repo": "flake-utils", 32 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 33 - "type": "github" 34 - }, 35 - "original": { 36 - "owner": "numtide", 37 - "repo": "flake-utils", 38 38 "type": "github" 39 39 } 40 40 }, ··· 128 128 "lucide-src": { 129 129 "flake": false, 130 130 "locked": { 131 - "lastModified": 1742302029, 132 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 131 + "lastModified": 1754044466, 132 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 133 133 "type": "tarball", 134 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 134 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 135 135 }, 136 136 "original": { 137 137 "type": "tarball", 138 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 138 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 139 139 } 140 140 }, 141 141 "nixpkgs": {
+1 -1
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 = {
+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 }
+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
+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)
+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 +
+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 + }
+122 -3
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 - "path/filepath" 8 8 9 9 "tangled.sh/tangled.sh/core/api/tangled" 10 10 "tangled.sh/tangled.sh/core/eventconsumer" 11 + "tangled.sh/tangled.sh/core/idresolver" 11 12 "tangled.sh/tangled.sh/core/rbac" 12 13 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/bluesky-social/indigo/xrpc" 13 18 "github.com/bluesky-social/jetstream/pkg/models" 19 + securejoin "github.com/cyphar/filepath-securejoin" 14 20 ) 15 21 16 22 type Ingester func(ctx context.Context, e *models.Event) error ··· 35 41 s.ingestMember(ctx, e) 36 42 case tangled.RepoNSID: 37 43 s.ingestRepo(ctx, e) 44 + case tangled.RepoCollaboratorNSID: 45 + s.ingestCollaborator(ctx, e) 38 46 } 39 47 40 48 return err ··· 92 100 return nil 93 101 } 94 102 95 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 103 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 96 104 var err error 105 + did := e.Did 106 + resolver := idresolver.DefaultResolver() 97 107 98 108 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 99 109 ··· 129 139 return fmt.Errorf("failed to add repo: %w", err) 130 140 } 131 141 142 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 143 + if err != nil { 144 + return err 145 + } 146 + 132 147 // add repo to rbac 133 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil { 148 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 134 149 l.Error("failed to add repo to enforcer", "error", err) 135 150 return fmt.Errorf("failed to add repo: %w", err) 136 151 } 137 152 153 + // add collaborators to rbac 154 + owner, err := resolver.ResolveIdent(ctx, did) 155 + if err != nil || owner.Handle.IsInvalidHandle() { 156 + return err 157 + } 158 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 159 + return err 160 + } 161 + 138 162 // add this knot to the event consumer 139 163 src := eventconsumer.NewKnotSource(record.Knot) 140 164 s.ks.AddSource(context.Background(), src) ··· 144 168 } 145 169 return nil 146 170 } 171 + 172 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 173 + var err error 174 + 175 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 176 + 177 + l.Info("ingesting collaborator record") 178 + 179 + switch e.Commit.Operation { 180 + case models.CommitOperationCreate, models.CommitOperationUpdate: 181 + raw := e.Commit.Record 182 + record := tangled.RepoCollaborator{} 183 + err = json.Unmarshal(raw, &record) 184 + if err != nil { 185 + l.Error("invalid record", "error", err) 186 + return err 187 + } 188 + 189 + resolver := idresolver.DefaultResolver() 190 + 191 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 192 + if err != nil || subjectId.Handle.IsInvalidHandle() { 193 + return err 194 + } 195 + 196 + repoAt, err := syntax.ParseATURI(record.Repo) 197 + if err != nil { 198 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 199 + return nil 200 + } 201 + 202 + // TODO: get rid of this entirely 203 + // resolve this aturi to extract the repo record 204 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 205 + if err != nil || owner.Handle.IsInvalidHandle() { 206 + return fmt.Errorf("failed to resolve handle: %w", err) 207 + } 208 + 209 + xrpcc := xrpc.Client{ 210 + Host: owner.PDSEndpoint(), 211 + } 212 + 213 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 214 + if err != nil { 215 + return err 216 + } 217 + 218 + repo := resp.Value.Val.(*tangled.Repo) 219 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 220 + 221 + // check perms for this user 222 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 223 + return fmt.Errorf("insufficient permissions: %w", err) 224 + } 225 + 226 + // add collaborator to rbac 227 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 228 + l.Error("failed to add repo to enforcer", "error", err) 229 + return fmt.Errorf("failed to add repo: %w", err) 230 + } 231 + 232 + return nil 233 + } 234 + return nil 235 + } 236 + 237 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 238 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 239 + 240 + l.Info("fetching and adding existing collaborators") 241 + 242 + xrpcc := xrpc.Client{ 243 + Host: owner.PDSEndpoint(), 244 + } 245 + 246 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 247 + if err != nil { 248 + return err 249 + } 250 + 251 + var errs error 252 + for _, r := range resp.Records { 253 + if r == nil { 254 + continue 255 + } 256 + record := r.Value.Val.(*tangled.RepoCollaborator) 257 + 258 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 259 + l.Error("failed to add repo to enforcer", "error", err) 260 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 261 + } 262 + } 263 + 264 + return errs 265 + }
+1
spindle/server.go
··· 103 103 collections := []string{ 104 104 tangled.SpindleMemberNSID, 105 105 tangled.RepoNSID, 106 + tangled.RepoCollaboratorNSID, 106 107 } 107 108 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 108 109 if err != nil {