Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview: refactor string page to accept multiple files

db schema is still using single file for non-blob file contents.

Signed-off-by: Seongmin Lee <git@boltless.me>

Seongmin Lee (May 10, 2026, 12:52 AM +0900) 6f621fac 99271cf5

+979 -494
+59
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "fmt" 6 7 "log/slog" 7 8 "strings" 8 9 ··· 1634 1635 alter table strings add column cid text; 1635 1636 `) 1636 1637 return err 1638 + }) 1639 + 1640 + orm.RunMigration(conn, logger, "multiple-files-for-a-string", func(tx *sql.Tx) error { 1641 + _, err := tx.Exec(` 1642 + create table strings_new ( 1643 + did text not null, 1644 + rkey text not null, 1645 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.string' || '/' || rkey) stored unique, 1646 + cid text, 1647 + 1648 + title text, 1649 + description text, 1650 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1651 + file_name text not null default '', 1652 + file_content text not null default '', 1653 + 1654 + -- appview-local information 1655 + edited text, 1656 + 1657 + primary key (did, rkey) 1658 + ); 1659 + `) 1660 + if err != nil { 1661 + return fmt.Errorf("failed to create tables: %w", err) 1662 + } 1663 + 1664 + _, err = tx.Exec(` 1665 + insert into strings_new ( 1666 + did, rkey, cid, 1667 + title, 1668 + description, 1669 + file_name, 1670 + file_content, 1671 + created, 1672 + edited 1673 + ) 1674 + select 1675 + did, rkey, cid, 1676 + filename, 1677 + description, 1678 + filename, 1679 + content, 1680 + created, 1681 + edited 1682 + from strings; 1683 + `) 1684 + if err != nil { 1685 + return fmt.Errorf("failed to insert strings: %w", err) 1686 + } 1687 + 1688 + _, err = tx.Exec(` 1689 + drop table strings; 1690 + alter table strings_new rename to strings; 1691 + `) 1692 + if err != nil { 1693 + return fmt.Errorf("failed to drop legacy table: %w", err) 1694 + } 1695 + return nil 1637 1696 }) 1638 1697 1639 1698 return &DB{
+170 -21
appview/db/strings.go
··· 4 4 "database/sql" 5 5 "errors" 6 6 "fmt" 7 + "log" 8 + "slices" 7 9 "strings" 8 10 "time" 9 11 ··· 12 14 "tangled.org/core/orm" 13 15 ) 14 16 15 - func AddString(e Execer, s models.String) error { 16 - _, err := e.Exec( 17 + func AddString(d *DB, s models.String) error { 18 + tx, err := d.Begin() 19 + if err != nil { 20 + return fmt.Errorf("starting transaction: %w", err) 21 + } 22 + defer tx.Rollback() 23 + res, err := tx.Exec( 17 24 `insert into strings ( 18 25 did, 19 26 rkey, 20 27 cid, 21 - filename, 28 + title, 22 29 description, 23 - content, 24 - created, 25 - edited 30 + file_name, 31 + file_content, 32 + created 26 33 ) 27 - values (?, ?, ?, ?, ?, ?, ?, null) 34 + values (?, ?, ?, ?, ?, ?, ?, ?) 28 35 on conflict(did, rkey) do update set 29 36 cid = excluded.cid, 30 - filename = excluded.filename, 37 + title = excluded.title, 31 38 description = excluded.description, 32 - content = excluded.content, 39 + file_name = excluded.file_name, 40 + file_content= excluded.file_content, 41 + created = excluded.created, 33 42 edited = case when strings.cid is not null then ? else strings.edited end 34 43 where strings.cid is not excluded.cid`, 35 44 s.Did, 36 45 s.Rkey, 37 46 s.Cid, 38 - s.Filename, 47 + s.Title, 39 48 s.Description, 40 - s.Contents, 49 + s.FileName, 50 + s.FileContent, 41 51 s.Created.Format(time.RFC3339), 42 52 time.Now().Format(time.RFC3339), 43 53 ) 44 - return err 54 + if err != nil { 55 + return fmt.Errorf("inserting string: %w", err) 56 + } 57 + 58 + num, err := res.RowsAffected() 59 + if err != nil { 60 + return fmt.Errorf("calculating affected rows: %w", err) 61 + } 62 + if num == 0 { 63 + return nil 64 + } 65 + 66 + if err := tx.Commit(); err != nil { 67 + return fmt.Errorf("commiting transaction: %w", err) 68 + } 69 + return nil 70 + } 71 + 72 + func GetString(e Execer, filters ...orm.Filter) (models.String, error) { 73 + strings, err := GetStrings(e, 0, filters...) 74 + if err != nil { 75 + return models.String{}, err 76 + } 77 + if len(strings) != 1 { 78 + return models.String{}, sql.ErrNoRows 79 + } 80 + return strings[0], nil 45 81 } 46 82 47 83 func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) { 48 - var all []models.String 84 + stringMap := make(map[syntax.ATURI]*models.String) 49 85 50 86 var conditions []string 51 87 var args []any ··· 64 100 limitClause = fmt.Sprintf(" limit %d ", limit) 65 101 } 66 102 67 - query := fmt.Sprintf(`select 103 + query := fmt.Sprintf( 104 + `select 68 105 did, 69 106 rkey, 70 107 cid, 71 - filename, 108 + title, 72 109 description, 73 - content, 110 + file_name, 111 + file_content, 74 112 created, 75 113 edited 76 114 from strings ··· 91 129 for rows.Next() { 92 130 var s models.String 93 131 var createdAt string 94 - var cid, editedAt sql.Null[string] 132 + var cid, title, description, editedAt sql.Null[string] 95 133 96 134 if err := rows.Scan( 97 135 &s.Did, 98 136 &s.Rkey, 99 137 &cid, 100 - &s.Filename, 101 - &s.Description, 102 - &s.Contents, 138 + &title, 139 + &description, 140 + &s.FileName, 141 + &s.FileContent, 103 142 &createdAt, 104 143 &editedAt, 105 144 ); err != nil { ··· 111 150 *s.Cid = syntax.CID(cid.V) 112 151 } 113 152 153 + if title.Valid { 154 + s.Title = new(string) 155 + *s.Title = title.V 156 + } 157 + 158 + if description.Valid { 159 + s.Description = new(string) 160 + *s.Description = description.V 161 + } 162 + 114 163 s.Created, err = time.Parse(time.RFC3339, createdAt) 115 164 if err != nil { 116 165 s.Created = time.Now() ··· 124 173 s.Edited = &e 125 174 } 126 175 127 - all = append(all, s) 176 + s.Stats = &models.StringStats{} 177 + stringMap[s.AtUri()] = &s 128 178 } 129 179 130 180 if err := rows.Err(); err != nil { 131 181 return nil, err 132 182 } 183 + 184 + // if no strings, return early 185 + if len(stringMap) == 0 { 186 + return nil, nil 187 + } 188 + 189 + // build IN clause for related queries 190 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(stringMap)), ", ") 191 + args = make([]any, len(stringMap)) 192 + i := 0 193 + for _, s := range stringMap { 194 + args[i] = s.AtUri() 195 + i++ 196 + } 197 + 198 + // // get files 199 + // { 200 + // rows, err := e.Query( 201 + // fmt.Sprintf( 202 + // `select at_uri, name, blob from string_files where at_uri in (%s) order by at_uri, id`, 203 + // inClause, 204 + // ), 205 + // args..., 206 + // ) 207 + // if err != nil { 208 + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) 209 + // } 210 + // defer rows.Close() 211 + // 212 + // for rows.Next() { 213 + // var stringAt syntax.ATURI 214 + // var file models.String_File 215 + // file.Blob = &util.LexBlob{} 216 + // var gzipMimeType sql.Null[string] 217 + // var gzipSize sql.Null[int64] 218 + // if err := rows.Scan( 219 + // &stringAt, 220 + // &file.Name, 221 + // &blob, 222 + // ); err != nil { 223 + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) 224 + // } 225 + // if gzipMimeType.Valid && gzipSize.Valid { 226 + // file.Gzip = &models.GzipInfo{ 227 + // MimeType: gzipMimeType.V, 228 + // Size: gzipSize.V, 229 + // } 230 + // } 231 + // if s, ok := stringMap[stringAt]; ok { 232 + // s.Files = append(s.Files, file) 233 + // } 234 + // } 235 + // if err = rows.Err(); err != nil { 236 + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) 237 + // } 238 + // } 239 + 240 + // get star counts 241 + { 242 + rows, err := e.Query( 243 + fmt.Sprintf( 244 + `select subject_at, count(1) from stars where subject_at in (%s) group by subject_at`, 245 + inClause, 246 + ), 247 + args..., 248 + ) 249 + if err != nil { 250 + return nil, fmt.Errorf("failed to execute star-count query: %w", err) 251 + } 252 + defer rows.Close() 253 + 254 + for rows.Next() { 255 + var stringAt syntax.ATURI 256 + var count int 257 + if err := rows.Scan(&stringAt, &count); err != nil { 258 + log.Println("error scanning star counts", err) 259 + continue 260 + } 261 + if s, ok := stringMap[stringAt]; ok { 262 + s.Stats.StarCount = count 263 + } 264 + } 265 + if err = rows.Err(); err != nil { 266 + return nil, fmt.Errorf("failed to execute star-count query: %w", err) 267 + } 268 + } 269 + 270 + var all []models.String 271 + for _, s := range stringMap { 272 + all = append(all, *s) 273 + } 274 + 275 + // sort by created timestamp (desc) 276 + slices.SortFunc(all, func(a, b models.String) int { 277 + if a.Created.After(b.Created) { 278 + return -1 279 + } 280 + return 1 281 + }) 133 282 134 283 return all, nil 135 284 }
+9 -4
appview/ingester.go
··· 106 106 case tangled.LabelOpNSID: 107 107 err = i.ingestLabelOp(e) 108 108 } 109 - l = i.Logger.With("nsid", e.Commit.Collection) 109 + l = l.With( 110 + "uri", fmt.Sprintf("at://%s/%s/%s", e.Did, e.Commit.Collection, e.Commit.RKey), 111 + "cid", e.Commit.CID, 112 + ) 110 113 } 111 114 112 115 if err != nil { ··· 818 821 return err 819 822 } 820 823 821 - string := models.StringFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(e.Commit.CID), record) 822 - 823 - if err = i.Validator.ValidateString(&string); err != nil { 824 + string, err := models.StringFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(e.Commit.CID), record) 825 + if err != nil { 826 + return fmt.Errorf("failed to parse string record: %w", err) 827 + } 828 + if err = string.Validate(); err != nil { 824 829 l.Error("invalid record", "err", err) 825 830 return err 826 831 }
+91 -55
appview/models/string.go
··· 1 1 package models 2 2 3 3 import ( 4 - "bytes" 4 + "errors" 5 5 "fmt" 6 - "io" 7 - "strings" 8 6 "time" 7 + "unicode/utf8" 9 8 10 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 11 "tangled.org/core/api/tangled" 12 12 ) 13 13 ··· 16 16 Rkey syntax.RecordKey 17 17 Cid *syntax.CID 18 18 19 - Filename string 20 - Description string 21 - Contents string 19 + // String_File will remain, and we will still use it. 20 + // We just use `FileContent` when fetching the file. 21 + 22 + // after that, change lexicon, start migrating to blobs 23 + // when string is migrated, clear FileName and FileContent. 24 + // when they are cleared, fetch the blob on page load. 25 + 26 + Title *string 27 + Description *string 28 + Files []String_File 22 29 Created time.Time 23 30 Edited *time.Time 31 + 32 + // legacy string data 33 + FileName string 34 + FileContent string 35 + 36 + // optionally, populate this when querying for reverse mappings 37 + Stats *StringStats 38 + } 39 + 40 + // TODO: replace this with [tangled.String_File] 41 + type String_File struct { 42 + Name string 43 + Blob *lexutil.LexBlob 44 + Gzip *GzipInfo 45 + } 46 + type GzipInfo struct { 47 + MimeType string 48 + Size int64 24 49 } 25 50 26 51 func (s *String) AtUri() syntax.ATURI { ··· 28 53 } 29 54 30 55 func (s *String) AsRecord() *tangled.String { 56 + var description string 57 + if s.Description != nil { 58 + description = *s.Description 59 + } 31 60 return &tangled.String{ 32 - Filename: s.Filename, 33 - Description: s.Description, 34 - Contents: s.Contents, 61 + Filename: s.FileName, 62 + Description: description, 63 + Contents: s.FileContent, 35 64 CreatedAt: s.Created.Format(time.RFC3339), 36 65 } 37 66 } 38 67 39 - func StringFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.String) String { 40 - created, err := time.Parse(record.CreatedAt, time.RFC3339) 41 - if err != nil { 42 - created = time.Now() 68 + func (s *String) Validate() error { 69 + var err error 70 + if s.FileName == "" && len(s.Files) == 0 { 71 + err = errors.Join(err, fmt.Errorf("string should have more than one files")) 43 72 } 44 - return String{ 45 - Did: did, 46 - Rkey: rkey, 47 - Cid: &cid, 48 - Filename: record.Filename, 49 - Description: record.Description, 50 - Contents: record.Contents, 51 - Created: created, 73 + if s.Description != nil { 74 + if utf8.RuneCountInString(*s.Description) > 280 { 75 + err = errors.Join(err, fmt.Errorf("description too long")) 76 + } 52 77 } 78 + return err 53 79 } 54 80 55 - type StringStats struct { 56 - LineCount uint64 57 - ByteCount uint64 81 + func (s String) RenderTitle() string { 82 + if s.Title != nil { 83 + return *s.Title 84 + } 85 + if len(s.Files) > 0 { 86 + return s.Files[0].Name 87 + } 88 + return s.FileName 58 89 } 59 90 60 - func (s String) Stats() StringStats { 61 - lineCount, err := countLines(strings.NewReader(s.Contents)) 91 + // FileByName returns first item in files with given filename 92 + func (s *String) FileByName(name string) (String_File, bool) { 93 + for _, file := range s.Files { 94 + if file.Name == name { 95 + return file, true 96 + } 97 + } 98 + return String_File{}, false 99 + } 100 + 101 + func (s String) IsLegacySingleFile() bool { 102 + return len(s.Files) == 0 103 + } 104 + 105 + func StringFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.String) (String, error) { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 62 107 if err != nil { 63 - // non-fatal 64 - // TODO: log this? 108 + return String{}, fmt.Errorf("invalid createdAt: %w", err) 65 109 } 66 - 67 - return StringStats{ 68 - LineCount: uint64(lineCount), 69 - ByteCount: uint64(len(s.Contents)), 110 + var description *string 111 + if record.Description != "" { 112 + description = &record.Description 70 113 } 114 + return String{ 115 + Did: did, 116 + Rkey: rkey, 117 + Cid: &cid, 118 + Description: description, 119 + Created: created, 120 + FileName: record.Filename, 121 + FileContent: record.Contents, 122 + }, nil 71 123 } 72 124 73 - func countLines(r io.Reader) (int, error) { 74 - buf := make([]byte, 32*1024) 75 - bufLen := 0 76 - count := 0 77 - nl := []byte{'\n'} 78 - 79 - for { 80 - c, err := r.Read(buf) 81 - if c > 0 { 82 - bufLen += c 83 - } 84 - count += bytes.Count(buf[:c], nl) 125 + type StringStats struct { 126 + StarCount int 127 + // CommentCount int 128 + } 85 129 86 - switch { 87 - case err == io.EOF: 88 - /* handle last line not having a newline at the end */ 89 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 90 - count++ 91 - } 92 - return count, nil 93 - case err != nil: 94 - return 0, err 95 - } 96 - } 130 + type StringFileStats struct { 131 + LineCount int 132 + ByteCount int 97 133 }
+45 -23
appview/pages/pages.go
··· 30 30 "tangled.org/core/patchutil" 31 31 "tangled.org/core/types" 32 32 33 - "github.com/bluesky-social/indigo/atproto/identity" 34 33 "github.com/bluesky-social/indigo/atproto/syntax" 35 34 "github.com/go-git/go-git/v5/plumbing" 36 35 ) ··· 1570 1569 return p.executeRepo("repo/pipelines/workflow", w, params) 1571 1570 } 1572 1571 1573 - type PutStringParams struct { 1572 + type NewStringParams struct { 1574 1573 LoggedInUser *oauth.MultiAccountUser 1575 - Action string 1576 - 1577 - // this is supplied in the case of editing an existing string 1578 - String models.String 1574 + String models.String 1575 + FileParams []StringFileEditFragmentParams 1579 1576 } 1580 1577 1581 - func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1582 - return p.execute("strings/put", w, params) 1578 + func (p *Pages) NewString(w io.Writer, params NewStringParams) error { 1579 + // use default string value to render template 1580 + params.String = models.String{Files: make([]models.String_File, 1)} 1581 + params.FileParams = make([]StringFileEditFragmentParams, 1) 1582 + return p.execute("strings/new", w, params) 1583 1583 } 1584 1584 1585 - type StringsDashboardParams struct { 1585 + type EditStringParams struct { 1586 1586 LoggedInUser *oauth.MultiAccountUser 1587 - Card ProfileCard 1588 - Strings []models.String 1587 + String models.String 1588 + FileParams []StringFileEditFragmentParams 1589 1589 } 1590 1590 1591 - func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1592 - return p.execute("strings/dashboard", w, params) 1591 + func (p *Pages) EditString(w io.Writer, params EditStringParams) error { 1592 + return p.execute("strings/edit", w, params) 1593 1593 } 1594 1594 1595 1595 type StringTimelineParams struct { ··· 1602 1602 } 1603 1603 1604 1604 type SingleStringParams struct { 1605 - LoggedInUser *oauth.MultiAccountUser 1606 - ShowRendered bool 1607 - RenderToggle bool 1608 - RenderedContents template.HTML 1609 - String *models.String 1610 - Stats models.StringStats 1611 - IsStarred bool 1612 - StarCount int 1613 - Owner identity.Identity 1614 - CommentList []models.CommentListItem 1605 + LoggedInUser *oauth.MultiAccountUser 1606 + String *models.String 1607 + FileParams []StringFileFragmentParams 1608 + IsStarred bool 1609 + StarCount int 1610 + CommentList []models.CommentListItem 1615 1611 1616 1612 Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData 1617 1613 UserReacted map[syntax.ATURI]map[models.ReactionKind]bool ··· 1620 1616 1621 1617 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1622 1618 return p.execute("strings/string", w, params) 1619 + } 1620 + 1621 + type StringFileEditFragmentParams struct { 1622 + Name string 1623 + Content string 1624 + Size uint64 1625 + } 1626 + 1627 + type StringFileFragmentParams struct { 1628 + String *models.String 1629 + Name string 1630 + Content string 1631 + 1632 + LineCount int 1633 + Size uint64 1634 + HasNoTrailingEOL bool 1635 + HasRenderedToggle bool 1636 + ShowingRendered bool 1637 + } 1638 + 1639 + func (p *Pages) StringFileFragment(w io.Writer, params StringFileFragmentParams) error { 1640 + return p.executePlain("strings/fragments/file", w, params) 1641 + } 1642 + 1643 + func (p *Pages) StringFileEditFragment(w io.Writer) error { 1644 + return p.executePlain("strings/fragments/fileEdit", w, StringFileEditFragmentParams{}) 1623 1645 } 1624 1646 1625 1647 type SearchReposParams struct {
-58
appview/pages/templates/strings/dashboard.html
··· 1 - {{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - {{ $handle := resolve .Card.UserDid }} 5 - <meta property="og:title" content="{{ $handle }}" /> 6 - <meta property="og:type" content="profile" /> 7 - <meta property="og:url" content="https://tangled.org/{{ $handle }}" /> 8 - <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 9 - {{ end }} 10 - 11 - 12 - {{ define "content" }} 13 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 - <div class="md:col-span-3 order-1 md:order-1"> 15 - {{ template "user/fragments/profileCard" .Card }} 16 - </div> 17 - <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 18 - {{ block "allStrings" . }}{{ end }} 19 - </div> 20 - </div> 21 - {{ end }} 22 - 23 - {{ define "allStrings" }} 24 - <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 25 - <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 26 - {{ range .Strings }} 27 - {{ template "singleString" (list $ .) }} 28 - {{ else }} 29 - <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 30 - {{ end }} 31 - </div> 32 - {{ end }} 33 - 34 - {{ define "singleString" }} 35 - {{ $root := index . 0 }} 36 - {{ $s := index . 1 }} 37 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 38 - <div class="font-medium dark:text-white flex gap-2 items-center"> 39 - <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 40 - </div> 41 - {{ with $s.Description }} 42 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 43 - {{ . }} 44 - </div> 45 - {{ end }} 46 - 47 - {{ $stat := $s.Stats }} 48 - <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 49 - <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 - {{ with $s.Edited }} 52 - <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 53 - {{ else }} 54 - {{ template "repo/fragments/shortTimeAgo" $s.Created }} 55 - {{ end }} 56 - </div> 57 - </div> 58 - {{ end }}
+16
appview/pages/templates/strings/edit.html
··· 1 + {{ define "title" }}edit string{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-2 mb-4"> 5 + <p class="text-xl font-bold dark:text-white">Edit string</p> 6 + </div> 7 + <form 8 + id="string-form" 9 + hx-post="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit" 10 + hx-indicator="find button[type='submit']" 11 + hx-swap="none" 12 + hx-trigger="submit, ctrlenter" 13 + > 14 + {{ template "strings/fragments/formBody" . }} 15 + </form> 16 + {{ end }}
+31
appview/pages/templates/strings/fragments/file.html
··· 1 + {{ define "strings/fragments/file" }} 2 + <section 3 + class="bg-white dark:bg-gray-800 px-6 py-4 mb-2 rounded relative w-full dark:text-white" 4 + hx-target="this" 5 + hx-swap="outerHTML" 6 + > 7 + <div class="flex flex-col md:flex-row md:justify-between md: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"> 8 + <span>{{ .Name }}</span> 9 + <div> 10 + <span>{{ .LineCount }} line{{if ne .LineCount 1}}s{{end}}</span> 11 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 12 + <span>{{ .Size }}</span> 13 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 14 + <a href="/strings/{{ resolve .String.Did.String }}/{{ .String.Rkey }}/{{ .String.Cid }}/{{ .Name }}/raw">view raw</a> 15 + {{ if .HasRenderedToggle }} 16 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 17 + <button hx-get="/strings/{{ .String.Did }}/{{ .String.Rkey }}/{{ .String.Cid }}/{{ .Name }}?code={{ .ShowingRendered }}"> 18 + view {{ if .ShowingRendered }}code{{ else }}rendered{{ end }} 19 + </button> 20 + {{ end }} 21 + </div> 22 + </div> 23 + <div class="overflow-x-auto overflow-y-hidden relative"> 24 + {{ if .ShowingRendered }} 25 + <div class="prose dark:prose-invert">{{ .Content | readme }}</div> 26 + {{ else }} 27 + <div class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .Content .Name | escapeHtml }}</div> 28 + {{ end }} 29 + </div> 30 + </section> 31 + {{ end }}
+37
appview/pages/templates/strings/fragments/fileEdit.html
··· 1 + {{ define "strings/fragments/fileEdit" }} 2 + <div id="file-editor" class="p-6 mb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 3 + <div class="flex justify-between items-center md:flex-row md:items-center gap-2"> 4 + <div class="flex gap-2"> 5 + <input 6 + type="text" 7 + name="filename" 8 + placeholder="Filename" 9 + required 10 + value="{{ .Name }}" 11 + 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" 12 + > 13 + <button 14 + id="remove-file-btn" 15 + class="remove-file-btn btn flex items-center text-red-400 dark:text-red-500" 16 + onclick="this.closest('#file-editor').remove()" 17 + > 18 + {{ i "trash-2" "size-4" }} 19 + </button> 20 + </div> 21 + <div class="text-sm text-gray-500 dark:text-gray-400"> 22 + <span id="line-count">0 lines</span> 23 + <span class="select-none px-1 [&:before]:content-['·']"></span> 24 + <span id="byte-count">0 bytes</span> 25 + </div> 26 + </div> 27 + <textarea 28 + name="content" 29 + wrap="off" 30 + class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700 dark:placeholder-gray-400" 31 + rows="20" 32 + spellcheck="false" 33 + placeholder="Paste your string here!" 34 + required 35 + >{{ .Content }}</textarea> 36 + </div> 37 + {{ end }}
-90
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" 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-800 dark:text-white dark:border-gray-700 dark:placeholder-gray-400 font-mono" 35 - rows="20" 36 - spellcheck="false" 37 - placeholder="Paste your string here!" 38 - required>{{ .String.Contents }}</textarea> 39 - <div class="flex justify-between items-center"> 40 - <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 - <span id="line-count">0 lines</span> 42 - <span class="select-none px-1 [&:before]:content-['·']"></span> 43 - <span id="byte-count">0 bytes</span> 44 - </div> 45 - <div id="actions" class="flex gap-2 items-center"> 46 - {{ if eq .Action "edit" }} 47 - <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 " 48 - href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 - {{ i "x" "size-4" }} 50 - <span class="hidden md:inline">cancel</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </a> 53 - {{ end }} 54 - <button 55 - type="submit" 56 - id="new-button" 57 - class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 58 - > 59 - <span class="inline-flex items-center gap-2"> 60 - {{ i "arrow-up" "w-4 h-4" }} 61 - publish 62 - </span> 63 - <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 - </span> 66 - </button> 67 - </div> 68 - </div> 69 - <script> 70 - (function() { 71 - const textarea = document.getElementById('content-textarea'); 72 - const lineCount = document.getElementById('line-count'); 73 - const byteCount = document.getElementById('byte-count'); 74 - function updateStats() { 75 - const content = textarea.value; 76 - const lines = content === '' ? 0 : content.split('\n').length; 77 - const bytes = new TextEncoder().encode(content).length; 78 - lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 - byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 - } 81 - textarea.addEventListener('input', updateStats); 82 - textarea.addEventListener('paste', () => { 83 - setTimeout(updateStats, 0); 84 - }); 85 - updateStats(); 86 - })(); 87 - </script> 88 - <div id="error" class="error dark:text-red-400"></div> 89 - </form> 90 - {{ end }}
+125
appview/pages/templates/strings/fragments/formBody.html
··· 1 + {{ define "strings/fragments/formBody" }} 2 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 3 + <input 4 + type="text" 5 + name="title" 6 + placeholder="Title..." 7 + value="{{ with .String.Title }}{{ . }}{{ end }}" 8 + 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" 9 + > 10 + <input 11 + type="text" 12 + name="description" 13 + placeholder="Description ..." 14 + value="{{ with .String.Description }}{{ . }}{{ end }}" 15 + 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" 16 + > 17 + </div> 18 + <div id="string-files"> 19 + {{ range .FileParams }} 20 + {{ template "strings/fragments/fileEdit" . }} 21 + {{ end }} 22 + </div> 23 + <div class="flex justify-between items-center"> 24 + <div id="error"></div> 25 + <div class="flex gap-2"> 26 + <!-- TODO: accept multiple files in a string 27 + <button 28 + type="button" 29 + class="w-fit btn rounded py-0" 30 + hx-get="/strings/fileEdit" 31 + hx-swap="beforeend" 32 + hx-target="#string-files" 33 + hx-indicator="this" 34 + > 35 + <span class="inline-flex items-center gap-2"> 36 + {{ i "file-plus" "w-4 h-4" }} 37 + add file 38 + </span> 39 + </button> 40 + --> 41 + <button 42 + type="submit" 43 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 44 + > 45 + <span class="inline-flex items-center gap-2"> 46 + {{ i "arrow-up" "w-4 h-4" }} 47 + publish 48 + </span> 49 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + <script> 56 + (() => { 57 + const container = document.getElementById('string-files'); 58 + 59 + // update file stats 60 + function updateStats(editor) { 61 + console.log('update stats'); 62 + const textarea = editor.querySelector('textarea'); 63 + const lineCount = editor.querySelector('#line-count'); 64 + const byteCount = editor.querySelector('#byte-count'); 65 + 66 + if (!textarea || !lineCount || !byteCount) return; 67 + 68 + const content = textarea.value; 69 + const lines = content === '' ? 0 : content.split('\n').length; 70 + const bytes = new TextEncoder().encode(content).length; 71 + 72 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 73 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 74 + } 75 + 76 + function findEditorFromEvent(evt) { 77 + const textarea = evt.target.closest('#file-editor textarea'); 78 + if (!textarea) return null; 79 + return textarea.closest('#file-editor'); 80 + } 81 + container.addEventListener('input', (evt) => { 82 + const editor = findEditorFromEvent(evt); 83 + if (!editor) return; 84 + updateStats(editor); 85 + }); 86 + container.addEventListener('paste', (evt) => { 87 + const editor = findEditorFromEvent(evt); 88 + if (!editor) return; 89 + setTimeout(() => updateStats(editor), 0); 90 + }); 91 + 92 + container.querySelectorAll('#file-editor').forEach((el) => { 93 + updateStats(el); 94 + }); 95 + 96 + // disable remove-file-btn 97 + function updateRemoveButtons() { 98 + const editors = container.querySelectorAll('#file-editor'); 99 + const buttons = container.querySelectorAll('#file-editor #remove-file-btn'); 100 + if (editors.length == 1) { 101 + buttons.forEach(btn => { btn.disabled = true; }); 102 + } else { 103 + buttons.forEach(btn => { btn.disabled = false; }); 104 + } 105 + } 106 + 107 + (new MutationObserver(() => updateRemoveButtons())).observe(container, { 108 + childList: true, 109 + subtree: false, 110 + }); 111 + updateRemoveButtons(); 112 + 113 + // Because textarea triggering the keydown event can be added to DOM 114 + // after the initial load, we manually trigger the event from js. 115 + const form = document.querySelector('string-form'); 116 + container.addEventListener('keydown', (evt) => { 117 + const textarea = evt.target.closest('#file-editor textarea'); 118 + if (!textarea) return; 119 + if ((evt.ctrlKey || evt.metaKey) && evt.key === 'Enter') { 120 + htmx.trigger('#string-form', "ctrlenter"); 121 + } 122 + }); 123 + })(); 124 + </script> 125 + {{ end }}
+17
appview/pages/templates/strings/new.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-2 mb-4"> 5 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 6 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 7 + </div> 8 + <form 9 + id="string-form" 10 + hx-post="/strings/new" 11 + hx-indicator="find button[type='submit']" 12 + hx-swap="none" 13 + hx-trigger="submit, ctrlenter" 14 + > 15 + {{ template "strings/fragments/formBody" . }} 16 + </form> 17 + {{ end }}
-13
appview/pages/templates/strings/put.html
··· 1 - {{ define "title" }}publish a new string{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="px-6 py-2 mb-4"> 5 - {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 - <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 - {{ else }} 9 - <p class="text-xl font-bold dark:text-white">Edit string</p> 10 - {{ end }} 11 - </div> 12 - {{ template "strings/fragments/form" . }} 13 - {{ end }}
+26 -46
appview/pages/templates/strings/string.html
··· 1 - {{ define "title" }}{{ .String.Filename }} · by {{ resolve .Owner.DID.String }}{{ end }} 1 + {{ define "title" }}{{ .String.RenderTitle }} · by {{ resolve .String.Did.String }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $ownerId := resolve .Owner.DID.String }} 5 - <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 4 + {{ $ownerId := resolve .String.Did.String }} 5 + <meta property="og:title" content="{{ .String.RenderTitle }} · by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 7 <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 - <meta property="og:description" content="{{ .String.Description }}" /> 8 + <meta property="og:description" content="{{ with .String.Description }}{{ . }}{{ end }}" /> 9 9 {{ end }} 10 10 11 11 {{ define "content" }} 12 - {{ $ownerId := resolve .Owner.DID.String }} 12 + {{ $ownerId := resolve .String.Did.String }} 13 13 <section id="string-header" class="mb-2 py-2 px-4 dark:text-white"> 14 14 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 15 15 <!-- left items --> 16 16 <div class="flex flex-col gap-2"> 17 17 <!-- string owner / string name --> 18 18 <div class="flex items-center gap-2 flex-wrap"> 19 - {{ template "user/fragments/picHandleLink" .Owner.DID.String }} 19 + <a href="/{{ resolve .String.Did.String }}?tab=strings" class="flex items-center gap-1"> 20 + {{ template "user/fragments/picHandle" .String.Did.String }} 21 + </a> 20 22 <span class="select-none">/</span> 21 - <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.RenderTitle }}</a> 22 24 </div> 23 25 24 26 <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 25 - {{ if .String.Description }} 26 - {{ .String.Description }} 27 + {{ with .String.Description }} 28 + {{ . }} 27 29 {{ else }} 28 30 <span class="italic">this string has no description</span> 29 31 {{ end }} 30 32 </span> 33 + 34 + <div class="text-gray-500 dark:text-gray-400 text-sm text-base"> 35 + <span> 36 + {{ if .String.Edited }} 37 + edited {{ template "repo/fragments/time" .String.Edited }} 38 + {{ else }} 39 + {{ template "repo/fragments/time" .String.Created }} 40 + {{ end }} 41 + </span> 42 + </div> 31 43 </div> 32 44 33 45 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> ··· 44 56 title="Delete string" 45 57 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 46 58 hx-swap="none" 47 - hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 59 + hx-confirm="Are you sure you want to delete the string?" 48 60 > 49 61 {{ i "trash-2" "w-4 h-4" }} 50 62 <span class="hidden md:inline">delete</span> ··· 58 70 </div> 59 71 </div> 60 72 </section> 61 - <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 62 - <div class="flex flex-col md:flex-row md:justify-between md: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"> 63 - <span> 64 - {{ .String.Filename }} 65 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 66 - <span> 67 - {{ with .String.Edited }} 68 - edited {{ template "repo/fragments/shortTimeAgo" . }} 69 - {{ else }} 70 - {{ template "repo/fragments/shortTimeAgo" .String.Created }} 71 - {{ end }} 72 - </span> 73 - </span> 74 - <div> 75 - <span>{{ .Stats.LineCount }} lines</span> 76 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 77 - <span>{{ byteFmt .Stats.ByteCount }}</span> 78 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 79 - <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 80 - {{ if .RenderToggle }} 81 - <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 82 - <a href="?code={{ .ShowRendered }}" hx-boost="true"> 83 - view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 84 - </a> 85 - {{ end }} 86 - </div> 87 - </div> 88 - <div class="overflow-x-auto overflow-y-hidden relative"> 89 - {{ if .ShowRendered }} 90 - <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 91 - {{ else }} 92 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div> 93 - {{ end }} 94 - </div> 95 - {{ template "fragments/multiline-select" }} 96 - </section> 73 + {{ range .FileParams }} 74 + {{ template "strings/fragments/file" . }} 75 + {{ end }} 76 + {{ template "fragments/multiline-select" }} 97 77 <div class="flex flex-col gap-4 mt-4"> 98 78 {{ 99 79 template "fragments/comment/commentList"
+2 -4
appview/pages/templates/strings/timeline.html
··· 31 31 <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 32 <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 33 <span class="select-none">/</span> 34 - <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .RenderTitle }}</a> 35 35 </div> 36 36 {{ with .Description }} 37 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 46 46 {{ define "stringCardInfo" }} 47 47 {{ $stat := .Stats }} 48 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 49 - <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span>{{ $stat.StarCount }} star{{if ne $stat.StarCount 1}}s{{end}}</span> 50 50 <span class="select-none [&:before]:content-['·']"></span> 51 51 {{ with .Edited }} 52 52 <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> ··· 55 55 {{ end }} 56 56 </div> 57 57 {{ end }} 58 - 59 -
+2 -2
appview/pages/templates/user/strings.html
··· 25 25 {{ $s := index . 1 }} 26 26 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 27 27 <div class="font-medium dark:text-white flex gap-2 items-center"> 28 - <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 28 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.RenderTitle }}</a> 29 29 </div> 30 30 {{ with $s.Description }} 31 31 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 35 35 36 36 {{ $stat := $s.Stats }} 37 37 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 38 - <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 38 + <span>{{ $stat.StarCount }} star{{if ne $stat.StarCount 1}}s{{end}}</span> 39 39 <span class="select-none [&:before]:content-['·']"></span> 40 40 {{ with $s.Edited }} 41 41 <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
+3
appview/state/profile.go
··· 389 389 Strings: strings, 390 390 Card: profile, 391 391 }) 392 + if err != nil { 393 + l.Error("failed to render", "err", err) 394 + } 392 395 } 393 396 394 397 func (s *State) vouchesPage(w http.ResponseWriter, r *http.Request) {
+6 -6
appview/state/router.go
··· 313 313 logger := log.SubLogger(s.logger, "strings") 314 314 315 315 strs := &avstrings.Strings{ 316 - Db: s.db, 317 - OAuth: s.oauth, 318 - Pages: s.pages, 319 - IdResolver: s.idResolver, 320 - Notifier: s.notifier, 321 - Logger: logger, 316 + Db: s.db, 317 + OAuth: s.oauth, 318 + Pages: s.pages, 319 + Dir: s.idResolver.Directory(), 320 + Notifier: s.notifier, 321 + Logger: logger, 322 322 } 323 323 324 324 return strs.Router(mw)
+340 -145
appview/strings/strings.go
··· 1 - package strings 1 + package stringn 2 2 3 3 import ( 4 + "bytes" 5 + "context" 6 + "database/sql" 7 + "errors" 4 8 "fmt" 9 + "io" 5 10 "log/slog" 6 11 "net/http" 7 - "path" 8 12 "strconv" 13 + "strings" 9 14 "time" 10 15 11 16 "tangled.org/core/api/tangled" ··· 16 21 "tangled.org/core/appview/oauth" 17 22 "tangled.org/core/appview/pages" 18 23 "tangled.org/core/appview/pages/markup" 19 - "tangled.org/core/idresolver" 20 24 "tangled.org/core/orm" 21 25 "tangled.org/core/tid" 22 26 27 + "github.com/bluesky-social/indigo/api/agnostic" 23 28 "github.com/bluesky-social/indigo/api/atproto" 24 29 "github.com/bluesky-social/indigo/atproto/identity" 25 30 "github.com/bluesky-social/indigo/atproto/syntax" 31 + "github.com/bluesky-social/indigo/xrpc" 26 32 "github.com/go-chi/chi/v5" 27 33 28 34 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 30 36 ) 31 37 32 38 type Strings struct { 33 - Db *db.DB 34 - OAuth *oauth.OAuth 35 - Pages *pages.Pages 36 - IdResolver *idresolver.Resolver 37 - Logger *slog.Logger 38 - Notifier notify.Notifier 39 + Db *db.DB 40 + OAuth *oauth.OAuth 41 + Pages *pages.Pages 42 + Dir identity.Directory 43 + Logger *slog.Logger 44 + Notifier notify.Notifier 39 45 } 40 46 41 47 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { ··· 43 49 44 50 r. 45 51 Get("/", s.timeline) 52 + r. 53 + Get("/fileEdit", s.FileEditFragment) 46 54 47 55 r. 48 - With(mw.ResolveIdent()). 49 56 Route("/{user}", func(r chi.Router) { 50 57 r.Get("/", s.dashboard) 51 58 52 59 r.Route("/{rkey}", func(r chi.Router) { 53 - r.Get("/", s.contents) 60 + r.Use(mw.ResolveIdent()) 61 + r.Use(s.resolveString) 62 + 63 + r.Get("/", s.SingleString) 54 64 r.Delete("/", s.delete) 55 - r.Get("/raw", s.contents) 56 65 r.Get("/edit", s.edit) 57 66 r.Post("/edit", s.edit) 67 + 68 + r.Get("/{cid}/{filename}", s.FileFragment) 69 + r.Get("/{cid}/{filename}/raw", s.FileRaw) 70 + 71 + // legacy endpoint 72 + r.Get("/raw", s.redirectToFirstFileRaw) 58 73 }) 59 74 }) 60 75 ··· 68 83 return r 69 84 } 70 85 86 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 87 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 88 + } 89 + 71 90 func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 72 91 l := s.Logger.With("handler", "timeline") 73 92 ··· 84 103 }) 85 104 } 86 105 87 - func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 88 - l := s.Logger.With("handler", "contents") 106 + type stringCtxKey struct{} 89 107 90 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 91 - if !ok { 92 - l.Error("malformed middleware") 93 - w.WriteHeader(http.StatusInternalServerError) 94 - return 95 - } 96 - l = l.With("did", id.DID, "handle", id.Handle) 108 + func (s *Strings) resolveString(next http.Handler) http.Handler { 109 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 + l := s.Logger.With("middleware", "resolveString") 111 + rkey := chi.URLParam(r, "rkey") 97 112 98 - rkey := chi.URLParam(r, "rkey") 99 - if rkey == "" { 100 - l.Error("malformed url, empty rkey") 101 - w.WriteHeader(http.StatusBadRequest) 102 - return 103 - } 104 - l = l.With("rkey", rkey) 113 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 114 + if !ok { 115 + l.Error("malformed middleware") 116 + w.WriteHeader(http.StatusInternalServerError) 117 + return 118 + } 119 + 120 + string, err := db.GetString(s.Db, orm.FilterEq("did", id.DID), orm.FilterEq("rkey", rkey)) 121 + if errors.Is(err, sql.ErrNoRows) { 122 + s.Pages.Error404(w) 123 + return 124 + } else if err != nil { 125 + l.Error("failed to fetch string", "err", err) 126 + w.WriteHeader(http.StatusInternalServerError) 127 + return 128 + } 129 + 130 + ctx := context.WithValue(r.Context(), stringCtxKey{}, string) 131 + next.ServeHTTP(w, r.WithContext(ctx)) 132 + }) 133 + } 134 + 135 + func stringFromContext(ctx context.Context) (models.String, bool) { 136 + str, ok := ctx.Value(stringCtxKey{}).(models.String) 137 + return str, ok 138 + } 105 139 106 - strings, err := db.GetStrings( 107 - s.Db, 108 - 0, 109 - orm.FilterEq("did", id.DID), 110 - orm.FilterEq("rkey", rkey), 111 - ) 112 - if err != nil { 113 - l.Error("failed to fetch string", "err", err) 114 - w.WriteHeader(http.StatusInternalServerError) 115 - return 116 - } 117 - if len(strings) < 1 { 118 - l.Error("string not found") 140 + // redirectToFirstFileRaw is a handle for legacy endpoint. 141 + // It redirects /strings/{did}/{rkey}/raw to /strings/{did}/{rkey}/{cid}/{filename}/raw 142 + func (s *Strings) redirectToFirstFileRaw(w http.ResponseWriter, r *http.Request) { 143 + str, ok := stringFromContext(r.Context()) 144 + if !ok { 145 + s.Logger.Error("malformed middleware. string missing") 119 146 s.Pages.Error404(w) 120 147 return 121 148 } 122 - if len(strings) != 1 { 123 - l.Error("incorrect number of records returned", "len(strings)", len(strings)) 124 - w.WriteHeader(http.StatusInternalServerError) 125 - return 149 + var cid syntax.CID 150 + var filename string 151 + if str.Cid != nil { 152 + cid = *str.Cid 153 + } else { 154 + var err error 155 + cid, err = s.getRecordCid(r.Context(), str.AtUri()) 156 + if err != nil { 157 + s.Pages.Error404(w) 158 + return 159 + } 160 + } 161 + if str.IsLegacySingleFile() { 162 + filename = str.FileName 163 + } else { 164 + filename = str.Files[0].Name 126 165 } 127 - string := strings[0] 166 + http.Redirect(w, r, fmt.Sprintf("/strings/%s/%s/%s/%s/raw", str.Did, str.Rkey, cid, filename), http.StatusFound) 167 + } 128 168 129 - if path.Base(r.URL.Path) == "raw" { 130 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 131 - if string.Filename != "" { 132 - w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 133 - } 134 - w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 169 + func (s *Strings) SingleString(w http.ResponseWriter, r *http.Request) { 170 + l := s.Logger.With("handler", "SingleString") 171 + ctx := r.Context() 135 172 136 - _, err = w.Write([]byte(string.Contents)) 137 - if err != nil { 138 - l.Error("failed to write raw response", "err", err) 139 - } 173 + string, ok := stringFromContext(ctx) 174 + if !ok { 175 + l.Error("malformed middleware. string missing") 176 + s.Pages.Error404(w) 140 177 return 141 178 } 142 179 143 - var showRendered, renderToggle bool 144 - if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 145 - renderToggle = true 146 - showRendered = r.URL.Query().Get("code") != "true" 147 - } 148 - 149 180 starCount, err := db.GetStarCount(s.Db, string.AtUri()) 150 181 if err != nil { 151 182 l.Error("failed to get star count", "err", err) ··· 190 221 } 191 222 } 192 223 224 + var files []pages.StringFileFragmentParams 225 + 226 + if string.IsLegacySingleFile() { 227 + files = []pages.StringFileFragmentParams{ 228 + s.makeFileFragmentParams(&string, string.FileName, string.FileContent, false), 229 + } 230 + } else { 231 + files = make([]pages.StringFileFragmentParams, len(string.Files)) 232 + for i, file := range string.Files { 233 + // TODO: read blob 234 + content := "" 235 + files[i] = s.makeFileFragmentParams(&string, file.Name, content, false) 236 + } 237 + } 238 + 193 239 err = s.Pages.SingleString(w, pages.SingleStringParams{ 194 240 LoggedInUser: user, 195 - RenderToggle: renderToggle, 196 - ShowRendered: showRendered, 197 241 String: &string, 198 - Stats: string.Stats(), 242 + FileParams: files, 199 243 IsStarred: isStarred, 200 244 StarCount: starCount, 201 - Owner: id, 202 245 CommentList: models.NewCommentList(comments), 203 246 204 247 Reactions: reactions, 205 248 UserReacted: userReactions, 206 249 VouchRelationships: vouchRelationships, 207 250 }) 208 - } 209 - 210 - func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 211 - http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 251 + if err != nil { 252 + l.Error("failed to render", "err", err) 253 + } 212 254 } 213 255 214 256 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 215 257 l := s.Logger.With("handler", "edit") 258 + ctx := r.Context() 216 259 217 260 user := s.OAuth.GetMultiAccountUser(r) 218 261 219 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 262 + oldString, ok := stringFromContext(ctx) 220 263 if !ok { 221 - l.Error("malformed middleware") 222 - w.WriteHeader(http.StatusInternalServerError) 223 - return 224 - } 225 - l = l.With("did", id.DID, "handle", id.Handle) 226 - 227 - rkey := chi.URLParam(r, "rkey") 228 - if rkey == "" { 229 - l.Error("malformed url, empty rkey") 230 - w.WriteHeader(http.StatusBadRequest) 231 - return 232 - } 233 - l = l.With("rkey", rkey) 234 - 235 - // get the string currently being edited 236 - all, err := db.GetStrings( 237 - s.Db, 238 - 0, 239 - orm.FilterEq("did", id.DID), 240 - orm.FilterEq("rkey", rkey), 241 - ) 242 - if err != nil { 243 - l.Error("failed to fetch string", "err", err) 244 - w.WriteHeader(http.StatusInternalServerError) 245 - return 246 - } 247 - if len(all) != 1 { 248 - l.Error("incorrect number of records returned", "len(strings)", len(all)) 249 - w.WriteHeader(http.StatusInternalServerError) 264 + l.Error("malformed middleware. string missing") 265 + s.Pages.Error404(w) 250 266 return 251 267 } 252 - first := all[0] 253 268 254 269 // verify that the logged in user owns this string 255 - if user.Did != id.DID.String() { 256 - l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 270 + if user.Did != oldString.Did.String() { 271 + l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did) 257 272 w.WriteHeader(http.StatusUnauthorized) 258 273 return 259 274 } ··· 261 276 switch r.Method { 262 277 case http.MethodGet: 263 278 // return the form with prefilled fields 264 - s.Pages.PutString(w, pages.PutStringParams{ 265 - LoggedInUser: s.OAuth.GetMultiAccountUser(r), 266 - Action: "edit", 267 - String: first, 279 + var files []pages.StringFileEditFragmentParams 280 + if oldString.IsLegacySingleFile() { 281 + files = []pages.StringFileEditFragmentParams{ 282 + { 283 + Name: oldString.FileName, 284 + Content: oldString.FileContent, 285 + Size: uint64(len(oldString.FileContent)), 286 + }, 287 + } 288 + } else { 289 + files = make([]pages.StringFileEditFragmentParams, len(oldString.Files)) 290 + for i, file := range oldString.Files { 291 + // TODO: read blob 292 + content := "" 293 + files[i] = pages.StringFileEditFragmentParams{ 294 + Name: file.Name, 295 + Content: content, 296 + Size: uint64(file.Blob.Size), 297 + } 298 + } 299 + } 300 + err := s.Pages.EditString(w, pages.EditStringParams{ 301 + LoggedInUser: user, 302 + String: oldString, 303 + FileParams: files, 268 304 }) 305 + if err != nil { 306 + l.Error("failed to render", "err", err) 307 + } 269 308 case http.MethodPost: 270 309 fail := func(msg string, err error) { 271 310 l.Error(msg, "err", err) 272 311 s.Pages.Notice(w, "error", msg) 273 312 } 274 313 314 + var title *string 315 + if val := r.FormValue("title"); val != "" { 316 + title = &val 317 + } 318 + 319 + var description *string 320 + if val := r.FormValue("description"); val != "" { 321 + description = &val 322 + } 323 + 275 324 filename := r.FormValue("filename") 276 325 if filename == "" { 277 326 fail("Empty filename.", nil) ··· 280 329 281 330 content := r.FormValue("content") 282 331 if content == "" { 283 - fail("Empty contents.", nil) 332 + fail("Empty content.", nil) 284 333 return 285 334 } 286 335 287 - description := r.FormValue("description") 288 - 289 - // construct new string from form values 290 - entry := models.String{ 291 - Did: first.Did, 292 - Rkey: first.Rkey, 293 - Filename: filename, 294 - Description: description, 295 - Contents: content, 296 - Created: first.Created, 297 - } 336 + newString := oldString 337 + newString.Title = title 338 + newString.Description = description 339 + newString.FileName = filename 340 + newString.FileContent = content 298 341 299 342 client, err := s.OAuth.AuthorizedClient(r) 300 343 if err != nil { ··· 304 347 305 348 // first replace the existing record in the PDS 306 349 var exCid string 307 - if entry.Cid != nil { 308 - exCid = entry.Cid.String() 350 + if newString.Cid != nil { 351 + exCid = oldString.Cid.String() 309 352 } else { 310 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey.String()) 353 + cid, err := s.getRecordCid(ctx, oldString.AtUri()) 311 354 if err != nil { 312 - fail("Failed to get existing record.", err) 313 - return 314 - } 315 - if ex.Cid == nil { 316 - fail("Failed to get existing record.", err) 355 + s.Pages.Error404(w) 317 356 return 318 357 } 319 - exCid = *ex.Cid 358 + exCid = cid.String() 320 359 } 321 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 360 + resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ 322 361 Collection: tangled.StringNSID, 323 - Repo: entry.Did.String(), 324 - Rkey: entry.Rkey.String(), 362 + Repo: newString.Did.String(), 363 + Rkey: newString.Rkey.String(), 325 364 SwapRecord: &exCid, 326 - Record: &lexutil.LexiconTypeDecoder{Val: entry.AsRecord()}, 365 + Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, 327 366 }) 328 367 if err != nil { 329 368 fail("Failed to updated existing record.", err) 330 369 return 331 370 } 332 - l := l.With("aturi", resp.Uri) 371 + l = l.With("aturi", resp.Uri) 333 372 l.Info("edited string") 373 + 374 + newString.Cid = new(syntax.CID) 375 + *newString.Cid = syntax.CID(resp.Cid) 334 376 335 377 // if that went okay, updated the db 336 - if err = db.AddString(s.Db, entry); err != nil { 378 + if err = db.AddString(s.Db, newString); err != nil { 337 379 fail("Failed to update string.", err) 338 380 return 339 381 } 340 382 341 - s.Notifier.EditString(r.Context(), &entry) 383 + s.Notifier.EditString(ctx, &newString) 342 384 343 385 // if that went okay, redir to the string 344 - s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", entry.Did, entry.Rkey)) 386 + s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) 345 387 } 346 388 347 389 } ··· 352 394 353 395 switch r.Method { 354 396 case http.MethodGet: 355 - s.Pages.PutString(w, pages.PutStringParams{ 356 - LoggedInUser: s.OAuth.GetMultiAccountUser(r), 357 - Action: "new", 397 + err := s.Pages.NewString(w, pages.NewStringParams{ 398 + LoggedInUser: user, 358 399 }) 400 + if err != nil { 401 + l.Error("failed to render", "err", err) 402 + } 359 403 case http.MethodPost: 360 404 fail := func(msg string, err error) { 361 405 l.Error(msg, "err", err) 362 406 s.Pages.Notice(w, "error", msg) 363 407 } 364 408 409 + var title *string 410 + if val := r.FormValue("title"); val != "" { 411 + title = &val 412 + } 413 + 414 + var description *string 415 + if val := r.FormValue("description"); val != "" { 416 + description = &val 417 + } 418 + 365 419 filename := r.FormValue("filename") 366 420 if filename == "" { 367 421 fail("Empty filename.", nil) ··· 370 424 371 425 content := r.FormValue("content") 372 426 if content == "" { 373 - fail("Empty contents.", nil) 427 + fail("Empty content.", nil) 374 428 return 375 429 } 376 430 377 - description := r.FormValue("description") 378 - 379 431 string := models.String{ 380 432 Did: syntax.DID(user.Did), 381 433 Rkey: syntax.RecordKey(tid.TID()), 382 - Filename: filename, 434 + Title: title, 383 435 Description: description, 384 - Contents: content, 436 + FileName: filename, 437 + FileContent: content, 385 438 Created: time.Now(), 386 439 } 387 440 ··· 418 471 } 419 472 420 473 func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 421 - l := s.Logger.With("handler", "create") 474 + l := s.Logger.With("handler", "delete") 422 475 user := s.OAuth.GetMultiAccountUser(r) 423 476 fail := func(msg string, err error) { 424 477 l.Error(msg, "err", err) ··· 474 527 475 528 s.Pages.HxRedirect(w, "/strings/"+user.Did) 476 529 } 530 + 531 + // FileRaw renders raw file in that specific CID. (strong cache policy) 532 + func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) { 533 + l := s.Logger.With("handler", "FileRaw") 534 + ctx := r.Context() 535 + 536 + string, ok := stringFromContext(ctx) 537 + if !ok { 538 + l.Error("malformed middleware. string missing") 539 + s.Pages.Error404(w) 540 + return 541 + } 542 + filename := chi.URLParam(r, "filename") 543 + 544 + if string.IsLegacySingleFile() { 545 + if filename != string.FileName { 546 + http.NotFound(w, r) 547 + return 548 + } 549 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 550 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 551 + w.Header().Set("Content-Length", strconv.Itoa(len(string.FileContent))) 552 + _, err := w.Write([]byte(string.FileContent)) 553 + if err != nil { 554 + l.Error("failed to write raw response", "err", err) 555 + } 556 + } else { 557 + file, ok := string.FileByName(filename) 558 + if !ok { 559 + http.NotFound(w, r) 560 + return 561 + } 562 + 563 + content := "" 564 + 565 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 566 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) 567 + w.Header().Set("Content-Length", strconv.FormatInt(file.Blob.Size, 10)) 568 + _, err := w.Write([]byte(content)) 569 + if err != nil { 570 + l.Error("failed to write raw response", "err", err) 571 + } 572 + } 573 + } 574 + 575 + func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams { 576 + size := len(content) 577 + if size > 8*1024*1024 { // 8 MB 578 + // TODO: show "file too big" page 579 + } 580 + 581 + buf, _ := io.ReadAll(strings.NewReader(content)) 582 + 583 + var lineCount int 584 + var hasNoTrailingEOL bool 585 + if size > 0 { 586 + hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'}) 587 + lineCount = bytes.Count(buf, []byte{'\n'}) 588 + if hasNoTrailingEOL { 589 + lineCount++ 590 + } 591 + } 592 + 593 + format := markup.GetFormat(filename) 594 + isMarkup := format == markup.FormatMarkdown 595 + 596 + return pages.StringFileFragmentParams{ 597 + String: string, 598 + Name: filename, 599 + Content: content, 600 + 601 + LineCount: lineCount, 602 + Size: uint64(size), 603 + HasNoTrailingEOL: hasNoTrailingEOL, 604 + HasRenderedToggle: isMarkup, 605 + ShowingRendered: isMarkup, 606 + } 607 + } 608 + 609 + // render each string "file" html fragment 610 + func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) { 611 + l := s.Logger.With("handler", "FileFragment") 612 + ctx := r.Context() 613 + 614 + string, ok := stringFromContext(ctx) 615 + if !ok { 616 + l.Error("malformed middleware. string missing") 617 + http.NotFound(w, r) 618 + return 619 + } 620 + filename := chi.URLParam(r, "filename") 621 + forceCode := r.URL.Query().Get("code") == "true" 622 + 623 + var params pages.StringFileFragmentParams 624 + if string.IsLegacySingleFile() { 625 + if filename != string.FileName { 626 + http.NotFound(w, r) 627 + return 628 + } 629 + params = s.makeFileFragmentParams(&string, string.FileName, string.FileContent, forceCode) 630 + } else { 631 + file, ok := string.FileByName(filename) 632 + if !ok { 633 + l.Error("malformed middleware. string missing") 634 + http.NotFound(w, r) 635 + return 636 + } 637 + 638 + // TODO: read blob 639 + content := "" 640 + 641 + params = s.makeFileFragmentParams(&string, file.Name, content, forceCode) 642 + } 643 + s.Pages.StringFileFragment(w, params) 644 + } 645 + 646 + func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) { 647 + s.Pages.StringFileEditFragment(w) 648 + } 649 + 650 + func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { 651 + ident, err := s.Dir.Lookup(ctx, uri.Authority()) 652 + if err != nil { 653 + return "", err 654 + } 655 + 656 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 657 + out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 658 + if err != nil { 659 + return "", err 660 + } 661 + if out.Cid == nil { 662 + return "", fmt.Errorf("record CID is empty") 663 + } 664 + 665 + cid, err := syntax.ParseCID(*out.Cid) 666 + if err != nil { 667 + return "", err 668 + } 669 + 670 + return cid, nil 671 + }
-27
appview/validator/string.go
··· 1 - package validator 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "unicode/utf8" 7 - 8 - "tangled.org/core/appview/models" 9 - ) 10 - 11 - func (v *Validator) ValidateString(s *models.String) error { 12 - var err error 13 - 14 - if utf8.RuneCountInString(s.Filename) > 140 { 15 - err = errors.Join(err, fmt.Errorf("filename too long")) 16 - } 17 - 18 - if utf8.RuneCountInString(s.Description) > 280 { 19 - err = errors.Join(err, fmt.Errorf("description too long")) 20 - } 21 - 22 - if len(s.Contents) == 0 { 23 - err = errors.Join(err, fmt.Errorf("contents is empty")) 24 - } 25 - 26 - return err 27 - }