diff --git a/appview/db/db.go b/appview/db/db.go index 1efa5706..052c421f 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -2094,6 +2094,64 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + orm.RunMigration(conn, logger, "multiple-files-for-a-string", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table strings_new ( + did text not null, + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.string' || '/' || rkey) stored unique, + cid text, + + title text, + description text, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + file_name text not null default '', + file_content text not null default '', + + -- appview-local information + edited text, + + primary key (did, rkey) + ); + `) + if err != nil { + return fmt.Errorf("failed to create tables: %w", err) + } + + _, err = tx.Exec(` + insert into strings_new ( + did, rkey, cid, + title, + description, + file_name, + file_content, + created, + edited + ) + select + did, rkey, cid, + filename, + description, + filename, + content, + created, + edited + from strings; + `) + if err != nil { + return fmt.Errorf("failed to insert strings: %w", err) + } + + _, err = tx.Exec(` + drop table strings; + alter table strings_new rename to strings; + `) + if err != nil { + return fmt.Errorf("failed to drop legacy table: %w", err) + } + return nil + }) + return &DB{ db, logger, diff --git a/appview/db/strings.go b/appview/db/strings.go index 3cffb710..2a4a4baa 100644 --- a/appview/db/strings.go +++ b/appview/db/strings.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "strings" "time" @@ -12,40 +13,74 @@ import ( "tangled.org/core/orm" ) -func AddString(e Execer, s models.String) error { - _, err := e.Exec( +func AddString(d *DB, s models.String) error { + tx, err := d.Begin() + if err != nil { + return fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() + res, err := tx.Exec( `insert into strings ( did, rkey, cid, - filename, + title, description, - content, - created, - edited + file_name, + file_content, + created ) - values (?, ?, ?, ?, ?, ?, ?, null) + values (?, ?, ?, ?, ?, ?, ?, ?) on conflict(did, rkey) do update set cid = excluded.cid, - filename = excluded.filename, + title = excluded.title, description = excluded.description, - content = excluded.content, + file_name = excluded.file_name, + file_content= excluded.file_content, + created = excluded.created, edited = case when strings.cid is not null then ? else strings.edited end where strings.cid is not excluded.cid`, s.Did, s.Rkey, s.Cid, - s.Filename, + s.Title, s.Description, - s.Contents, + s.FileName, + s.FileContent, s.Created.Format(time.RFC3339), time.Now().Format(time.RFC3339), ) - return err + if err != nil { + return fmt.Errorf("inserting string: %w", err) + } + + num, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("calculating affected rows: %w", err) + } + if num == 0 { + return nil + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commiting transaction: %w", err) + } + return nil +} + +func GetString(e Execer, filters ...orm.Filter) (models.String, error) { + strings, err := GetStrings(e, 0, filters...) + if err != nil { + return models.String{}, err + } + if len(strings) != 1 { + return models.String{}, sql.ErrNoRows + } + return strings[0], nil } func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) { - var all []models.String + stringMap := make(map[syntax.ATURI]*models.String) var conditions []string var args []any @@ -64,13 +99,15 @@ func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, er limitClause = fmt.Sprintf(" limit %d ", limit) } - query := fmt.Sprintf(`select + query := fmt.Sprintf( + `select did, rkey, cid, - filename, + title, description, - content, + file_name, + file_content, created, edited from strings @@ -91,15 +128,16 @@ func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, er for rows.Next() { var s models.String var createdAt string - var cid, editedAt sql.Null[string] + var cid, title, description, editedAt sql.Null[string] if err := rows.Scan( &s.Did, &s.Rkey, &cid, - &s.Filename, - &s.Description, - &s.Contents, + &title, + &description, + &s.FileName, + &s.FileContent, &createdAt, &editedAt, ); err != nil { @@ -111,6 +149,16 @@ func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, er *s.Cid = syntax.CID(cid.V) } + if title.Valid { + s.Title = new(string) + *s.Title = title.V + } + + if description.Valid { + s.Description = new(string) + *s.Description = description.V + } + s.Created, err = time.Parse(time.RFC3339, createdAt) if err != nil { s.Created = time.Now() @@ -124,13 +172,112 @@ func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, er s.Edited = &e } - all = append(all, s) + s.Stats = &models.StringStats{} + stringMap[s.AtUri()] = &s } if err := rows.Err(); err != nil { return nil, err } + // if no strings, return early + if len(stringMap) == 0 { + return nil, nil + } + + // build IN clause for related queries + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(stringMap)), ", ") + args = make([]any, len(stringMap)) + i := 0 + for _, s := range stringMap { + args[i] = s.AtUri() + i++ + } + + // // get files + // { + // rows, err := e.Query( + // fmt.Sprintf( + // `select at_uri, name, blob from string_files where at_uri in (%s) order by at_uri, id`, + // inClause, + // ), + // args..., + // ) + // if err != nil { + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) + // } + // defer rows.Close() + // + // for rows.Next() { + // var stringAt syntax.ATURI + // var file models.String_File + // file.Blob = &util.LexBlob{} + // var gzipMimeType sql.Null[string] + // var gzipSize sql.Null[int64] + // if err := rows.Scan( + // &stringAt, + // &file.Name, + // &blob, + // ); err != nil { + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) + // } + // if gzipMimeType.Valid && gzipSize.Valid { + // file.Gzip = &models.GzipInfo{ + // MimeType: gzipMimeType.V, + // Size: gzipSize.V, + // } + // } + // if s, ok := stringMap[stringAt]; ok { + // s.Files = append(s.Files, file) + // } + // } + // if err = rows.Err(); err != nil { + // return nil, fmt.Errorf("failed to execute string_files query: %w", err) + // } + // } + + // get star counts + { + rows, err := e.Query( + fmt.Sprintf( + `select subject, count(1) from stars where subject_type = 'string' and subject in (%s) group by subject`, + inClause, + ), + args..., + ) + if err != nil { + return nil, fmt.Errorf("failed to execute star-count query: %w", err) + } + defer rows.Close() + + for rows.Next() { + var stringAt syntax.ATURI + var count int + if err := rows.Scan(&stringAt, &count); err != nil { + continue + } + if s, ok := stringMap[stringAt]; ok { + s.Stats.StarCount = count + } + } + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("failed to execute star-count query: %w", err) + } + } + + var all []models.String + for _, s := range stringMap { + all = append(all, *s) + } + + // sort by created timestamp (desc) + slices.SortFunc(all, func(a, b models.String) int { + if a.Created.After(b.Created) { + return -1 + } + return 1 + }) + return all, nil } diff --git a/appview/ingester.go b/appview/ingester.go index e397fde3..74fcec90 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -112,7 +112,10 @@ func (i *Ingester) Ingest() processFunc { case tangled.RepoNSID: err = i.ingestRepo(ctx, e) } - l = i.Logger.With("nsid", e.Commit.Collection) + l = l.With( + "uri", fmt.Sprintf("at://%s/%s/%s", e.Did, e.Commit.Collection, e.Commit.RKey), + "cid", e.Commit.CID, + ) } if err != nil { @@ -919,9 +922,11 @@ func (i *Ingester) ingestString(e *jmodels.Event) error { return err } - string := models.StringFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(e.Commit.CID), record) - - if err = i.Validator.ValidateString(&string); err != nil { + string, err := models.StringFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(e.Commit.CID), record) + if err != nil { + return fmt.Errorf("failed to parse string record: %w", err) + } + if err = string.Validate(); err != nil { l.Error("invalid record", "err", err) return err } diff --git a/appview/ingester_string_test.go b/appview/ingester_string_test.go index 928e78ba..ecde8bf1 100644 --- a/appview/ingester_string_test.go +++ b/appview/ingester_string_test.go @@ -104,14 +104,17 @@ func TestIngestString_CreateRoundTrip(t *testing.T) { if !ok { t.Fatal("row not inserted") } - if s.Filename != "hello.txt" { - t.Errorf("filename = %q, want hello.txt", s.Filename) + if s.FileName != "hello.txt" { + t.Errorf("filename = %q, want hello.txt", s.FileName) } - if s.Description != "a greeting" { - t.Errorf("description = %q", s.Description) + if s.Description == nil { + t.Errorf("description = nil") } - if s.Contents != "hello world\n" { - t.Errorf("contents = %q", s.Contents) + if *s.Description != "a greeting" { + t.Errorf("description = %q", *s.Description) + } + if s.FileContent != "hello world\n" { + t.Errorf("contents = %q", s.FileContent) } if !s.Created.Equal(created) { t.Errorf("created = %v, want %v (record CreatedAt must round-trip)", s.Created, created) @@ -145,8 +148,8 @@ func TestIngestString_UpdateBumpsEditedOnContentChange(t *testing.T) { if !ok { t.Fatal("row missing after update") } - if s.Contents != "hello, world!\n" { - t.Errorf("contents = %q, want updated value", s.Contents) + if s.FileContent != "hello, world!\n" { + t.Errorf("contents = %q, want updated value", s.FileContent) } if !s.Created.Equal(created) { t.Errorf("update overwrote created: got %v, want %v", s.Created, created) @@ -216,7 +219,6 @@ func TestIngestString_ValidatorRejects(t *testing.T) { name string rec tangled.String }{ - {"empty contents", tangled.String{Filename: "x", Contents: "", CreatedAt: now}}, {"long filename", tangled.String{Filename: strings.Repeat("a", 141), Contents: "x", CreatedAt: now}}, {"long description", tangled.String{Filename: "x", Description: strings.Repeat("d", 281), Contents: "x", CreatedAt: now}}, } diff --git a/appview/models/string.go b/appview/models/string.go index 2b3075ad..f377fece 100644 --- a/appview/models/string.go +++ b/appview/models/string.go @@ -1,13 +1,13 @@ package models import ( - "bytes" + "errors" "fmt" - "io" - "strings" "time" + "unicode/utf8" "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" "tangled.org/core/api/tangled" ) @@ -16,11 +16,36 @@ type String struct { Rkey syntax.RecordKey Cid *syntax.CID - Filename string - Description string - Contents string + // String_File will remain, and we will still use it. + // We just use `FileContent` when fetching the file. + + // after that, change lexicon, start migrating to blobs + // when string is migrated, clear FileName and FileContent. + // when they are cleared, fetch the blob on page load. + + Title *string + Description *string + Files []String_File Created time.Time Edited *time.Time + + // legacy string data + FileName string + FileContent string + + // optionally, populate this when querying for reverse mappings + Stats *StringStats +} + +// TODO: replace this with [tangled.String_File] +type String_File struct { + Name string + Blob *lexutil.LexBlob + Gzip *GzipInfo +} +type GzipInfo struct { + MimeType string + Size int64 } func (s *String) AtUri() syntax.ATURI { @@ -28,70 +53,95 @@ func (s *String) AtUri() syntax.ATURI { } func (s *String) AsRecord() *tangled.String { + var description string + if s.Description != nil { + description = *s.Description + } return &tangled.String{ - Filename: s.Filename, - Description: s.Description, - Contents: s.Contents, + Filename: s.FileName, + Description: description, + Contents: s.FileContent, CreatedAt: s.Created.Format(time.RFC3339), } } -func StringFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.String) String { +func (s *String) Validate() error { + var err error + if s.FileName == "" && len(s.Files) == 0 { + err = errors.Join(err, fmt.Errorf("string should have more than one files")) + } + // legacy record check + if utf8.RuneCountInString(s.FileName) > 140 { + err = errors.Join(err, fmt.Errorf("filename too long")) + } + for i, file := range s.Files { + if utf8.RuneCountInString(file.Name) > 140 { + err = errors.Join(err, fmt.Errorf("filename too long at files[%d]", i)) + } + } + if s.Title != nil { + if utf8.RuneCountInString(*s.Title) > 140 { + err = errors.Join(err, fmt.Errorf("title too long")) + } + } + if s.Description != nil { + if utf8.RuneCountInString(*s.Description) > 280 { + err = errors.Join(err, fmt.Errorf("description too long")) + } + } + return err +} + +func (s String) RenderTitle() string { + if s.Title != nil { + return *s.Title + } + if len(s.Files) > 0 { + return s.Files[0].Name + } + return s.FileName +} + +// FileByName returns first item in files with given filename +func (s *String) FileByName(name string) (String_File, bool) { + for _, file := range s.Files { + if file.Name == name { + return file, true + } + } + return String_File{}, false +} + +func (s String) IsLegacySingleFile() bool { + return len(s.Files) == 0 +} + +func StringFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.String) (String, error) { created, err := time.Parse(time.RFC3339, record.CreatedAt) if err != nil { - created = time.Now() + return String{}, fmt.Errorf("invalid createdAt: %w", err) + } + var description *string + if record.Description != "" { + description = &record.Description } return String{ Did: did, Rkey: rkey, Cid: &cid, - Filename: record.Filename, - Description: record.Description, - Contents: record.Contents, + Description: description, Created: created, - } + FileName: record.Filename, + FileContent: record.Contents, + }, nil } type StringStats struct { - LineCount uint64 - ByteCount uint64 + StarCount int + // CommentCount int } -func (s String) Stats() StringStats { - lineCount, err := countLines(strings.NewReader(s.Contents)) - if err != nil { - // non-fatal - // TODO: log this? - } - - return StringStats{ - LineCount: uint64(lineCount), - ByteCount: uint64(len(s.Contents)), - } -} - -func countLines(r io.Reader) (int, error) { - buf := make([]byte, 32*1024) - bufLen := 0 - count := 0 - nl := []byte{'\n'} - - for { - c, err := r.Read(buf) - if c > 0 { - bufLen += c - } - count += bytes.Count(buf[:c], nl) - - switch { - case err == io.EOF: - /* handle last line not having a newline at the end */ - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { - count++ - } - return count, nil - case err != nil: - return 0, err - } - } +type StringFileStats struct { + LineCount int + ByteCount int } diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 8ef90bbe..e8164941 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -29,7 +29,6 @@ import ( "tangled.org/core/idresolver" "tangled.org/core/types" - "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-git/go-git/v5/plumbing" ) @@ -1556,26 +1555,27 @@ func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error { return p.executeRepo("repo/pipelines/workflow", w, params) } -type PutStringParams struct { +type NewStringParams struct { LoggedInUser *oauth.MultiAccountUser - Action string - - // this is supplied in the case of editing an existing string - String models.String + String models.String + FileParams []StringFileEditFragmentParams } -func (p *Pages) PutString(w io.Writer, params PutStringParams) error { - return p.execute("strings/put", w, params) +func (p *Pages) NewString(w io.Writer, params NewStringParams) error { + // use default string value to render template + params.String = models.String{Files: make([]models.String_File, 1)} + params.FileParams = make([]StringFileEditFragmentParams, 1) + return p.execute("strings/new", w, params) } -type StringsDashboardParams struct { +type EditStringParams struct { LoggedInUser *oauth.MultiAccountUser - Card ProfileCard - Strings []models.String + String models.String + FileParams []StringFileEditFragmentParams } -func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { - return p.execute("strings/dashboard", w, params) +func (p *Pages) EditString(w io.Writer, params EditStringParams) error { + return p.execute("strings/edit", w, params) } type StringTimelineParams struct { @@ -1588,16 +1588,12 @@ func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error } type SingleStringParams struct { - LoggedInUser *oauth.MultiAccountUser - ShowRendered bool - RenderToggle bool - RenderedContents template.HTML - String *models.String - Stats models.StringStats - IsStarred bool - StarCount int - Owner identity.Identity - CommentList []models.CommentListItem + LoggedInUser *oauth.MultiAccountUser + String *models.String + FileParams []StringFileFragmentParams + IsStarred bool + StarCount int + CommentList []models.CommentListItem Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData UserReacted map[syntax.ATURI]map[models.ReactionKind]bool @@ -1608,6 +1604,32 @@ func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { return p.execute("strings/string", w, params) } +type StringFileEditFragmentParams struct { + Name string + Content string + Size uint64 +} + +type StringFileFragmentParams struct { + String *models.String + Name string + Content string + + LineCount int + Size uint64 + HasNoTrailingEOL bool + HasRenderedToggle bool + ShowingRendered bool +} + +func (p *Pages) StringFileFragment(w io.Writer, params StringFileFragmentParams) error { + return p.executePlain("strings/fragments/file", w, params) +} + +func (p *Pages) StringFileEditFragment(w io.Writer) error { + return p.executePlain("strings/fragments/fileEdit", w, StringFileEditFragmentParams{}) +} + type SearchReposParams struct { LoggedInUser *oauth.MultiAccountUser Repos []models.Repo diff --git a/appview/pages/ratchet_test.go b/appview/pages/ratchet_test.go index 635042a0..a5cb1fc4 100644 --- a/appview/pages/ratchet_test.go +++ b/appview/pages/ratchet_test.go @@ -42,9 +42,10 @@ func TestNoRepoRkeyInTemplates(t *testing.T) { } var bareDidAllowlist = map[string]bool{ - "templates/strings/string.html": true, - "templates/strings/fragments/form.html": true, "templates/spindles/dashboard.html": true, + "templates/strings/edit.html": true, + "templates/strings/fragments/file.html": true, + "templates/strings/string.html": true, } var didCloseAsUrlSegment = regexp.MustCompile(`\.(?:Did|OwnerDid)\s*\}\}\s*/`) diff --git a/appview/pages/templates/strings/dashboard.html b/appview/pages/templates/strings/dashboard.html deleted file mode 100644 index 006a6a53..00000000 --- a/appview/pages/templates/strings/dashboard.html +++ /dev/null @@ -1,58 +0,0 @@ -{{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }} - -{{ define "extrameta" }} - {{ $handle := resolve .Card.UserDid }} - - - - -{{ end }} - - -{{ define "content" }} -
-
- {{ template "user/fragments/profileCard" .Card }} -
-
- {{ block "allStrings" . }}{{ end }} -
-
-{{ end }} - -{{ define "allStrings" }} -

ALL STRINGS

-
- {{ range .Strings }} - {{ template "singleString" (list $ .) }} - {{ else }} -

This user does not have any strings yet.

- {{ end }} -
-{{ end }} - -{{ define "singleString" }} - {{ $root := index . 0 }} - {{ $s := index . 1 }} -
-
- {{ $s.Filename }} -
- {{ with $s.Description }} -
- {{ . }} -
- {{ end }} - - {{ $stat := $s.Stats }} -
- {{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}} - - {{ with $s.Edited }} - edited {{ template "repo/fragments/shortTimeAgo" . }} - {{ else }} - {{ template "repo/fragments/shortTimeAgo" $s.Created }} - {{ end }} -
-
-{{ end }} diff --git a/appview/pages/templates/strings/edit.html b/appview/pages/templates/strings/edit.html new file mode 100644 index 00000000..a6b42102 --- /dev/null +++ b/appview/pages/templates/strings/edit.html @@ -0,0 +1,16 @@ +{{ define "title" }}edit string{{ end }} + +{{ define "content" }} +
+

Edit string

+
+
+ {{ template "strings/fragments/formBody" . }} +
+{{ end }} diff --git a/appview/pages/templates/strings/fragments/file.html b/appview/pages/templates/strings/fragments/file.html new file mode 100644 index 00000000..366c0cc4 --- /dev/null +++ b/appview/pages/templates/strings/fragments/file.html @@ -0,0 +1,31 @@ +{{ define "strings/fragments/file" }} +
+
+ {{ .Name }} +
+ {{ .LineCount }} line{{if ne .LineCount 1}}s{{end}} + + {{ .Size }} + + view raw + {{ if .HasRenderedToggle }} + + + {{ end }} +
+
+
+ {{ if .ShowingRendered }} +
{{ .Content | readme }}
+ {{ else }} +
{{ code .Content .Name | escapeHtml }}
+ {{ end }} +
+
+{{ end }} diff --git a/appview/pages/templates/strings/fragments/fileEdit.html b/appview/pages/templates/strings/fragments/fileEdit.html new file mode 100644 index 00000000..8bf6eab5 --- /dev/null +++ b/appview/pages/templates/strings/fragments/fileEdit.html @@ -0,0 +1,37 @@ +{{ define "strings/fragments/fileEdit" }} +
+
+
+ + +
+
+ 0 lines + + 0 bytes +
+
+ +
+{{ end }} diff --git a/appview/pages/templates/strings/fragments/form.html b/appview/pages/templates/strings/fragments/form.html deleted file mode 100644 index 69d64930..00000000 --- a/appview/pages/templates/strings/fragments/form.html +++ /dev/null @@ -1,90 +0,0 @@ -{{ define "strings/fragments/form" }} -
-
- - -
- -
-
- 0 lines - - 0 bytes -
-
- {{ if eq .Action "edit" }} - - {{ i "x" "size-4" }} - - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} - - {{ end }} - -
-
- -
-
-{{ end }} diff --git a/appview/pages/templates/strings/fragments/formBody.html b/appview/pages/templates/strings/fragments/formBody.html new file mode 100644 index 00000000..df3ec580 --- /dev/null +++ b/appview/pages/templates/strings/fragments/formBody.html @@ -0,0 +1,125 @@ +{{ define "strings/fragments/formBody" }} +
+ + +
+
+ {{ range .FileParams }} + {{ template "strings/fragments/fileEdit" . }} + {{ end }} +
+
+
+
+ + +
+
+ +{{ end }} diff --git a/appview/pages/templates/strings/new.html b/appview/pages/templates/strings/new.html new file mode 100644 index 00000000..02e46727 --- /dev/null +++ b/appview/pages/templates/strings/new.html @@ -0,0 +1,17 @@ +{{ define "title" }}publish a new string{{ end }} + +{{ define "content" }} +
+

Create a new string

+

Store and share code snippets with ease.

+
+
+ {{ template "strings/fragments/formBody" . }} +
+{{ end }} diff --git a/appview/pages/templates/strings/put.html b/appview/pages/templates/strings/put.html deleted file mode 100644 index 154af060..00000000 --- a/appview/pages/templates/strings/put.html +++ /dev/null @@ -1,13 +0,0 @@ -{{ define "title" }}publish a new string{{ end }} - -{{ define "content" }} -
- {{ if eq .Action "new" }} -

Create a new string

-

Store and share code snippets with ease.

- {{ else }} -

Edit string

- {{ end }} -
- {{ template "strings/fragments/form" . }} -{{ end }} diff --git a/appview/pages/templates/strings/string.html b/appview/pages/templates/strings/string.html index f3ce7c56..beae1011 100644 --- a/appview/pages/templates/strings/string.html +++ b/appview/pages/templates/strings/string.html @@ -1,33 +1,45 @@ -{{ define "title" }}{{ .String.Filename }} · by {{ resolve .Owner.DID.String }}{{ end }} +{{ define "title" }}{{ .String.RenderTitle }} · by {{ resolve .String.Did.String }}{{ end }} {{ define "extrameta" }} - {{ $ownerId := resolve .Owner.DID.String }} - + {{ $ownerId := resolve .String.Did.String }} + - + {{ end }} {{ define "content" }} -{{ $ownerId := resolve .Owner.DID.String }} +{{ $ownerId := resolve .String.Did.String }}
- {{ template "user/fragments/picHandleLink" .Owner.DID.String }} + + {{ template "user/fragments/picHandle" .String.Did.String }} + / - {{ .String.Filename }} + {{ .String.RenderTitle }}
- {{ if .String.Description }} - {{ .String.Description }} + {{ with .String.Description }} + {{ . }} {{ else }} this string has no description {{ end }} + +
+ + {{ if .String.Edited }} + edited {{ template "repo/fragments/time" .String.Edited }} + {{ else }} + {{ template "repo/fragments/time" .String.Created }} + {{ end }} + +
@@ -44,7 +56,7 @@ title="Delete string" hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" hx-swap="none" - hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" + hx-confirm="Are you sure you want to delete the string?" > {{ i "trash-2" "w-4 h-4" }} @@ -58,42 +70,10 @@
-
-
- - {{ .String.Filename }} - - - {{ with .String.Edited }} - edited {{ template "repo/fragments/shortTimeAgo" . }} - {{ else }} - {{ template "repo/fragments/shortTimeAgo" .String.Created }} - {{ end }} - - -
- {{ .Stats.LineCount }} lines - - {{ byteFmt .Stats.ByteCount }} - - view raw - {{ if .RenderToggle }} - - - view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} - - {{ end }} -
-
-
- {{ if .ShowRendered }} -
{{ .String.Contents | readme }}
- {{ else }} -
{{ code .String.Contents .String.Filename | escapeHtml }}
- {{ end }} -
- {{ template "fragments/multiline-select" }} -
+ {{ range .FileParams }} + {{ template "strings/fragments/file" . }} + {{ end }} + {{ template "fragments/multiline-select" }}
{{ template "fragments/comment/commentList" diff --git a/appview/pages/templates/strings/timeline.html b/appview/pages/templates/strings/timeline.html index 60d20bb6..7f5fb053 100644 --- a/appview/pages/templates/strings/timeline.html +++ b/appview/pages/templates/strings/timeline.html @@ -31,7 +31,7 @@
{{ template "user/fragments/picHandle" $resolved }} / - {{ .Filename }} + {{ .RenderTitle }}
{{ with .Description }}
@@ -46,7 +46,7 @@ {{ define "stringCardInfo" }} {{ $stat := .Stats }}
- {{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}} + {{ $stat.StarCount }} star{{if ne $stat.StarCount 1}}s{{end}} {{ with .Edited }} edited {{ template "repo/fragments/shortTimeAgo" . }} @@ -55,5 +55,3 @@ {{ end }}
{{ end }} - - diff --git a/appview/pages/templates/user/strings.html b/appview/pages/templates/user/strings.html index e1faa530..bbfdc772 100644 --- a/appview/pages/templates/user/strings.html +++ b/appview/pages/templates/user/strings.html @@ -25,7 +25,7 @@ {{ $s := index . 1 }}
{{ with $s.Description }}
@@ -35,7 +35,7 @@ {{ $stat := $s.Stats }}
- {{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}} + {{ $stat.StarCount }} star{{if ne $stat.StarCount 1}}s{{end}} {{ with $s.Edited }} edited {{ template "repo/fragments/shortTimeAgo" . }} diff --git a/appview/state/profile.go b/appview/state/profile.go index a90ff7c4..d5237a43 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -389,6 +389,9 @@ func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { Strings: strings, Card: profile, }) + if err != nil { + l.Error("failed to render", "err", err) + } } func (s *State) vouchesPage(w http.ResponseWriter, r *http.Request) { diff --git a/appview/state/router.go b/appview/state/router.go index 3d820a21..f06776a9 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -323,12 +323,12 @@ func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { logger := log.SubLogger(s.logger, "strings") strs := &avstrings.Strings{ - Db: s.db, - OAuth: s.oauth, - Pages: s.pages, - IdResolver: s.idResolver, - Notifier: s.notifier, - Logger: logger, + Db: s.db, + OAuth: s.oauth, + Pages: s.pages, + Dir: s.idResolver.Directory(), + Notifier: s.notifier, + Logger: logger, } return strs.Router(mw) diff --git a/appview/strings/strings.go b/appview/strings/strings.go index ab6b2673..a6a7d416 100644 --- a/appview/strings/strings.go +++ b/appview/strings/strings.go @@ -1,11 +1,16 @@ -package strings +package stringn import ( + "bytes" + "context" + "database/sql" + "errors" "fmt" + "io" "log/slog" "net/http" - "path" "strconv" + "strings" "time" "tangled.org/core/api/tangled" @@ -16,13 +21,14 @@ import ( "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" - "tangled.org/core/idresolver" "tangled.org/core/orm" "tangled.org/core/tid" + "github.com/bluesky-social/indigo/api/agnostic" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -30,12 +36,12 @@ import ( ) type Strings struct { - Db *db.DB - OAuth *oauth.OAuth - Pages *pages.Pages - IdResolver *idresolver.Resolver - Logger *slog.Logger - Notifier notify.Notifier + Db *db.DB + OAuth *oauth.OAuth + Pages *pages.Pages + Dir identity.Directory + Logger *slog.Logger + Notifier notify.Notifier } func (s *Strings) Router(mw *middleware.Middleware) http.Handler { @@ -43,18 +49,27 @@ func (s *Strings) Router(mw *middleware.Middleware) http.Handler { r. Get("/", s.timeline) + r. + Get("/fileEdit", s.FileEditFragment) r. - With(mw.ResolveIdent()). Route("/{user}", func(r chi.Router) { r.Get("/", s.dashboard) r.Route("/{rkey}", func(r chi.Router) { - r.Get("/", s.contents) + r.Use(mw.ResolveIdent()) + r.Use(s.resolveString) + + r.Get("/", s.SingleString) r.Delete("/", s.delete) - r.Get("/raw", s.contents) r.Get("/edit", s.edit) r.Post("/edit", s.edit) + + r.Get("/{cid}/{filename}", s.FileFragment) + r.Get("/{cid}/{filename}/raw", s.FileRaw) + + // legacy endpoint + r.Get("/raw", s.redirectToFirstFileRaw) }) }) @@ -68,6 +83,10 @@ func (s *Strings) Router(mw *middleware.Middleware) http.Handler { return r } +func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) +} + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "timeline") @@ -84,77 +103,88 @@ func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { }) } -func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { - l := s.Logger.With("handler", "contents") +type stringCtxKey struct{} - id, ok := r.Context().Value("resolvedId").(identity.Identity) - if !ok { - l.Error("malformed middleware") - w.WriteHeader(http.StatusInternalServerError) - return - } - l = l.With("did", id.DID, "handle", id.Handle) +func (s *Strings) resolveString(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("middleware", "resolveString") + rkey := chi.URLParam(r, "rkey") - rkey := chi.URLParam(r, "rkey") - if rkey == "" { - l.Error("malformed url, empty rkey") - w.WriteHeader(http.StatusBadRequest) - return - } - l = l.With("rkey", rkey) + id, ok := r.Context().Value("resolvedId").(identity.Identity) + if !ok { + l.Error("malformed middleware") + w.WriteHeader(http.StatusInternalServerError) + return + } - strings, err := db.GetStrings( - s.Db, - 0, - orm.FilterEq("did", id.DID), - orm.FilterEq("rkey", rkey), - ) - if err != nil { - l.Error("failed to fetch string", "err", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - if len(strings) < 1 { - l.Error("string not found") + string, err := db.GetString(s.Db, orm.FilterEq("did", id.DID), orm.FilterEq("rkey", rkey)) + if errors.Is(err, sql.ErrNoRows) { + s.Pages.Error404(w) + return + } else if err != nil { + l.Error("failed to fetch string", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := context.WithValue(r.Context(), stringCtxKey{}, string) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func stringFromContext(ctx context.Context) (models.String, bool) { + str, ok := ctx.Value(stringCtxKey{}).(models.String) + return str, ok +} + +// redirectToFirstFileRaw is a handle for legacy endpoint. +// It redirects /strings/{did}/{rkey}/raw to /strings/{did}/{rkey}/{cid}/{filename}/raw +func (s *Strings) redirectToFirstFileRaw(w http.ResponseWriter, r *http.Request) { + str, ok := stringFromContext(r.Context()) + if !ok { + s.Logger.Error("malformed middleware. string missing") s.Pages.Error404(w) return } - if len(strings) != 1 { - l.Error("incorrect number of records returned", "len(strings)", len(strings)) - w.WriteHeader(http.StatusInternalServerError) - return - } - string := strings[0] - - if path.Base(r.URL.Path) == "raw" { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - if string.Filename != "" { - w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) - } - w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) - - _, err = w.Write([]byte(string.Contents)) + var cid syntax.CID + var filename string + if str.Cid != nil { + cid = *str.Cid + } else { + var err error + cid, err = s.getRecordCid(r.Context(), str.AtUri()) if err != nil { - l.Error("failed to write raw response", "err", err) + s.Pages.Error404(w) + return } - return } + if str.IsLegacySingleFile() { + filename = str.FileName + } else { + filename = str.Files[0].Name + } + http.Redirect(w, r, fmt.Sprintf("/strings/%s/%s/%s/%s/raw", str.Did, str.Rkey, cid, filename), http.StatusFound) +} + +func (s *Strings) SingleString(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "SingleString") + ctx := r.Context() - var showRendered, renderToggle bool - if markup.GetFormat(string.Filename) == markup.FormatMarkdown { - renderToggle = true - showRendered = r.URL.Query().Get("code") != "true" + string, ok := stringFromContext(ctx) + if !ok { + l.Error("malformed middleware. string missing") + s.Pages.Error404(w) + return } - stringUri := string.AtUri().String() - starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, stringUri) + starCount, err := db.GetStarCount(s.Db, models.StarSubjectString, string.AtUri().String()) if err != nil { l.Error("failed to get star count", "err", err) } user := s.OAuth.GetMultiAccountUser(r) isStarred := false if user != nil { - isStarred = db.GetStarStatus(s.Db, user.Did, stringUri) + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri().String()) } comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri())) @@ -191,70 +221,54 @@ func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { } } + var files []pages.StringFileFragmentParams + + if string.IsLegacySingleFile() { + files = []pages.StringFileFragmentParams{ + s.makeFileFragmentParams(&string, string.FileName, string.FileContent, false), + } + } else { + files = make([]pages.StringFileFragmentParams, len(string.Files)) + for i, file := range string.Files { + // TODO: read blob + content := "" + files[i] = s.makeFileFragmentParams(&string, file.Name, content, false) + } + } + err = s.Pages.SingleString(w, pages.SingleStringParams{ LoggedInUser: user, - RenderToggle: renderToggle, - ShowRendered: showRendered, String: &string, - Stats: string.Stats(), + FileParams: files, IsStarred: isStarred, StarCount: starCount, - Owner: id, CommentList: models.NewCommentList(comments), Reactions: reactions, UserReacted: userReactions, VouchRelationships: vouchRelationships, }) -} - -func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) + if err != nil { + l.Error("failed to render", "err", err) + } } func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { l := s.Logger.With("handler", "edit") + ctx := r.Context() user := s.OAuth.GetMultiAccountUser(r) - id, ok := r.Context().Value("resolvedId").(identity.Identity) + oldString, ok := stringFromContext(ctx) if !ok { - l.Error("malformed middleware") - w.WriteHeader(http.StatusInternalServerError) - return - } - l = l.With("did", id.DID, "handle", id.Handle) - - rkey := chi.URLParam(r, "rkey") - if rkey == "" { - l.Error("malformed url, empty rkey") - w.WriteHeader(http.StatusBadRequest) - return - } - l = l.With("rkey", rkey) - - // get the string currently being edited - all, err := db.GetStrings( - s.Db, - 0, - orm.FilterEq("did", id.DID), - orm.FilterEq("rkey", rkey), - ) - if err != nil { - l.Error("failed to fetch string", "err", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - if len(all) != 1 { - l.Error("incorrect number of records returned", "len(strings)", len(all)) - w.WriteHeader(http.StatusInternalServerError) + l.Error("malformed middleware. string missing") + s.Pages.Error404(w) return } - first := all[0] // verify that the logged in user owns this string - if user.Did != id.DID.String() { - l.Error("unauthorized request", "expected", id.DID, "got", user.Did) + if user.Did != oldString.Did.String() { + l.Error("unauthorized request", "expected", oldString.Did, "got", user.Did) w.WriteHeader(http.StatusUnauthorized) return } @@ -262,17 +276,51 @@ func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // return the form with prefilled fields - s.Pages.PutString(w, pages.PutStringParams{ - LoggedInUser: s.OAuth.GetMultiAccountUser(r), - Action: "edit", - String: first, + var files []pages.StringFileEditFragmentParams + if oldString.IsLegacySingleFile() { + files = []pages.StringFileEditFragmentParams{ + { + Name: oldString.FileName, + Content: oldString.FileContent, + Size: uint64(len(oldString.FileContent)), + }, + } + } else { + files = make([]pages.StringFileEditFragmentParams, len(oldString.Files)) + for i, file := range oldString.Files { + // TODO: read blob + content := "" + files[i] = pages.StringFileEditFragmentParams{ + Name: file.Name, + Content: content, + Size: uint64(file.Blob.Size), + } + } + } + err := s.Pages.EditString(w, pages.EditStringParams{ + LoggedInUser: user, + String: oldString, + FileParams: files, }) + if err != nil { + l.Error("failed to render", "err", err) + } case http.MethodPost: fail := func(msg string, err error) { l.Error(msg, "err", err) s.Pages.Notice(w, "error", msg) } + var title *string + if val := r.FormValue("title"); val != "" { + title = &val + } + + var description *string + if val := r.FormValue("description"); val != "" { + description = &val + } + filename := r.FormValue("filename") if filename == "" { fail("Empty filename.", nil) @@ -281,21 +329,15 @@ func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { content := r.FormValue("content") if content == "" { - fail("Empty contents.", nil) + fail("Empty content.", nil) return } - description := r.FormValue("description") - - // construct new string from form values - entry := models.String{ - Did: first.Did, - Rkey: first.Rkey, - Filename: filename, - Description: description, - Contents: content, - Created: first.Created, - } + newString := oldString + newString.Title = title + newString.Description = description + newString.FileName = filename + newString.FileContent = content client, err := s.OAuth.AuthorizedClient(r) if err != nil { @@ -305,44 +347,43 @@ func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { // first replace the existing record in the PDS var exCid string - if entry.Cid != nil { - exCid = entry.Cid.String() + if newString.Cid != nil { + exCid = oldString.Cid.String() } else { - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey.String()) + cid, err := s.getRecordCid(ctx, oldString.AtUri()) if err != nil { - fail("Failed to get existing record.", err) - return - } - if ex.Cid == nil { - fail("Failed to get existing record.", err) + s.Pages.Error404(w) return } - exCid = *ex.Cid + exCid = cid.String() } - resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(ctx, client, &atproto.RepoPutRecord_Input{ Collection: tangled.StringNSID, - Repo: entry.Did.String(), - Rkey: entry.Rkey.String(), + Repo: newString.Did.String(), + Rkey: newString.Rkey.String(), SwapRecord: &exCid, - Record: &lexutil.LexiconTypeDecoder{Val: entry.AsRecord()}, + Record: &lexutil.LexiconTypeDecoder{Val: newString.AsRecord()}, }) if err != nil { fail("Failed to updated existing record.", err) return } - l := l.With("aturi", resp.Uri) + l = l.With("aturi", resp.Uri) l.Info("edited string") + newString.Cid = new(syntax.CID) + *newString.Cid = syntax.CID(resp.Cid) + // if that went okay, updated the db - if err = db.AddString(s.Db, entry); err != nil { + if err = db.AddString(s.Db, newString); err != nil { fail("Failed to update string.", err) return } - s.Notifier.EditString(r.Context(), &entry) + s.Notifier.EditString(ctx, &newString) // if that went okay, redir to the string - s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", entry.Did, entry.Rkey)) + s.Pages.HxRedirect(w, fmt.Sprintf("/strings/%s/%s", newString.Did, newString.Rkey)) } } @@ -353,16 +394,28 @@ func (s *Strings) create(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - s.Pages.PutString(w, pages.PutStringParams{ - LoggedInUser: s.OAuth.GetMultiAccountUser(r), - Action: "new", + err := s.Pages.NewString(w, pages.NewStringParams{ + LoggedInUser: user, }) + if err != nil { + l.Error("failed to render", "err", err) + } case http.MethodPost: fail := func(msg string, err error) { l.Error(msg, "err", err) s.Pages.Notice(w, "error", msg) } + var title *string + if val := r.FormValue("title"); val != "" { + title = &val + } + + var description *string + if val := r.FormValue("description"); val != "" { + description = &val + } + filename := r.FormValue("filename") if filename == "" { fail("Empty filename.", nil) @@ -371,18 +424,17 @@ func (s *Strings) create(w http.ResponseWriter, r *http.Request) { content := r.FormValue("content") if content == "" { - fail("Empty contents.", nil) + fail("Empty content.", nil) return } - description := r.FormValue("description") - string := models.String{ Did: syntax.DID(user.Did), Rkey: syntax.RecordKey(tid.TID()), - Filename: filename, + Title: title, Description: description, - Contents: content, + FileName: filename, + FileContent: content, Created: time.Now(), } @@ -419,7 +471,7 @@ func (s *Strings) create(w http.ResponseWriter, r *http.Request) { } func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { - l := s.Logger.With("handler", "create") + l := s.Logger.With("handler", "delete") user := s.OAuth.GetMultiAccountUser(r) fail := func(msg string, err error) { l.Error(msg, "err", err) @@ -475,3 +527,145 @@ func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { s.Pages.HxRedirect(w, "/strings/"+user.Did) } + +// FileRaw renders raw file in that specific CID. (strong cache policy) +func (s *Strings) FileRaw(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "FileRaw") + ctx := r.Context() + + string, ok := stringFromContext(ctx) + if !ok { + l.Error("malformed middleware. string missing") + s.Pages.Error404(w) + return + } + filename := chi.URLParam(r, "filename") + + if string.IsLegacySingleFile() { + if filename != string.FileName { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) + w.Header().Set("Content-Length", strconv.Itoa(len(string.FileContent))) + _, err := w.Write([]byte(string.FileContent)) + if err != nil { + l.Error("failed to write raw response", "err", err) + } + } else { + file, ok := string.FileByName(filename) + if !ok { + http.NotFound(w, r) + return + } + + content := "" + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) + w.Header().Set("Content-Length", strconv.FormatInt(file.Blob.Size, 10)) + _, err := w.Write([]byte(content)) + if err != nil { + l.Error("failed to write raw response", "err", err) + } + } +} + +func (s *Strings) makeFileFragmentParams(string *models.String, filename string, content string, forceCode bool) pages.StringFileFragmentParams { + size := len(content) + if size > 8*1024*1024 { // 8 MB + // TODO: show "file too big" page + } + + buf, _ := io.ReadAll(strings.NewReader(content)) + + var lineCount int + var hasNoTrailingEOL bool + if size > 0 { + hasNoTrailingEOL = !bytes.HasSuffix(buf, []byte{'\n'}) + lineCount = bytes.Count(buf, []byte{'\n'}) + if hasNoTrailingEOL { + lineCount++ + } + } + + format := markup.GetFormat(filename) + isMarkup := format == markup.FormatMarkdown + + return pages.StringFileFragmentParams{ + String: string, + Name: filename, + Content: content, + + LineCount: lineCount, + Size: uint64(size), + HasNoTrailingEOL: hasNoTrailingEOL, + HasRenderedToggle: isMarkup, + ShowingRendered: isMarkup, + } +} + +// render each string "file" html fragment +func (s *Strings) FileFragment(w http.ResponseWriter, r *http.Request) { + l := s.Logger.With("handler", "FileFragment") + ctx := r.Context() + + string, ok := stringFromContext(ctx) + if !ok { + l.Error("malformed middleware. string missing") + http.NotFound(w, r) + return + } + filename := chi.URLParam(r, "filename") + forceCode := r.URL.Query().Get("code") == "true" + + var params pages.StringFileFragmentParams + if string.IsLegacySingleFile() { + if filename != string.FileName { + http.NotFound(w, r) + return + } + params = s.makeFileFragmentParams(&string, string.FileName, string.FileContent, forceCode) + } else { + file, ok := string.FileByName(filename) + if !ok { + l.Error("malformed middleware. string missing") + http.NotFound(w, r) + return + } + + // TODO: read blob + content := "" + + params = s.makeFileFragmentParams(&string, file.Name, content, forceCode) + } + s.Pages.StringFileFragment(w, params) +} + +func (s *Strings) FileEditFragment(w http.ResponseWriter, r *http.Request) { + s.Pages.StringFileEditFragment(w) +} + +func (s *Strings) getRecordCid(ctx context.Context, uri syntax.ATURI) (syntax.CID, error) { + ident, err := s.Dir.Lookup(ctx, uri.Authority()) + if err != nil { + return "", err + } + + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} + out, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) + if err != nil { + return "", err + } + if out.Cid == nil { + return "", fmt.Errorf("record CID is empty") + } + + cid, err := syntax.ParseCID(*out.Cid) + if err != nil { + return "", err + } + + return cid, nil +} diff --git a/appview/validator/string.go b/appview/validator/string.go deleted file mode 100644 index 44b72ffe..00000000 --- a/appview/validator/string.go +++ /dev/null @@ -1,27 +0,0 @@ -package validator - -import ( - "errors" - "fmt" - "unicode/utf8" - - "tangled.org/core/appview/models" -) - -func (v *Validator) ValidateString(s *models.String) error { - var err error - - if utf8.RuneCountInString(s.Filename) > 140 { - err = errors.Join(err, fmt.Errorf("filename too long")) - } - - if utf8.RuneCountInString(s.Description) > 280 { - err = errors.Join(err, fmt.Errorf("description too long")) - } - - if len(s.Contents) == 0 { - err = errors.Join(err, fmt.Errorf("contents is empty")) - } - - return err -}