Monorepo for Tangled tangled.org

appview/ogcard: migrate opengraph rendering to external cloudflare workers service #1182

merged opened by eti.tf targeting master from eti.tf/core: eti/opengraph-satori

the previous Go-based opengraph rendering made it difficult to handle dynamic content and was cumbersome to update when redesigning images. this moves rendering to a typescript/satori service on cloudflare workers, making design changes easier while offloading image processing from the main application.

Labels

None yet.

assignee

None yet.

Participants 5
AT URI
at://did:plc:xu5apv6kmu5jp7g5hwdnej42/sh.tangled.repo.pull/3mhga2rjlnd22
+2542 -11168
Diff #0
+1 -1
.air/appview.toml
··· 6 6 bin = "out/appview.out" 7 7 8 8 include_ext = ["go"] 9 - exclude_dir = ["avatar", "camo", "sites", "indexes", "nix", "tmp"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 10 stop_on_error = true
-12
.air/blog.toml
··· 1 - root = "." 2 - tmp_dir = "out" 3 - 4 - [build] 5 - cmd = "go build -o out/blog.out cmd/blog/main.go" 6 - bin = "out/blog.out" 7 - args_bin = ["serve"] 8 - 9 - include_ext = ["go", "html", "md"] 10 - include_dir = ["cmd/blog", "blog"] 11 - exclude_dir = ["nix", "tmp", "out"] 12 - stop_on_error = true
+1 -1
.air/knot.toml
··· 7 7 args_bin = ["server"] 8 8 9 9 include_ext = ["go"] 10 - exclude_dir = ["avatar", "camo", "sites", "indexes", "nix", "tmp"] 10 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 11 11 stop_on_error = true
+1 -1
.air/spindle.toml
··· 6 6 bin = "out/spindle.out" 7 7 8 8 include_ext = ["go"] 9 - exclude_dir = ["avatar", "camo", "sites", "indexes", "nix", "tmp"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 10 stop_on_error = true
-3
.gitignore
··· 16 16 *.rdb 17 17 .envrc 18 18 **/*.bleve 19 - sites/target/ 20 - sites/.wrangler/ 21 19 # Created if following hacking.md 22 20 genjwks.out 23 21 /nix/vm-data 24 - blog/build/
-33
.tangled/workflows/deploy-blog.yml
··· 1 - engine: nixery 2 - when: 3 - - event: push 4 - branch: master 5 - 6 - dependencies: 7 - nixpkgs: 8 - - go 9 - - gcc 10 - - nodejs 11 - - tailwindcss 12 - 13 - steps: 14 - - name: patch static dir 15 - command: | 16 - mkdir -p appview/pages/static 17 - touch appview/pages/static/x 18 - 19 - - name: build blog cmd 20 - command: | 21 - go build -o blog.out ./cmd/blog 22 - 23 - - name: generate css 24 - command: | 25 - tailwindcss -i input.css -o appview/pages/static/tw.css 26 - 27 - - name: build static site 28 - command: | 29 - ./blog.out build 30 - 31 - - name: deploy 32 - command: | 33 - npx --yes wrangler pages deploy --branch master --project-name tangled-blog ./build
-43
appview/bsky/client.go
··· 1 - package bsky 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - bsky "github.com/bluesky-social/indigo/api/bsky" 8 - "github.com/bluesky-social/indigo/xrpc" 9 - "tangled.org/core/appview/models" 10 - "tangled.org/core/consts" 11 - ) 12 - 13 - func FetchPosts(ctx context.Context, c *xrpc.Client, limit int, cursor string) ([]models.BskyPost, string, error) { 14 - resp, err := bsky.FeedGetAuthorFeed(ctx, c, consts.TangledDid, cursor, "posts_no_replies", false, int64(limit)) 15 - if err != nil { 16 - return nil, "", err 17 - } 18 - 19 - var posts []models.BskyPost 20 - for _, feedViewPost := range resp.Feed { 21 - // skip quote posts 22 - if feedViewPost.Post.Embed != nil && feedViewPost.Post.Embed.EmbedRecord_View != nil { 23 - continue 24 - } 25 - 26 - post, err := models.NewBskyPostFromView(feedViewPost.Post) 27 - if err != nil { 28 - log.Println(err) 29 - continue 30 - } 31 - 32 - posts = append(posts, *post) 33 - } 34 - 35 - return posts, stringPtr(resp.Cursor), nil 36 - } 37 - 38 - func stringPtr(s *string) string { 39 - if s == nil { 40 - return "" 41 - } 42 - return *s 43 - }
-65
appview/cloudflare/client.go
··· 1 - package cloudflare 2 - 3 - import ( 4 - "fmt" 5 - 6 - cf "github.com/cloudflare/cloudflare-go/v6" 7 - "github.com/cloudflare/cloudflare-go/v6/option" 8 - 9 - "github.com/aws/aws-sdk-go-v2/aws" 10 - "github.com/aws/aws-sdk-go-v2/credentials" 11 - "github.com/aws/aws-sdk-go-v2/service/s3" 12 - 13 - "tangled.org/core/appview/config" 14 - ) 15 - 16 - // Client holds all cloudflare service clients used by the appview. 17 - type Client struct { 18 - api *cf.Client // scoped to DNS / zone operations 19 - kvAPI *cf.Client // scoped to Workers KV (separate token) 20 - s3 *s3.Client 21 - zone string 22 - kvNS string 23 - bucket string 24 - cfAcct string 25 - } 26 - 27 - func New(c *config.Config) (*Client, error) { 28 - api := cf.NewClient(option.WithAPIToken(c.Cloudflare.ApiToken)) 29 - 30 - kvAPI := cf.NewClient(option.WithAPIToken(c.Cloudflare.KV.ApiToken)) 31 - 32 - // R2 endpoint is S3-compatible, keyed by account id. 33 - r2Endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", c.Cloudflare.AccountId) 34 - 35 - s3Client := s3.New(s3.Options{ 36 - BaseEndpoint: aws.String(r2Endpoint), 37 - Region: "auto", 38 - Credentials: credentials.NewStaticCredentialsProvider( 39 - c.Cloudflare.R2.AccessKeyID, 40 - c.Cloudflare.R2.SecretAccessKey, 41 - "", 42 - ), 43 - UsePathStyle: true, 44 - }) 45 - 46 - return &Client{ 47 - api: api, 48 - kvAPI: kvAPI, 49 - s3: s3Client, 50 - zone: c.Cloudflare.ZoneId, 51 - kvNS: c.Cloudflare.KV.NamespaceId, 52 - bucket: c.Cloudflare.R2.Bucket, 53 - cfAcct: c.Cloudflare.AccountId, 54 - }, nil 55 - } 56 - 57 - // Enabled returns true when the client has enough config to perform site 58 - // operations. Callers should check this before attempting any CF operations 59 - // so the appview degrades gracefully in dev environments without credentials. 60 - func (cl *Client) Enabled() bool { 61 - return cl != nil && 62 - cl.cfAcct != "" && 63 - cl.kvNS != "" && 64 - cl.bucket != "" 65 - }
-61
appview/cloudflare/dns.go
··· 1 - package cloudflare 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - cf "github.com/cloudflare/cloudflare-go/v6" 8 - "github.com/cloudflare/cloudflare-go/v6/dns" 9 - ) 10 - 11 - type DNSRecord struct { 12 - Type string 13 - Name string 14 - Content string 15 - TTL int 16 - Proxied bool 17 - } 18 - 19 - func (cl *Client) CreateDNSRecord(ctx context.Context, record DNSRecord) (string, error) { 20 - var body dns.RecordNewParamsBodyUnion 21 - 22 - switch record.Type { 23 - case "A": 24 - body = dns.ARecordParam{ 25 - Name: cf.F(record.Name), 26 - TTL: cf.F(dns.TTL(record.TTL)), 27 - Type: cf.F(dns.ARecordTypeA), 28 - Content: cf.F(record.Content), 29 - Proxied: cf.F(record.Proxied), 30 - } 31 - case "CNAME": 32 - body = dns.CNAMERecordParam{ 33 - Name: cf.F(record.Name), 34 - TTL: cf.F(dns.TTL(record.TTL)), 35 - Type: cf.F(dns.CNAMERecordTypeCNAME), 36 - Content: cf.F(record.Content), 37 - Proxied: cf.F(record.Proxied), 38 - } 39 - default: 40 - return "", fmt.Errorf("unsupported DNS record type: %s", record.Type) 41 - } 42 - 43 - result, err := cl.api.DNS.Records.New(ctx, dns.RecordNewParams{ 44 - ZoneID: cf.F(cl.zone), 45 - Body: body, 46 - }) 47 - if err != nil { 48 - return "", fmt.Errorf("failed to create DNS record: %w", err) 49 - } 50 - return result.ID, nil 51 - } 52 - 53 - func (cl *Client) DeleteDNSRecord(ctx context.Context, recordID string) error { 54 - _, err := cl.api.DNS.Records.Delete(ctx, recordID, dns.RecordDeleteParams{ 55 - ZoneID: cf.F(cl.zone), 56 - }) 57 - if err != nil { 58 - return fmt.Errorf("failed to delete DNS record: %w", err) 59 - } 60 - return nil 61 - }
-80
appview/cloudflare/kv.go
··· 1 - package cloudflare 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "io" 7 - "strings" 8 - 9 - cf "github.com/cloudflare/cloudflare-go/v6" 10 - "github.com/cloudflare/cloudflare-go/v6/kv" 11 - "github.com/cloudflare/cloudflare-go/v6/shared" 12 - ) 13 - 14 - // KVPut writes or overwrites a single Workers KV entry. 15 - func (cl *Client) KVPut(ctx context.Context, key string, value []byte) error { 16 - _, err := cl.kvAPI.KV.Namespaces.Values.Update(ctx, cl.kvNS, key, kv.NamespaceValueUpdateParams{ 17 - AccountID: cf.F(cl.cfAcct), 18 - Value: cf.F[kv.NamespaceValueUpdateParamsValueUnion](shared.UnionString(value)), 19 - }) 20 - if err != nil { 21 - return fmt.Errorf("writing KV entry %q: %w", key, err) 22 - } 23 - return nil 24 - } 25 - 26 - // KVGet reads a single Workers KV entry. Returns nil, nil if the key does not exist. 27 - func (cl *Client) KVGet(ctx context.Context, key string) ([]byte, error) { 28 - res, err := cl.kvAPI.KV.Namespaces.Values.Get(ctx, cl.kvNS, key, kv.NamespaceValueGetParams{ 29 - AccountID: cf.F(cl.cfAcct), 30 - }) 31 - if err != nil { 32 - // The CF SDK returns a 404 when the key doesn't exist. The error type 33 - // lives in an internal package so we match on the error string instead. 34 - if strings.Contains(err.Error(), "404") && strings.Contains(err.Error(), "key not found") { 35 - return nil, nil 36 - } 37 - return nil, fmt.Errorf("reading KV entry %q: %w", key, err) 38 - } 39 - defer res.Body.Close() 40 - val, err := io.ReadAll(res.Body) 41 - if err != nil { 42 - return nil, fmt.Errorf("reading KV entry body %q: %w", key, err) 43 - } 44 - return val, nil 45 - } 46 - 47 - // KVDelete removes a single Workers KV entry. 48 - // Safe to call even if the entry does not exist. 49 - func (cl *Client) KVDelete(ctx context.Context, key string) error { 50 - _, err := cl.kvAPI.KV.Namespaces.Values.Delete(ctx, cl.kvNS, key, kv.NamespaceValueDeleteParams{ 51 - AccountID: cf.F(cl.cfAcct), 52 - }) 53 - if err != nil { 54 - return fmt.Errorf("deleting KV entry %q: %w", key, err) 55 - } 56 - return nil 57 - } 58 - 59 - // KVDeleteByPrefix removes every Workers KV entry whose key starts with prefix, 60 - // handling pagination automatically. 61 - func (cl *Client) KVDeleteByPrefix(ctx context.Context, prefix string) error { 62 - iter := cl.kvAPI.KV.Namespaces.Keys.ListAutoPaging(ctx, cl.kvNS, kv.NamespaceKeyListParams{ 63 - AccountID: cf.F(cl.cfAcct), 64 - Prefix: cf.F(prefix), 65 - }) 66 - 67 - for iter.Next() { 68 - entry := iter.Current() 69 - if _, err := cl.kvAPI.KV.Namespaces.Values.Delete(ctx, cl.kvNS, entry.Name, kv.NamespaceValueDeleteParams{ 70 - AccountID: cf.F(cl.cfAcct), 71 - }); err != nil { 72 - return fmt.Errorf("deleting KV entry %q: %w", entry.Name, err) 73 - } 74 - } 75 - if err := iter.Err(); err != nil { 76 - return fmt.Errorf("listing KV entries with prefix %q: %w", prefix, err) 77 - } 78 - 79 - return nil 80 - }
-139
appview/cloudflare/r2.go
··· 1 - package cloudflare 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "mime" 8 - "net/http" 9 - "path/filepath" 10 - "strings" 11 - 12 - "github.com/aws/aws-sdk-go-v2/aws" 13 - "github.com/aws/aws-sdk-go-v2/service/s3" 14 - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" 15 - ) 16 - 17 - // SyncFiles uploads the given files (keyed by relative path) to R2 under 18 - // prefix and deletes any objects from a previous deploy that are no longer 19 - // present. It is a pure R2 operation — callers are responsible for 20 - // constructing the prefix and fetching the source files before calling this. 21 - func (cl *Client) SyncFiles(ctx context.Context, prefix string, files map[string][]byte) error { 22 - existingKeys, err := cl.listR2Objects(ctx, prefix) 23 - if err != nil { 24 - return fmt.Errorf("listing existing R2 objects: %w", err) 25 - } 26 - 27 - for relPath, content := range files { 28 - key := prefix + relPath 29 - _, err := cl.s3.PutObject(ctx, &s3.PutObjectInput{ 30 - Bucket: aws.String(cl.bucket), 31 - Key: aws.String(key), 32 - Body: bytes.NewReader(content), 33 - ContentType: aws.String(DetectContentType(relPath, content)), 34 - }) 35 - if err != nil { 36 - return fmt.Errorf("uploading %q: %w", key, err) 37 - } 38 - } 39 - 40 - for existingKey := range existingKeys { 41 - relPath := strings.TrimPrefix(existingKey, prefix) 42 - if _, kept := files[relPath]; !kept { 43 - if err := cl.deleteR2Object(ctx, existingKey); err != nil { 44 - return fmt.Errorf("deleting orphan %q: %w", existingKey, err) 45 - } 46 - } 47 - } 48 - 49 - return nil 50 - } 51 - 52 - // DeleteFiles removes all R2 objects under the given prefix. 53 - func (cl *Client) DeleteFiles(ctx context.Context, prefix string) error { 54 - keys, err := cl.listR2Objects(ctx, prefix) 55 - if err != nil { 56 - return fmt.Errorf("listing R2 objects for deletion: %w", err) 57 - } 58 - for key := range keys { 59 - if err := cl.deleteR2Object(ctx, key); err != nil { 60 - return fmt.Errorf("deleting %q: %w", key, err) 61 - } 62 - } 63 - return nil 64 - } 65 - 66 - // listR2Objects returns all object keys in the bucket under the given prefix, 67 - // handling pagination automatically. 68 - func (cl *Client) listR2Objects(ctx context.Context, prefix string) (map[string]struct{}, error) { 69 - keys := make(map[string]struct{}) 70 - var continuationToken *string 71 - 72 - for { 73 - out, err := cl.s3.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 74 - Bucket: aws.String(cl.bucket), 75 - Prefix: aws.String(prefix), 76 - ContinuationToken: continuationToken, 77 - }) 78 - if err != nil { 79 - return nil, err 80 - } 81 - 82 - for _, obj := range out.Contents { 83 - if obj.Key != nil { 84 - keys[*obj.Key] = struct{}{} 85 - } 86 - } 87 - 88 - if !aws.ToBool(out.IsTruncated) { 89 - break 90 - } 91 - continuationToken = out.NextContinuationToken 92 - } 93 - 94 - return keys, nil 95 - } 96 - 97 - func (cl *Client) deleteR2Object(ctx context.Context, key string) error { 98 - _, err := cl.s3.DeleteObject(ctx, &s3.DeleteObjectInput{ 99 - Bucket: aws.String(cl.bucket), 100 - Key: aws.String(key), 101 - }) 102 - return err 103 - } 104 - 105 - // deleteBatch deletes up to 1000 objects in a single call. 106 - // Unused for now, kept for future bulk-delete optimisation. 107 - func (cl *Client) deleteBatch(ctx context.Context, keys []string) error { 108 - if len(keys) == 0 { 109 - return nil 110 - } 111 - 112 - var objects []s3types.ObjectIdentifier 113 - for _, k := range keys { 114 - k := k 115 - objects = append(objects, s3types.ObjectIdentifier{Key: &k}) 116 - } 117 - 118 - _, err := cl.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{ 119 - Bucket: aws.String(cl.bucket), 120 - Delete: &s3types.Delete{Objects: objects}, 121 - }) 122 - return err 123 - } 124 - 125 - // DetectContentType guesses the MIME type from the file extension, falling 126 - // back to sniffing the first 512 bytes of content. 127 - func DetectContentType(relPath string, content []byte) string { 128 - if ext := filepath.Ext(relPath); ext != "" { 129 - if mt := mime.TypeByExtension(ext); mt != "" { 130 - return mt 131 - } 132 - } 133 - 134 - sniff := content 135 - if len(sniff) > 512 { 136 - sniff = sniff[:512] 137 - } 138 - return http.DetectContentType(sniff) 139 - }
+7 -39
appview/config/config.go
··· 4 4 "context" 5 5 "fmt" 6 6 "net/url" 7 - "strings" 8 7 "time" 9 8 10 9 "github.com/sethvargo/go-envconfig" ··· 89 88 AdminSecret string `env:"ADMIN_SECRET"` 90 89 } 91 90 92 - func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { 93 - return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") 94 - } 95 - 96 - type R2Config struct { 97 - AccessKeyID string `env:"ACCESS_KEY_ID"` 98 - SecretAccessKey string `env:"SECRET_ACCESS_KEY"` 99 - Bucket string `env:"BUCKET, default=tangled-sites"` 100 - } 101 - 102 - type TurnstileConfig struct { 103 - SiteKey string `env:"SITE_KEY"` 104 - SecretKey string `env:"SECRET_KEY"` 105 - } 106 - 107 - type KVConfig struct { 108 - NamespaceId string `env:"NAMESPACE_ID"` 109 - ApiToken string `env:"API_TOKEN"` 110 - } 111 - 112 91 type Cloudflare struct { 113 - // Legacy top-level API token. For services like Workers KV, we 114 - // now use a scoped Account API token configured under the relevant 115 - // sub-struct. 116 - ApiToken string `env:"API_TOKEN"` 117 - ZoneId string `env:"ZONE_ID"` 118 - AccountId string `env:"ACCOUNT_ID"` 119 - 120 - KV KVConfig `env:",prefix=KV_"` 121 - Turnstile TurnstileConfig `env:",prefix=TURNSTILE_"` 122 - R2 R2Config `env:",prefix=R2_"` 123 - } 124 - 125 - type SitesConfig struct { 126 - Domain string `env:"DOMAIN, default=tngl.io"` 92 + ApiToken string `env:"API_TOKEN"` 93 + ZoneId string `env:"ZONE_ID"` 94 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 95 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 127 96 } 128 97 129 98 type LabelConfig struct { ··· 131 100 GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 132 101 } 133 102 134 - type BlueskyConfig struct { 135 - UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` 103 + type OgcardConfig struct { 104 + Host string `env:"HOST, default=https://og.tangled.org"` 136 105 } 137 106 138 107 func (cfg RedisConfig) ToURL() string { ··· 164 133 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 165 134 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 166 135 Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 167 - Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` 168 - Sites SitesConfig `env:",prefix=TANGLED_SITES_"` 136 + Ogcard OgcardConfig `env:",prefix=TANGLED_OGCARD_"` 169 137 } 170 138 171 139 func LoadConfig(ctx context.Context) (*Config, error) {
-116
appview/db/bsky.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "encoding/json" 6 - "time" 7 - 8 - "tangled.org/core/appview/models" 9 - ) 10 - 11 - func InsertBlueskyPosts(e Execer, posts []models.BskyPost) error { 12 - if len(posts) == 0 { 13 - return nil 14 - } 15 - 16 - stmt, err := e.Prepare(` 17 - insert or replace into bluesky_posts (rkey, text, created_at, langs, facets, embed, like_count, reply_count, repost_count, quote_count) 18 - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 19 - `) 20 - if err != nil { 21 - return err 22 - } 23 - defer stmt.Close() 24 - 25 - for _, post := range posts { 26 - var langsJSON, facetsJSON, embedJSON []byte 27 - 28 - if len(post.Langs) > 0 { 29 - langsJSON, _ = json.Marshal(post.Langs) 30 - } 31 - if len(post.Facets) > 0 { 32 - facetsJSON, _ = json.Marshal(post.Facets) 33 - } 34 - if post.Embed != nil { 35 - embedJSON, _ = json.Marshal(post.Embed) 36 - } 37 - 38 - _, err := stmt.Exec( 39 - post.Rkey, 40 - post.Text, 41 - post.CreatedAt.Format(time.RFC3339), 42 - nullString(langsJSON), 43 - nullString(facetsJSON), 44 - nullString(embedJSON), 45 - post.LikeCount, 46 - post.ReplyCount, 47 - post.RepostCount, 48 - post.QuoteCount, 49 - ) 50 - if err != nil { 51 - return err 52 - } 53 - } 54 - 55 - return nil 56 - } 57 - 58 - func nullString(b []byte) any { 59 - if len(b) == 0 { 60 - return nil 61 - } 62 - return string(b) 63 - } 64 - 65 - func GetBlueskyPosts(e Execer, limit int) ([]models.BskyPost, error) { 66 - query := ` 67 - select rkey, text, created_at, langs, facets, embed, like_count, reply_count, repost_count, quote_count 68 - from bluesky_posts 69 - order by created_at desc 70 - limit ? 71 - ` 72 - 73 - rows, err := e.Query(query, limit) 74 - if err != nil { 75 - return nil, err 76 - } 77 - defer rows.Close() 78 - 79 - var posts []models.BskyPost 80 - for rows.Next() { 81 - var rkey, text, createdAt string 82 - var langs, facets, embed sql.Null[string] 83 - var likeCount, replyCount, repostCount, quoteCount int64 84 - 85 - err := rows.Scan(&rkey, &text, &createdAt, &langs, &facets, &embed, &likeCount, &replyCount, &repostCount, &quoteCount) 86 - if err != nil { 87 - return nil, err 88 - } 89 - 90 - post := models.BskyPost{ 91 - Rkey: rkey, 92 - Text: text, 93 - LikeCount: likeCount, 94 - ReplyCount: replyCount, 95 - RepostCount: repostCount, 96 - QuoteCount: quoteCount, 97 - } 98 - 99 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 100 - post.CreatedAt = t 101 - } 102 - if langs.Valid && langs.V != "" { 103 - json.Unmarshal([]byte(langs.V), &post.Langs) 104 - } 105 - if facets.Valid && facets.V != "" { 106 - json.Unmarshal([]byte(facets.V), &post.Facets) 107 - } 108 - if embed.Valid && embed.V != "" { 109 - json.Unmarshal([]byte(embed.V), &post.Embed) 110 - } 111 - 112 - posts = append(posts, post) 113 - } 114 - 115 - return posts, rows.Err() 116 - }
-45
appview/db/db.go
··· 596 596 foreign key (webhook_id) references webhooks(id) on delete cascade 597 597 ); 598 598 599 - create table if not exists bluesky_posts ( 600 - rkey text primary key, 601 - text text not null, 602 - created_at text not null, 603 - langs text, 604 - facets text, 605 - embed text, 606 - like_count integer not null default 0, 607 - reply_count integer not null default 0, 608 - repost_count integer not null default 0, 609 - quote_count integer not null default 0 610 - ); 611 - 612 - create table if not exists domain_claims ( 613 - id integer primary key autoincrement, 614 - did text not null unique, 615 - domain text not null unique, 616 - deleted text -- timestamp when the domain was released/unclaimed; null means actively claimed 617 - ); 618 - 619 - create table if not exists repo_sites ( 620 - id integer primary key autoincrement, 621 - repo_at text not null unique, 622 - branch text not null, 623 - dir text not null default '/', 624 - is_index integer not null default 0, 625 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 626 - updated text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 627 - foreign key (repo_at) references repos(at_uri) on delete cascade 628 - ); 629 - 630 - create table if not exists site_deploys ( 631 - id integer primary key autoincrement, 632 - repo_at text not null, 633 - branch text not null, 634 - dir text not null default '/', 635 - commit_sha text not null default '', 636 - status text not null check (status in ('success', 'failure')), 637 - trigger text not null check (trigger in ('config_change', 'push')), 638 - error text not null default '', 639 - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 640 - foreign key (repo_at) references repos(at_uri) on delete cascade 641 - ); 642 - 643 599 create table if not exists migrations ( 644 600 id integer primary key autoincrement, 645 601 name text unique ··· 659 615 create index if not exists idx_references_to_at on reference_links(to_at); 660 616 create index if not exists idx_webhooks_repo_at on webhooks(repo_at); 661 617 create index if not exists idx_webhook_deliveries_webhook_id on webhook_deliveries(webhook_id); 662 - create index if not exists idx_site_deploys_repo_at on site_deploys(repo_at); 663 618 `) 664 619 if err != nil { 665 620 return nil, err
-102
appview/db/site_deploys.go
··· 1 - package db 2 - 3 - import ( 4 - "fmt" 5 - "time" 6 - 7 - "tangled.org/core/appview/models" 8 - ) 9 - 10 - // AddSiteDeploy records a site deploy attempt. 11 - func AddSiteDeploy(e Execer, deploy *models.SiteDeploy) error { 12 - result, err := e.Exec(` 13 - insert into site_deploys ( 14 - repo_at, 15 - branch, 16 - dir, 17 - commit_sha, 18 - status, 19 - trigger, 20 - error 21 - ) values (?, ?, ?, ?, ?, ?, ?) 22 - `, 23 - deploy.RepoAt, 24 - deploy.Branch, 25 - deploy.Dir, 26 - deploy.CommitSHA, 27 - string(deploy.Status), 28 - string(deploy.Trigger), 29 - deploy.Error, 30 - ) 31 - if err != nil { 32 - return fmt.Errorf("failed to insert site deploy: %w", err) 33 - } 34 - 35 - id, err := result.LastInsertId() 36 - if err != nil { 37 - return fmt.Errorf("failed to get site deploy id: %w", err) 38 - } 39 - 40 - deploy.Id = id 41 - return nil 42 - } 43 - 44 - // GetSiteDeploys returns recent deploy records for a repository, newest first. 45 - func GetSiteDeploys(e Execer, repoAt string, limit int) ([]models.SiteDeploy, error) { 46 - if limit <= 0 { 47 - limit = 20 48 - } 49 - 50 - rows, err := e.Query(` 51 - select 52 - id, 53 - repo_at, 54 - branch, 55 - dir, 56 - commit_sha, 57 - status, 58 - trigger, 59 - error, 60 - created_at 61 - from site_deploys 62 - where repo_at = ? 63 - order by created_at desc 64 - limit ? 65 - `, repoAt, limit) 66 - if err != nil { 67 - return nil, fmt.Errorf("failed to query site deploys: %w", err) 68 - } 69 - defer rows.Close() 70 - 71 - var deploys []models.SiteDeploy 72 - for rows.Next() { 73 - var d models.SiteDeploy 74 - var createdAt string 75 - 76 - if err := rows.Scan( 77 - &d.Id, 78 - &d.RepoAt, 79 - &d.Branch, 80 - &d.Dir, 81 - &d.CommitSHA, 82 - &d.Status, 83 - &d.Trigger, 84 - &d.Error, 85 - &createdAt, 86 - ); err != nil { 87 - return nil, fmt.Errorf("failed to scan site deploy: %w", err) 88 - } 89 - 90 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 91 - d.CreatedAt = t 92 - } 93 - 94 - deploys = append(deploys, d) 95 - } 96 - 97 - if err := rows.Err(); err != nil { 98 - return nil, fmt.Errorf("failed to iterate site deploys: %w", err) 99 - } 100 - 101 - return deploys, nil 102 - }
-270
appview/db/sites.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "errors" 6 - "fmt" 7 - "time" 8 - 9 - "tangled.org/core/appview/models" 10 - ) 11 - 12 - var ( 13 - ErrDomainTaken = errors.New("domain is already claimed by another user") 14 - ErrDomainCooldown = errors.New("domain is in a 30-day cooldown period after being released") 15 - ErrAlreadyClaimed = errors.New("you already have an active domain claim; release it before claiming a new one") 16 - ) 17 - 18 - func scanClaim(row *sql.Row) (*models.DomainClaim, error) { 19 - var claim models.DomainClaim 20 - var deletedStr sql.NullString 21 - 22 - if err := row.Scan(&claim.ID, &claim.Did, &claim.Domain, &deletedStr); err != nil { 23 - return nil, err 24 - } 25 - 26 - if deletedStr.Valid { 27 - t, err := time.Parse(time.RFC3339, deletedStr.String) 28 - if err != nil { 29 - return nil, fmt.Errorf("parsing deleted timestamp: %w", err) 30 - } 31 - claim.Deleted = &t 32 - } 33 - 34 - return &claim, nil 35 - } 36 - 37 - func GetDomainClaimByDomain(e Execer, domain string) (*models.DomainClaim, error) { 38 - row := e.QueryRow(` 39 - select id, did, domain, deleted 40 - from domain_claims 41 - where domain = ? 42 - `, domain) 43 - return scanClaim(row) 44 - } 45 - 46 - func GetActiveDomainClaimForDid(e Execer, did string) (*models.DomainClaim, error) { 47 - row := e.QueryRow(` 48 - select id, did, domain, deleted 49 - from domain_claims 50 - where did = ? and deleted is null 51 - `, did) 52 - return scanClaim(row) 53 - } 54 - 55 - func GetDomainClaimForDid(e Execer, did string) (*models.DomainClaim, error) { 56 - row := e.QueryRow(` 57 - select id, did, domain, deleted 58 - from domain_claims 59 - where did = ? 60 - `, did) 61 - return scanClaim(row) 62 - } 63 - 64 - func ClaimDomain(e Execer, did, domain string) error { 65 - const cooldown = 30 * 24 * time.Hour 66 - 67 - domainRow, err := GetDomainClaimByDomain(e, domain) 68 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 69 - return fmt.Errorf("looking up domain: %w", err) 70 - } 71 - 72 - if domainRow != nil { 73 - if domainRow.Did == did { 74 - if domainRow.Deleted == nil { 75 - return nil 76 - } 77 - if time.Since(*domainRow.Deleted) < cooldown { 78 - return ErrDomainCooldown 79 - } 80 - _, err = e.Exec(` 81 - update domain_claims set deleted = null where did = ? and domain = ? 82 - `, did, domain) 83 - return err 84 - } 85 - 86 - if domainRow.Deleted == nil { 87 - return ErrDomainTaken 88 - } 89 - if time.Since(*domainRow.Deleted) < cooldown { 90 - return ErrDomainCooldown 91 - } 92 - 93 - if _, err = e.Exec(`delete from domain_claims where domain = ?`, domain); err != nil { 94 - return fmt.Errorf("clearing expired domain row: %w", err) 95 - } 96 - } 97 - 98 - didRow, err := GetDomainClaimForDid(e, did) 99 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 100 - return fmt.Errorf("looking up DID claim: %w", err) 101 - } 102 - 103 - if didRow == nil { 104 - _, err = e.Exec(` 105 - insert into domain_claims (did, domain) values (?, ?) 106 - `, did, domain) 107 - return err 108 - } 109 - 110 - if didRow.Deleted == nil { 111 - return ErrAlreadyClaimed 112 - } 113 - 114 - _, err = e.Exec(` 115 - update domain_claims set domain = ?, deleted = null where did = ? 116 - `, domain, did) 117 - return err 118 - } 119 - 120 - func ReleaseDomain(e Execer, did, domain string) error { 121 - result, err := e.Exec(` 122 - update domain_claims 123 - set deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 124 - where did = ? and domain = ? and deleted is null 125 - `, did, domain) 126 - if err != nil { 127 - return err 128 - } 129 - 130 - n, err := result.RowsAffected() 131 - if err != nil { 132 - return err 133 - } 134 - if n == 0 { 135 - return errors.New("domain not found or not actively claimed by this account") 136 - } 137 - return nil 138 - } 139 - 140 - // GetRepoSiteConfig returns the site configuration for a repo, or nil if not configured. 141 - func GetRepoSiteConfig(e Execer, repoAt string) (*models.RepoSite, error) { 142 - row := e.QueryRow(` 143 - select id, repo_at, branch, dir, is_index, created, updated 144 - from repo_sites 145 - where repo_at = ? 146 - `, repoAt) 147 - 148 - var s models.RepoSite 149 - var isIndex int 150 - var createdStr, updatedStr string 151 - 152 - err := row.Scan(&s.ID, &s.RepoAt, &s.Branch, &s.Dir, &isIndex, &createdStr, &updatedStr) 153 - if errors.Is(err, sql.ErrNoRows) { 154 - return nil, nil 155 - } 156 - if err != nil { 157 - return nil, err 158 - } 159 - 160 - s.IsIndex = isIndex != 0 161 - 162 - s.Created, err = time.Parse(time.RFC3339, createdStr) 163 - if err != nil { 164 - return nil, fmt.Errorf("parsing created timestamp: %w", err) 165 - } 166 - 167 - s.Updated, err = time.Parse(time.RFC3339, updatedStr) 168 - if err != nil { 169 - return nil, fmt.Errorf("parsing updated timestamp: %w", err) 170 - } 171 - 172 - return &s, nil 173 - } 174 - 175 - // SetRepoSiteConfig inserts or replaces the site configuration for a repo. 176 - func SetRepoSiteConfig(e Execer, repoAt, branch, dir string, isIndex bool) error { 177 - isIndexInt := 0 178 - if isIndex { 179 - isIndexInt = 1 180 - } 181 - 182 - _, err := e.Exec(` 183 - insert into repo_sites (repo_at, branch, dir, is_index, updated) 184 - values (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 185 - on conflict(repo_at) do update set 186 - branch = excluded.branch, 187 - dir = excluded.dir, 188 - is_index = excluded.is_index, 189 - updated = excluded.updated 190 - `, repoAt, branch, dir, isIndexInt) 191 - return err 192 - } 193 - 194 - // DeleteRepoSiteConfig removes the site configuration for a repo. 195 - func DeleteRepoSiteConfig(e Execer, repoAt string) error { 196 - _, err := e.Exec(`delete from repo_sites where repo_at = ?`, repoAt) 197 - return err 198 - } 199 - 200 - // GetRepoSiteConfigsForDid returns all site configurations for repos owned by a DID. 201 - // RepoName is populated on each returned RepoSite. 202 - func GetRepoSiteConfigsForDid(e Execer, did string) ([]*models.RepoSite, error) { 203 - rows, err := e.Query(` 204 - select rs.id, rs.repo_at, r.name, rs.branch, rs.dir, rs.is_index, rs.created, rs.updated 205 - from repo_sites rs 206 - join repos r on r.at_uri = rs.repo_at 207 - where r.did = ? 208 - `, did) 209 - if err != nil { 210 - return nil, err 211 - } 212 - defer rows.Close() 213 - 214 - var sites []*models.RepoSite 215 - for rows.Next() { 216 - var s models.RepoSite 217 - var isIndex int 218 - var createdStr, updatedStr string 219 - if err := rows.Scan(&s.ID, &s.RepoAt, &s.RepoName, &s.Branch, &s.Dir, &isIndex, &createdStr, &updatedStr); err != nil { 220 - return nil, err 221 - } 222 - s.IsIndex = isIndex != 0 223 - s.Created, err = time.Parse(time.RFC3339, createdStr) 224 - if err != nil { 225 - return nil, fmt.Errorf("parsing created timestamp: %w", err) 226 - } 227 - s.Updated, err = time.Parse(time.RFC3339, updatedStr) 228 - if err != nil { 229 - return nil, fmt.Errorf("parsing updated timestamp: %w", err) 230 - } 231 - sites = append(sites, &s) 232 - } 233 - return sites, rows.Err() 234 - } 235 - 236 - // DeleteRepoSiteConfigsForDid removes all site configurations for repos owned by a DID. 237 - func DeleteRepoSiteConfigsForDid(e Execer, did string) error { 238 - _, err := e.Exec(` 239 - delete from repo_sites 240 - where repo_at in ( 241 - select at_uri from repos where did = ? 242 - ) 243 - `, did) 244 - return err 245 - } 246 - 247 - // GetIndexRepoAtForDid returns the repo_at of the repo that currently holds 248 - // is_index=1 for the given DID, excluding excludeRepoAt (the current repo). 249 - // Returns "", nil if no other repo is the index site. 250 - func GetIndexRepoAtForDid(e Execer, did, excludeRepoAt string) (string, error) { 251 - row := e.QueryRow(` 252 - select rs.repo_at 253 - from repo_sites rs 254 - join repos r on r.at_uri = rs.repo_at 255 - where r.did = ? 256 - and rs.is_index = 1 257 - and rs.repo_at != ? 258 - limit 1 259 - `, did, excludeRepoAt) 260 - 261 - var repoAt string 262 - err := row.Scan(&repoAt) 263 - if errors.Is(err, sql.ErrNoRows) { 264 - return "", nil 265 - } 266 - if err != nil { 267 - return "", err 268 - } 269 - return repoAt, nil 270 - }
+53
appview/dns/cloudflare.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/cloudflare/cloudflare-go" 8 + "tangled.org/core/appview/config" 9 + ) 10 + 11 + type Record struct { 12 + Type string 13 + Name string 14 + Content string 15 + TTL int 16 + Proxied bool 17 + } 18 + 19 + type Cloudflare struct { 20 + api *cloudflare.API 21 + zone string 22 + } 23 + 24 + func NewCloudflare(c *config.Config) (*Cloudflare, error) { 25 + apiToken := c.Cloudflare.ApiToken 26 + api, err := cloudflare.NewWithAPIToken(apiToken) 27 + if err != nil { 28 + return nil, err 29 + } 30 + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 + } 32 + 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 + Type: record.Type, 36 + Name: record.Name, 37 + Content: record.Content, 38 + TTL: record.TTL, 39 + Proxied: &record.Proxied, 40 + }) 41 + if err != nil { 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 + } 44 + return result.ID, nil 45 + } 46 + 47 + func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { 48 + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) 49 + if err != nil { 50 + return fmt.Errorf("failed to delete DNS record: %w", err) 51 + } 52 + return nil 53 + }
+4 -7
appview/filetree/filetree.go
··· 10 10 Name string 11 11 Path string 12 12 IsDirectory bool 13 - Level int 14 13 Children map[string]*FileTreeNode 15 14 } 16 15 17 - // newNode creates a new node 18 - func newNode(name, path string, isDir bool, level int) *FileTreeNode { 16 + // NewNode creates a new node 17 + func newNode(name, path string, isDir bool) *FileTreeNode { 19 18 return &FileTreeNode{ 20 19 Name: name, 21 20 Path: path, 22 21 IsDirectory: isDir, 23 - Level: level, 24 22 Children: make(map[string]*FileTreeNode), 25 23 } 26 24 } 27 25 28 26 func FileTree(files []string) *FileTreeNode { 29 - rootNode := newNode("", "", true, 0) 27 + rootNode := newNode("", "", true) 30 28 31 29 sort.Strings(files) 32 30 ··· 51 49 } 52 50 53 51 isDir := i < len(parts)-1 54 - level := i + 1 55 52 56 53 if _, exists := currentNode.Children[part]; !exists { 57 - currentNode.Children[part] = newNode(part, currentPath, isDir, level) 54 + currentNode.Children[part] = newNode(part, currentPath, isDir) 58 55 } 59 56 60 57 currentNode = currentNode.Children[part]
+20 -31
appview/indexer/issues/indexer.go
··· 18 18 "github.com/blevesearch/bleve/v2/search/query" 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/indexer/base36" 21 - bleveutil "tangled.org/core/appview/indexer/bleve" 21 + "tangled.org/core/appview/indexer/bleve" 22 22 "tangled.org/core/appview/models" 23 23 "tangled.org/core/appview/pagination" 24 24 tlog "tangled.org/core/log" ··· 31 31 unicodeNormalizeName = "uicodeNormalize" 32 32 33 33 // Bump this when the index mapping changes to trigger a rebuild. 34 - issueIndexerVersion = 3 34 + issueIndexerVersion = 2 35 35 ) 36 36 37 37 type Indexer struct { ··· 89 89 docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 90 90 docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 91 91 docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 92 - docMapping.AddFieldMappingsAt("label_values", keywordFieldMapping) 93 92 94 93 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 95 94 "type": unicodenorm.Name, ··· 184 183 } 185 184 186 185 type issueData struct { 187 - ID int64 `json:"id"` 188 - RepoAt string `json:"repo_at"` 189 - IssueID int `json:"issue_id"` 190 - Title string `json:"title"` 191 - Body string `json:"body"` 192 - IsOpen bool `json:"is_open"` 193 - AuthorDid string `json:"author_did"` 194 - Labels []string `json:"labels"` 195 - LabelValues []string `json:"label_values"` 186 + ID int64 `json:"id"` 187 + RepoAt string `json:"repo_at"` 188 + IssueID int `json:"issue_id"` 189 + Title string `json:"title"` 190 + Body string `json:"body"` 191 + IsOpen bool `json:"is_open"` 192 + AuthorDid string `json:"author_did"` 193 + Labels []string `json:"labels"` 196 194 197 195 Comments []IssueCommentData `json:"comments"` 198 196 } 199 197 200 198 func makeIssueData(issue *models.Issue) *issueData { 201 199 return &issueData{ 202 - ID: issue.Id, 203 - RepoAt: issue.RepoAt.String(), 204 - IssueID: issue.IssueId, 205 - Title: issue.Title, 206 - Body: issue.Body, 207 - IsOpen: issue.Open, 208 - AuthorDid: issue.Did, 209 - Labels: issue.Labels.LabelNames(), 210 - LabelValues: issue.Labels.LabelNameValues(), 200 + ID: issue.Id, 201 + RepoAt: issue.RepoAt.String(), 202 + IssueID: issue.IssueId, 203 + Title: issue.Title, 204 + Body: issue.Body, 205 + IsOpen: issue.Open, 206 + AuthorDid: issue.Did, 207 + Labels: issue.Labels.LabelNames(), 211 208 } 212 209 } 213 210 ··· 287 284 musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 288 285 } 289 286 290 - for _, did := range opts.NegatedAuthorDids { 291 - mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", did)) 287 + if opts.NegatedAuthorDid != "" { 288 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 292 289 } 293 290 294 291 for _, label := range opts.NegatedLabels { 295 292 mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 296 - } 297 - 298 - for _, lv := range opts.LabelValues { 299 - musts = append(musts, bleveutil.KeywordFieldQuery("label_values", lv)) 300 - } 301 - 302 - for _, lv := range opts.NegatedLabelValues { 303 - mustNots = append(mustNots, bleveutil.KeywordFieldQuery("label_values", lv)) 304 293 } 305 294 306 295 indexerQuery := bleve.NewBooleanQuery()
+4 -84
appview/indexer/issues/indexer_test.go
··· 3 3 import ( 4 4 "context" 5 5 "os" 6 - "strings" 7 6 "testing" 8 7 9 8 "github.com/blevesearch/bleve/v2" ··· 39 38 40 39 func boolPtr(b bool) *bool { return &b } 41 40 42 - // makeLabelState creates a LabelState for testing. Each entry is either 43 - // "name" (null-type label) or "name=val" (valued label). 44 - func makeLabelState(entries ...string) models.LabelState { 41 + func makeLabelState(labels ...string) models.LabelState { 45 42 state := models.NewLabelState() 46 - for _, entry := range entries { 47 - if eqIdx := strings.Index(entry, "="); eqIdx > 0 { 48 - name := entry[:eqIdx] 49 - val := entry[eqIdx+1:] 50 - if state.Inner()[name] == nil { 51 - state.Inner()[name] = make(map[string]struct{}) 52 - } 53 - state.Inner()[name][val] = struct{}{} 54 - state.SetName(name, name) 55 - } else { 56 - state.Inner()[entry] = make(map[string]struct{}) 57 - state.SetName(entry, entry) 58 - } 43 + for _, label := range labels { 44 + state.Inner()[label] = make(map[string]struct{}) 45 + state.SetName(label, label) 59 46 } 60 47 return state 61 48 } ··· 275 262 assert.Equal(t, uint64(0), result.Total) 276 263 assert.Empty(t, result.Hits) 277 264 } 278 - 279 - func TestSearchLabelValues(t *testing.T) { 280 - ix, cleanup := setupTestIndexer(t) 281 - defer cleanup() 282 - 283 - ctx := context.Background() 284 - 285 - err := ix.Index(ctx, 286 - models.Issue{Id: 1, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "High priority bug", Body: "Urgent", Open: true, Did: "did:plc:alice", 287 - Labels: makeLabelState("bug", "priority=high")}, 288 - models.Issue{Id: 2, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "Low priority feature", Body: "Nice to have", Open: true, Did: "did:plc:bob", 289 - Labels: makeLabelState("feature", "priority=low")}, 290 - models.Issue{Id: 3, RepoAt: "at://did:plc:test/sh.tangled.repo/abc", Title: "High priority feature", Body: "Important", Open: true, Did: "did:plc:alice", 291 - Labels: makeLabelState("feature", "priority=high")}, 292 - ) 293 - require.NoError(t, err) 294 - 295 - opts := func() models.IssueSearchOptions { 296 - return models.IssueSearchOptions{ 297 - RepoAt: "at://did:plc:test/sh.tangled.repo/abc", 298 - IsOpen: boolPtr(true), 299 - Page: pagination.Page{Limit: 10}, 300 - } 301 - } 302 - 303 - o := opts() 304 - o.LabelValues = []string{"priority:high"} 305 - result, err := ix.Search(ctx, o) 306 - require.NoError(t, err) 307 - assert.Equal(t, uint64(2), result.Total) 308 - assert.Contains(t, result.Hits, int64(1)) 309 - assert.Contains(t, result.Hits, int64(3)) 310 - 311 - o = opts() 312 - o.LabelValues = []string{"priority:low"} 313 - result, err = ix.Search(ctx, o) 314 - require.NoError(t, err) 315 - assert.Equal(t, uint64(1), result.Total) 316 - assert.Contains(t, result.Hits, int64(2)) 317 - 318 - // Combined: plain label + label value 319 - o = opts() 320 - o.Labels = []string{"feature"} 321 - o.LabelValues = []string{"priority:high"} 322 - result, err = ix.Search(ctx, o) 323 - require.NoError(t, err) 324 - assert.Equal(t, uint64(1), result.Total) 325 - assert.Contains(t, result.Hits, int64(3)) 326 - 327 - // Negated label value 328 - o = opts() 329 - o.NegatedLabelValues = []string{"priority:low"} 330 - result, err = ix.Search(ctx, o) 331 - require.NoError(t, err) 332 - assert.Equal(t, uint64(2), result.Total) 333 - assert.Contains(t, result.Hits, int64(1)) 334 - assert.Contains(t, result.Hits, int64(3)) 335 - 336 - // Label value + negated plain label 337 - o = opts() 338 - o.LabelValues = []string{"priority:high"} 339 - o.NegatedLabels = []string{"feature"} 340 - result, err = ix.Search(ctx, o) 341 - require.NoError(t, err) 342 - assert.Equal(t, uint64(1), result.Total) 343 - assert.Contains(t, result.Hits, int64(1)) 344 - }
+20 -31
appview/indexer/pulls/indexer.go
··· 18 18 "github.com/blevesearch/bleve/v2/search/query" 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/indexer/base36" 21 - bleveutil "tangled.org/core/appview/indexer/bleve" 21 + "tangled.org/core/appview/indexer/bleve" 22 22 "tangled.org/core/appview/models" 23 23 tlog "tangled.org/core/log" 24 24 ) ··· 30 30 unicodeNormalizeName = "uicodeNormalize" 31 31 32 32 // Bump this when the index mapping changes to trigger a rebuild. 33 - pullIndexerVersion = 3 33 + pullIndexerVersion = 2 34 34 ) 35 35 36 36 type Indexer struct { ··· 84 84 docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 85 85 docMapping.AddFieldMappingsAt("author_did", keywordFieldMapping) 86 86 docMapping.AddFieldMappingsAt("labels", keywordFieldMapping) 87 - docMapping.AddFieldMappingsAt("label_values", keywordFieldMapping) 88 87 89 88 err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 90 89 "type": unicodenorm.Name, ··· 179 178 } 180 179 181 180 type pullData struct { 182 - ID int64 `json:"id"` 183 - RepoAt string `json:"repo_at"` 184 - PullID int `json:"pull_id"` 185 - Title string `json:"title"` 186 - Body string `json:"body"` 187 - State string `json:"state"` 188 - AuthorDid string `json:"author_did"` 189 - Labels []string `json:"labels"` 190 - LabelValues []string `json:"label_values"` 181 + ID int64 `json:"id"` 182 + RepoAt string `json:"repo_at"` 183 + PullID int `json:"pull_id"` 184 + Title string `json:"title"` 185 + Body string `json:"body"` 186 + State string `json:"state"` 187 + AuthorDid string `json:"author_did"` 188 + Labels []string `json:"labels"` 191 189 192 190 Comments []pullCommentData `json:"comments"` 193 191 } 194 192 195 193 func makePullData(pull *models.Pull) *pullData { 196 194 return &pullData{ 197 - ID: int64(pull.ID), 198 - RepoAt: pull.RepoAt.String(), 199 - PullID: pull.PullId, 200 - Title: pull.Title, 201 - Body: pull.Body, 202 - State: pull.State.String(), 203 - AuthorDid: pull.OwnerDid, 204 - Labels: pull.Labels.LabelNames(), 205 - LabelValues: pull.Labels.LabelNameValues(), 195 + ID: int64(pull.ID), 196 + RepoAt: pull.RepoAt.String(), 197 + PullID: pull.PullId, 198 + Title: pull.Title, 199 + Body: pull.Body, 200 + State: pull.State.String(), 201 + AuthorDid: pull.OwnerDid, 202 + Labels: pull.Labels.LabelNames(), 206 203 } 207 204 } 208 205 ··· 288 285 musts = append(musts, bleveutil.KeywordFieldQuery("labels", label)) 289 286 } 290 287 291 - for _, did := range opts.NegatedAuthorDids { 292 - mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", did)) 288 + if opts.NegatedAuthorDid != "" { 289 + mustNots = append(mustNots, bleveutil.KeywordFieldQuery("author_did", opts.NegatedAuthorDid)) 293 290 } 294 291 295 292 for _, label := range opts.NegatedLabels { 296 293 mustNots = append(mustNots, bleveutil.KeywordFieldQuery("labels", label)) 297 - } 298 - 299 - for _, lv := range opts.LabelValues { 300 - musts = append(musts, bleveutil.KeywordFieldQuery("label_values", lv)) 301 - } 302 - 303 - for _, lv := range opts.NegatedLabelValues { 304 - mustNots = append(mustNots, bleveutil.KeywordFieldQuery("label_values", lv)) 305 294 } 306 295 307 296 indexerQuery := bleve.NewBooleanQuery()
+1 -1
appview/ingester.go
··· 401 401 402 402 ddb, ok := i.Db.Execer.(*db.DB) 403 403 if !ok { 404 - return fmt.Errorf("invalid db cast") 404 + return fmt.Errorf("failed to index profile record, invalid db cast") 405 405 } 406 406 407 407 err = db.AddSpindleMember(ddb, models.SpindleMember{
+44 -40
appview/issues/issues.go
··· 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 + "tangled.org/core/appview/ogcard" 26 27 "tangled.org/core/appview/pages" 27 28 "tangled.org/core/appview/pages/repoinfo" 28 29 "tangled.org/core/appview/pagination" ··· 48 49 logger *slog.Logger 49 50 validator *validator.Validator 50 51 indexer *issues_indexer.Indexer 52 + ogcardClient *ogcard.Client 51 53 } 52 54 53 55 func New( ··· 77 79 logger: logger, 78 80 validator: validator, 79 81 indexer: indexer, 82 + ogcardClient: ogcard.NewClient(config.Ogcard.Host), 80 83 } 81 84 } 82 85 ··· 827 830 query.Set("state", "open") 828 831 } 829 832 830 - resolve := func(ctx context.Context, ident string) (string, error) { 831 - id, err := rp.idResolver.ResolveIdent(ctx, ident) 833 + var authorDid string 834 + if authorHandle := query.Get("author"); authorHandle != nil { 835 + identity, err := rp.idResolver.ResolveIdent(r.Context(), *authorHandle) 832 836 if err != nil { 833 - return "", err 837 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 838 + } else { 839 + authorDid = identity.DID.String() 834 840 } 835 - return id.DID.String(), nil 836 841 } 837 842 838 - authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 843 + var negatedAuthorDid string 844 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 845 + identity, err := rp.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 846 + if err != nil { 847 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 848 + } else { 849 + negatedAuthorDid = identity.DID.String() 850 + } 851 + } 839 852 840 853 labels := query.GetAll("label") 841 854 negatedLabels := query.GetAllNegated("label") 842 - labelValues := query.GetDynamicTags() 843 - negatedLabelValues := query.GetNegatedDynamicTags() 844 855 845 - // resolve DID-format label values: if a dynamic tag's label 846 - // definition has format "did", resolve the handle to a DID 847 - if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 848 - labelDefs, err := db.GetLabelDefinitions( 849 - rp.db, 850 - orm.FilterIn("at_uri", f.Labels), 851 - orm.FilterContains("scope", tangled.RepoIssueNSID), 852 - ) 853 - if err == nil { 854 - didLabels := make(map[string]bool) 855 - for _, def := range labelDefs { 856 - if def.ValueType.Format == models.ValueTypeFormatDid { 857 - didLabels[def.Name] = true 858 - } 856 + var keywords, negatedKeywords []string 857 + var phrases, negatedPhrases []string 858 + for _, item := range query.Items() { 859 + switch item.Kind { 860 + case searchquery.KindKeyword: 861 + if item.Negated { 862 + negatedKeywords = append(negatedKeywords, item.Value) 863 + } else { 864 + keywords = append(keywords, item.Value) 865 + } 866 + case searchquery.KindQuoted: 867 + if item.Negated { 868 + negatedPhrases = append(negatedPhrases, item.Value) 869 + } else { 870 + phrases = append(phrases, item.Value) 859 871 } 860 - labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 861 - negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 862 - } else { 863 - l.Debug("failed to fetch label definitions for DID resolution", "err", err) 864 872 } 865 873 } 866 874 867 - tf := searchquery.ExtractTextFilters(query) 868 - 869 875 searchOpts := models.IssueSearchOptions{ 870 - Keywords: tf.Keywords, 871 - Phrases: tf.Phrases, 872 - RepoAt: f.RepoAt().String(), 873 - IsOpen: isOpen, 874 - AuthorDid: authorDid, 875 - Labels: labels, 876 - LabelValues: labelValues, 877 - NegatedKeywords: tf.NegatedKeywords, 878 - NegatedPhrases: tf.NegatedPhrases, 879 - NegatedLabels: negatedLabels, 880 - NegatedLabelValues: negatedLabelValues, 881 - NegatedAuthorDids: negatedAuthorDids, 882 - Page: page, 876 + Keywords: keywords, 877 + Phrases: phrases, 878 + RepoAt: f.RepoAt().String(), 879 + IsOpen: isOpen, 880 + AuthorDid: authorDid, 881 + Labels: labels, 882 + NegatedKeywords: negatedKeywords, 883 + NegatedPhrases: negatedPhrases, 884 + NegatedLabels: negatedLabels, 885 + NegatedAuthorDid: negatedAuthorDid, 886 + Page: page, 883 887 } 884 888 885 889 totalIssues := 0
+35 -229
appview/issues/opengraph.go
··· 1 1 package issues 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 - "fmt" 7 - "image" 8 - "image/color" 9 - "image/png" 10 5 "log" 11 6 "net/http" 7 + "time" 12 8 13 9 "tangled.org/core/appview/models" 14 10 "tangled.org/core/appview/ogcard" 15 11 ) 16 12 17 - func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 - width, height := ogcard.DefaultSize() 19 - mainCard, err := ogcard.NewCard(width, height) 20 - if err != nil { 21 - return nil, err 22 - } 23 - 24 - // Split: content area (75%) and status/stats area (25%) 25 - contentCard, statsArea := mainCard.Split(false, 75) 26 - 27 - // Add padding to content 28 - contentCard.SetMargin(50) 29 - 30 - // Split content horizontally: main content (80%) and avatar area (20%) 31 - mainContent, avatarArea := contentCard.Split(true, 80) 32 - 33 - // Add margin to main content like repo card 34 - mainContent.SetMargin(10) 35 - 36 - // Use full main content area for repo name and title 37 - bounds := mainContent.Img.Bounds() 38 - startX := bounds.Min.X + mainContent.Margin 39 - startY := bounds.Min.Y + mainContent.Margin 40 - 41 - // Draw full repository name at top (owner/repo format) 42 - var repoOwner string 43 - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 - if err != nil { 45 - repoOwner = repo.Did 46 - } else { 47 - repoOwner = "@" + owner.Handle.String() 48 - } 49 - 50 - fullRepoName := repoOwner + " / " + repo.Name 51 - if len(fullRepoName) > 60 { 52 - fullRepoName = fullRepoName[:60] + "…" 53 - } 54 - 55 - grayColor := color.RGBA{88, 96, 105, 255} 56 - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 - if err != nil { 58 - return nil, err 59 - } 60 - 61 - // Draw issue title below repo name with wrapping 62 - titleY := startY + 60 63 - titleX := startX 64 - 65 - // Truncate title if too long 66 - issueTitle := issue.Title 67 - maxTitleLength := 80 68 - if len(issueTitle) > maxTitleLength { 69 - issueTitle = issueTitle[:maxTitleLength] + "…" 70 - } 71 - 72 - // Create a temporary card for the title area to enable wrapping 73 - titleBounds := mainContent.Img.Bounds() 74 - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 - 77 - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 - titleCard := &ogcard.Card{ 79 - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 - Font: mainContent.Font, 81 - Margin: 0, 82 - } 83 - 84 - // Draw wrapped title 85 - lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 - if err != nil { 87 - return nil, err 88 - } 89 - 90 - // Calculate where title ends (number of lines * line height) 91 - lineHeight := 60 // Approximate line height for 54pt font 92 - titleEndY := titleY + (len(lines) * lineHeight) + 10 93 - 94 - // Draw issue ID in gray below the title 95 - issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 - err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 - if err != nil { 98 - return nil, err 99 - } 100 - 101 - // Get issue author handle (needed for avatar and metadata) 102 - var authorHandle string 103 - author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 - if err != nil { 105 - authorHandle = issue.Did 106 - } else { 107 - authorHandle = "@" + author.Handle.String() 108 - } 109 - 110 - // Draw avatar circle on the right side 111 - avatarBounds := avatarArea.Img.Bounds() 112 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 - if avatarSize > 220 { 114 - avatarSize = 220 115 - } 116 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 - avatarY := avatarBounds.Min.Y + 20 118 - 119 - // Get avatar URL for issue author 120 - avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 - if err != nil { 123 - log.Printf("failed to draw avatar (non-fatal): %v", err) 124 - } 125 - 126 - // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 - statusArea, dollyArea := statsArea.Split(true, 80) 128 - 129 - // Draw status and comment count in status/comments area 130 - statsBounds := statusArea.Img.Bounds() 131 - statsX := statsBounds.Min.X + 60 // left padding 132 - statsY := statsBounds.Min.Y 133 - 134 - iconColor := color.RGBA{88, 96, 105, 255} 135 - iconSize := 36 136 - textSize := 36.0 137 - labelSize := 28.0 138 - iconBaselineOffset := int(textSize) / 2 139 - 140 - // Draw status (open/closed) with colored icon and text 141 - var statusIcon string 142 - var statusText string 143 - var statusColor color.RGBA 144 - 145 - if issue.Open { 146 - statusIcon = "circle-dot" 147 - statusText = "open" 148 - statusColor = color.RGBA{34, 139, 34, 255} // green 149 - } else { 150 - statusIcon = "ban" 151 - statusText = "closed" 152 - statusColor = color.RGBA{52, 58, 64, 255} // dark gray 153 - } 154 - 155 - statusTextWidth := statusArea.TextWidth(statusText, textSize) 156 - badgePadding := 12 157 - badgeHeight := int(textSize) + (badgePadding * 2) 158 - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 159 - cornerRadius := 8 160 - badgeX := 60 161 - badgeY := 0 162 - 163 - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 164 - 165 - whiteColor := color.RGBA{255, 255, 255, 255} 166 - iconX := statsX + badgePadding 167 - iconY := statsY + (badgeHeight-iconSize)/2 168 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 169 - if err != nil { 170 - log.Printf("failed to draw status icon: %v", err) 171 - } 172 - 173 - textX := statsX + badgePadding + iconSize + badgePadding 174 - textY := statsY + (badgeHeight-int(textSize))/2 - 5 175 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 176 - if err != nil { 177 - log.Printf("failed to draw status text: %v", err) 178 - } 179 - 180 - currentX := statsX + badgeWidth + 50 181 - 182 - // Draw comment count 183 - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 184 - if err != nil { 185 - log.Printf("failed to draw comment icon: %v", err) 186 - } 187 - 188 - currentX += iconSize + 15 189 - commentText := fmt.Sprintf("%d comments", commentCount) 190 - if commentCount == 1 { 191 - commentText = "1 comment" 192 - } 193 - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 194 - if err != nil { 195 - log.Printf("failed to draw comment text: %v", err) 196 - } 197 - 198 - // Draw dolly logo on the right side 199 - dollyBounds := dollyArea.Img.Bounds() 200 - dollySize := 90 201 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 202 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 203 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 204 - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 205 - if err != nil { 206 - log.Printf("dolly not available (this is ok): %v", err) 207 - } 208 - 209 - // Draw "opened by @author" and date at the bottom with more spacing 210 - labelY := statsY + iconSize + 30 211 - 212 - // Format the opened date 213 - openedDate := issue.Created.Format("Jan 2, 2006") 214 - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 215 - 216 - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 217 - if err != nil { 218 - log.Printf("failed to draw metadata: %v", err) 219 - } 220 - 221 - return mainCard, nil 222 - } 223 - 224 13 func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 225 14 f, err := rp.repoResolver.Resolve(r) 226 15 if err != nil { ··· 235 24 return 236 25 } 237 26 238 - // Get comment count 239 - commentCount := len(issue.Comments) 240 - 241 - // Get owner handle for avatar 242 27 var ownerHandle string 243 - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 28 + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) 244 29 if err != nil { 245 30 ownerHandle = f.Did 246 31 } else { 247 32 ownerHandle = "@" + owner.Handle.String() 248 33 } 249 34 250 - card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) 35 + var authorHandle string 36 + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 251 37 if err != nil { 252 - log.Println("failed to draw issue summary card", err) 253 - http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) 254 - return 38 + authorHandle = issue.Did 39 + } else { 40 + authorHandle = "@" + author.Handle.String() 255 41 } 256 42 257 - var imageBuffer bytes.Buffer 258 - err = png.Encode(&imageBuffer, card.Img) 43 + avatarUrl := rp.pages.AvatarUrl(authorHandle, "256") 44 + 45 + status := "closed" 46 + if issue.Open { 47 + status = "open" 48 + } 49 + 50 + commentCount := len(issue.Comments) 51 + 52 + payload := ogcard.IssueCardPayload{ 53 + Type: "issue", 54 + RepoName: f.Name, 55 + OwnerHandle: ownerHandle, 56 + AvatarUrl: avatarUrl, 57 + Title: issue.Title, 58 + IssueNumber: issue.IssueId, 59 + Status: status, 60 + Labels: []ogcard.LabelData{}, 61 + CommentCount: commentCount, 62 + ReactionCount: 0, 63 + CreatedAt: issue.Created.Format(time.RFC3339), 64 + } 65 + 66 + imageBytes, err := rp.ogcardClient.RenderIssueCard(r.Context(), payload) 259 67 if err != nil { 260 - log.Println("failed to encode issue summary card", err) 261 - http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 68 + log.Println("failed to render issue card", err) 69 + http.Error(w, "failed to render issue card", http.StatusInternalServerError) 262 70 return 263 71 } 264 - 265 - imageBytes := imageBuffer.Bytes() 266 72 267 73 w.Header().Set("Content-Type", "image/png") 268 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 74 + w.Header().Set("Cache-Control", "public, max-age=3600") 269 75 w.WriteHeader(http.StatusOK) 270 76 _, err = w.Write(imageBytes) 271 77 if err != nil { 272 - log.Println("failed to write issue summary card", err) 78 + log.Println("failed to write issue card", err) 273 79 return 274 80 } 275 81 }
-72
appview/models/bsky.go
··· 1 - package models 2 - 3 - import ( 4 - "time" 5 - 6 - apibsky "github.com/bluesky-social/indigo/api/bsky" 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type BskyPost struct { 11 - Rkey string 12 - Text string 13 - CreatedAt time.Time 14 - Langs []string 15 - Tags []string 16 - Embed *apibsky.FeedDefs_PostView_Embed 17 - Facets []*apibsky.RichtextFacet 18 - Labels *apibsky.FeedPost_Labels 19 - Reply *apibsky.FeedPost_ReplyRef 20 - LikeCount int64 21 - ReplyCount int64 22 - RepostCount int64 23 - QuoteCount int64 24 - } 25 - 26 - func NewBskyPostFromView(postView *apibsky.FeedDefs_PostView) (*BskyPost, error) { 27 - atUri, err := syntax.ParseATURI(postView.Uri) 28 - if err != nil { 29 - return nil, err 30 - } 31 - 32 - // decode the record to get FeedPost 33 - feedPost, ok := postView.Record.Val.(*apibsky.FeedPost) 34 - if !ok { 35 - return nil, err 36 - } 37 - 38 - createdAt, err := time.Parse(time.RFC3339, feedPost.CreatedAt) 39 - if err != nil { 40 - return nil, err 41 - } 42 - 43 - var likeCount, replyCount, repostCount, quoteCount int64 44 - if postView.LikeCount != nil { 45 - likeCount = *postView.LikeCount 46 - } 47 - if postView.ReplyCount != nil { 48 - replyCount = *postView.ReplyCount 49 - } 50 - if postView.RepostCount != nil { 51 - repostCount = *postView.RepostCount 52 - } 53 - if postView.QuoteCount != nil { 54 - quoteCount = *postView.QuoteCount 55 - } 56 - 57 - return &BskyPost{ 58 - Rkey: atUri.RecordKey().String(), 59 - Text: feedPost.Text, 60 - CreatedAt: createdAt, 61 - Langs: feedPost.Langs, 62 - Tags: feedPost.Tags, 63 - Embed: postView.Embed, 64 - Facets: feedPost.Facets, 65 - Labels: feedPost.Labels, 66 - Reply: feedPost.Reply, 67 - LikeCount: likeCount, 68 - ReplyCount: replyCount, 69 - RepostCount: repostCount, 70 - QuoteCount: quoteCount, 71 - }, nil 72 - }
-21
appview/models/label.go
··· 314 314 return result 315 315 } 316 316 317 - // LabelNameValues returns composite "name:value" strings for all labels 318 - // that have non-empty values. 319 - func (s LabelState) LabelNameValues() []string { 320 - var result []string 321 - for key, valset := range s.inner { 322 - if valset == nil { 323 - continue 324 - } 325 - name, ok := s.names[key] 326 - if !ok { 327 - continue 328 - } 329 - for val := range valset { 330 - if val != "" { 331 - result = append(result, name+":"+val) 332 - } 333 - } 334 - } 335 - return result 336 - } 337 - 338 317 func (s LabelState) Inner() map[string]set { 339 318 return s.inner 340 319 }
+22 -28
appview/models/search.go
··· 3 3 import "tangled.org/core/appview/pagination" 4 4 5 5 type IssueSearchOptions struct { 6 - Keywords []string 7 - Phrases []string 8 - RepoAt string 9 - IsOpen *bool 10 - AuthorDid string 11 - Labels []string 12 - LabelValues []string 6 + Keywords []string 7 + Phrases []string 8 + RepoAt string 9 + IsOpen *bool 10 + AuthorDid string 11 + Labels []string 13 12 14 - NegatedKeywords []string 15 - NegatedPhrases []string 16 - NegatedLabels []string 17 - NegatedLabelValues []string 18 - NegatedAuthorDids []string 13 + NegatedKeywords []string 14 + NegatedPhrases []string 15 + NegatedLabels []string 16 + NegatedAuthorDid string 19 17 20 18 Page pagination.Page 21 19 } 22 20 23 21 func (o *IssueSearchOptions) HasSearchFilters() bool { 24 22 return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 25 - o.AuthorDid != "" || len(o.NegatedAuthorDids) > 0 || 23 + o.AuthorDid != "" || o.NegatedAuthorDid != "" || 26 24 len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 27 - len(o.LabelValues) > 0 || len(o.NegatedLabelValues) > 0 || 28 25 len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 29 26 } 30 27 31 28 type PullSearchOptions struct { 32 - Keywords []string 33 - Phrases []string 34 - RepoAt string 35 - State *PullState 36 - AuthorDid string 37 - Labels []string 38 - LabelValues []string 29 + Keywords []string 30 + Phrases []string 31 + RepoAt string 32 + State *PullState 33 + AuthorDid string 34 + Labels []string 39 35 40 - NegatedKeywords []string 41 - NegatedPhrases []string 42 - NegatedLabels []string 43 - NegatedLabelValues []string 44 - NegatedAuthorDids []string 36 + NegatedKeywords []string 37 + NegatedPhrases []string 38 + NegatedLabels []string 39 + NegatedAuthorDid string 45 40 46 41 Page pagination.Page 47 42 } 48 43 49 44 func (o *PullSearchOptions) HasSearchFilters() bool { 50 45 return len(o.Keywords) > 0 || len(o.Phrases) > 0 || 51 - o.AuthorDid != "" || len(o.NegatedAuthorDids) > 0 || 46 + o.AuthorDid != "" || o.NegatedAuthorDid != "" || 52 47 len(o.Labels) > 0 || len(o.NegatedLabels) > 0 || 53 - len(o.LabelValues) > 0 || len(o.NegatedLabelValues) > 0 || 54 48 len(o.NegatedKeywords) > 0 || len(o.NegatedPhrases) > 0 55 49 }
-40
appview/models/site_deploy.go
··· 1 - package models 2 - 3 - import "time" 4 - 5 - type SiteDeployStatus string 6 - 7 - const ( 8 - SiteDeployStatusSuccess SiteDeployStatus = "success" 9 - SiteDeployStatusFailure SiteDeployStatus = "failure" 10 - ) 11 - 12 - type SiteDeployTrigger string 13 - 14 - const ( 15 - SiteDeployTriggerConfigChange SiteDeployTrigger = "config_change" 16 - SiteDeployTriggerPush SiteDeployTrigger = "push" 17 - ) 18 - 19 - func (t SiteDeployTrigger) Label() string { 20 - switch t { 21 - case SiteDeployTriggerConfigChange: 22 - return "config change" 23 - case SiteDeployTriggerPush: 24 - return "push" 25 - default: 26 - return string(t) 27 - } 28 - } 29 - 30 - type SiteDeploy struct { 31 - Id int64 32 - RepoAt string 33 - Branch string 34 - Dir string 35 - CommitSHA string 36 - Status SiteDeployStatus 37 - Trigger SiteDeployTrigger 38 - Error string 39 - CreatedAt time.Time 40 - }
-21
appview/models/sites.go
··· 1 - package models 2 - 3 - import "time" 4 - 5 - type DomainClaim struct { 6 - ID int64 7 - Did string 8 - Domain string 9 - Deleted *time.Time 10 - } 11 - 12 - type RepoSite struct { 13 - ID int64 14 - RepoAt string 15 - RepoName string // populated when joined with repos table 16 - Branch string 17 - Dir string 18 - IsIndex bool 19 - Created time.Time 20 - Updated time.Time 21 - }
+1 -1
appview/oauth/accounts.go
··· 63 63 func (o *OAuth) SaveAccounts(w http.ResponseWriter, r *http.Request, registry *AccountRegistry) error { 64 64 session, err := o.SessStore.Get(r, AccountsName) 65 65 if err != nil { 66 - o.Logger.Warn("failed to decode existing accounts cookie, will create new", "err", err) 66 + return err 67 67 } 68 68 69 69 data, err := json.Marshal(registry)
+13 -159
appview/oauth/handler.go
··· 6 6 "encoding/json" 7 7 "errors" 8 8 "fmt" 9 - "log/slog" 10 9 "net/http" 11 10 "slices" 12 - "strings" 13 11 "time" 14 12 15 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 14 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 15 lexutil "github.com/bluesky-social/indigo/lex/util" 18 - xrpc "github.com/bluesky-social/indigo/xrpc" 19 16 "github.com/go-chi/chi/v5" 20 17 "github.com/posthog/posthog-go" 21 18 "tangled.org/core/api/tangled" 22 19 "tangled.org/core/appview/db" 23 20 "tangled.org/core/appview/models" 24 21 "tangled.org/core/consts" 25 - "tangled.org/core/idresolver" 26 22 "tangled.org/core/orm" 27 23 "tangled.org/core/tid" 28 24 ) ··· 41 37 doc.JWKSURI = &o.JwksUri 42 38 doc.ClientName = &o.ClientName 43 39 doc.ClientURI = &o.ClientUri 44 - doc.Scope = doc.Scope + " identity:handle" 45 40 46 41 w.Header().Set("Content-Type", "application/json") 47 42 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 94 89 go o.addToDefaultKnot(sessData.AccountDID.String()) 95 90 go o.addToDefaultSpindle(sessData.AccountDID.String()) 96 91 go o.ensureTangledProfile(sessData) 97 - go o.autoClaimTnglShDomain(sessData.AccountDID.String()) 98 92 99 93 if !o.Config.Core.Dev { 100 94 err = o.Posthog.Enqueue(posthog.Capture{ ··· 111 105 redirectURL = authReturn.ReturnURL 112 106 } 113 107 114 - if o.isAccountDeactivated(sessData) { 115 - redirectURL = "/settings/profile" 116 - } 117 - 118 108 http.Redirect(w, r, redirectURL, http.StatusFound) 119 109 } 120 110 121 - func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { 122 - pdsClient := &xrpc.Client{ 123 - Host: sessData.HostURL, 124 - Client: &http.Client{Timeout: 5 * time.Second}, 125 - } 126 - 127 - _, err := comatproto.RepoDescribeRepo( 128 - context.Background(), 129 - pdsClient, 130 - sessData.AccountDID.String(), 131 - ) 132 - if err == nil { 133 - return false 134 - } 135 - 136 - var xrpcErr *xrpc.Error 137 - var xrpcBody *xrpc.XRPCError 138 - return errors.As(err, &xrpcErr) && 139 - errors.As(xrpcErr.Wrapped, &xrpcBody) && 140 - xrpcBody.ErrStr == "RepoDeactivated" 141 - } 142 - 143 111 func (o *OAuth) addToDefaultSpindle(did string) { 144 112 l := o.Logger.With("subject", did) 145 113 ··· 161 129 } 162 130 163 131 l.Debug("adding to default spindle") 164 - session, err := o.getAppPasswordSession() 132 + session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 165 133 if err != nil { 166 134 l.Error("failed to create session", "err", err) 167 135 return 168 136 } 169 137 170 138 record := tangled.SpindleMember{ 171 - LexiconTypeID: tangled.SpindleMemberNSID, 139 + LexiconTypeID: "sh.tangled.spindle.member", 172 140 Subject: did, 173 141 Instance: consts.DefaultSpindle, 174 142 CreatedAt: time.Now().Format(time.RFC3339), ··· 200 168 } 201 169 202 170 l.Debug("adding to default knot") 203 - session, err := o.getAppPasswordSession() 171 + session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 204 172 if err != nil { 205 173 l.Error("failed to create session", "err", err) 206 174 return 207 175 } 208 176 209 177 record := tangled.KnotMember{ 210 - LexiconTypeID: tangled.KnotMemberNSID, 178 + LexiconTypeID: "sh.tangled.knot.member", 211 179 Subject: did, 212 180 Domain: consts.DefaultKnot, 213 181 CreatedAt: time.Now().Format(time.RFC3339), ··· 223 191 return 224 192 } 225 193 226 - l.Debug("successfully added to default knot") 194 + l.Debug("successfully addeds to default Knot") 227 195 } 228 196 229 197 func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { ··· 273 241 l.Debug("successfully created empty Tangled profile on PDS and DB") 274 242 } 275 243 276 - // create a AppPasswordSession using apppasswords 277 - type AppPasswordSession struct { 244 + // create a session using apppasswords 245 + type session struct { 278 246 AccessJwt string `json:"accessJwt"` 279 - RefreshJwt string `json:"refreshJwt"` 280 247 PdsEndpoint string 281 248 Did string 282 - Logger *slog.Logger 283 - ExpiresAt time.Time 284 249 } 285 250 286 - func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) { 251 + func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 287 252 if appPassword == "" { 288 - return nil, fmt.Errorf("no app password configured") 253 + return nil, fmt.Errorf("no app password configured, skipping member addition") 289 254 } 290 255 291 - resolved, err := res.ResolveIdent(context.Background(), did) 256 + resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 292 257 if err != nil { 293 258 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 294 259 } ··· 314 279 } 315 280 sessionReq.Header.Set("Content-Type", "application/json") 316 281 317 - logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header) 318 - 319 282 client := &http.Client{Timeout: 30 * time.Second} 320 283 sessionResp, err := client.Do(sessionReq) 321 284 if err != nil { ··· 327 290 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 328 291 } 329 292 330 - var session AppPasswordSession 293 + var session session 331 294 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 332 295 return nil, fmt.Errorf("failed to decode session response: %v", err) 333 296 } 334 297 335 298 session.PdsEndpoint = pdsEndpoint 336 299 session.Did = did 337 - session.Logger = logger 338 - session.ExpiresAt = time.Now().Add(115 * time.Minute) 339 300 340 301 return &session, nil 341 302 } 342 303 343 - func (s *AppPasswordSession) refreshSession() error { 344 - refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession" 345 - req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil) 346 - if err != nil { 347 - return fmt.Errorf("failed to create refresh request: %w", err) 348 - } 349 - 350 - req.Header.Set("Authorization", "Bearer "+s.RefreshJwt) 351 - 352 - s.Logger.Debug("refreshing app password session", "url", refreshURL) 353 - 354 - client := &http.Client{Timeout: 30 * time.Second} 355 - resp, err := client.Do(req) 356 - if err != nil { 357 - return fmt.Errorf("failed to refresh session: %w", err) 358 - } 359 - defer resp.Body.Close() 360 - 361 - if resp.StatusCode != http.StatusOK { 362 - var errorResponse map[string]any 363 - if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 364 - return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 365 - } 366 - errorBytes, _ := json.Marshal(errorResponse) 367 - return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes)) 368 - } 369 - 370 - var refreshResponse struct { 371 - AccessJwt string `json:"accessJwt"` 372 - RefreshJwt string `json:"refreshJwt"` 373 - } 374 - if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil { 375 - return fmt.Errorf("failed to decode refresh response: %w", err) 376 - } 377 - 378 - s.AccessJwt = refreshResponse.AccessJwt 379 - s.RefreshJwt = refreshResponse.RefreshJwt 380 - // Set new expiry time with 5 minute buffer 381 - s.ExpiresAt = time.Now().Add(115 * time.Minute) 382 - 383 - s.Logger.Debug("successfully refreshed app password session") 384 - return nil 385 - } 386 - 387 - func (s *AppPasswordSession) isValid() bool { 388 - return time.Now().Before(s.ExpiresAt) 389 - } 390 - 391 - func (s *AppPasswordSession) putRecord(record any, collection string) error { 392 - if !s.isValid() { 393 - s.Logger.Debug("access token expired, refreshing session") 394 - if err := s.refreshSession(); err != nil { 395 - return fmt.Errorf("failed to refresh session: %w", err) 396 - } 397 - s.Logger.Debug("session refreshed") 398 - } 399 - 304 + func (s *session) putRecord(record any, collection string) error { 400 305 recordBytes, err := json.Marshal(record) 401 306 if err != nil { 402 307 return fmt.Errorf("failed to marshal knot member record: %w", err) ··· 423 328 req.Header.Set("Content-Type", "application/json") 424 329 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 425 330 426 - s.Logger.Debug("putting record", "url", url, "collection", collection) 427 - 428 331 client := &http.Client{Timeout: 30 * time.Second} 429 332 resp, err := client.Do(req) 430 333 if err != nil { ··· 433 336 defer resp.Body.Close() 434 337 435 338 if resp.StatusCode != http.StatusOK { 436 - var errorResponse map[string]any 437 - if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 438 - return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 439 - } 440 - return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse) 339 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 441 340 } 442 341 443 342 return nil 444 343 } 445 - 446 - // autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so, 447 - // ensures their corresponding sites domain is claimed. This is idempotent — 448 - // ClaimDomain is a no-op if the claim already exists. 449 - func (o *OAuth) autoClaimTnglShDomain(did string) { 450 - l := o.Logger.With("did", did) 451 - 452 - pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://") 453 - pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 454 - 455 - resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 456 - if err != nil { 457 - l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err) 458 - return 459 - } 460 - 461 - handle := resolved.Handle.String() 462 - if !strings.HasSuffix(handle, "."+pdsDomain) { 463 - return 464 - } 465 - 466 - if err := db.ClaimDomain(o.Db, did, handle); err != nil { 467 - l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err) 468 - } else { 469 - l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle) 470 - } 471 - } 472 - 473 - // getAppPasswordSession returns a cached AppPasswordSession, creating one if needed. 474 - func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) { 475 - o.appPasswordSessionMu.Lock() 476 - defer o.appPasswordSessionMu.Unlock() 477 - 478 - if o.appPasswordSession != nil { 479 - return o.appPasswordSession, nil 480 - } 481 - 482 - session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger) 483 - if err != nil { 484 - return nil, err 485 - } 486 - 487 - o.appPasswordSession = session 488 - return session, nil 489 - }
+1 -63
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 - "context" 5 4 "errors" 6 5 "fmt" 7 6 "log/slog" 8 7 "net/http" 9 - "net/url" 10 - "sync" 11 8 "time" 12 9 13 10 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 36 33 Enforcer *rbac.Enforcer 37 34 IdResolver *idresolver.Resolver 38 35 Logger *slog.Logger 39 - 40 - appPasswordSession *AppPasswordSession 41 - appPasswordSessionMu sync.Mutex 42 36 } 43 37 44 38 func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { ··· 106 100 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 107 101 userSession, err := o.SessStore.Get(r, SessionName) 108 102 if err != nil { 109 - o.Logger.Warn("failed to decode existing session cookie, will create new", "err", err) 103 + return err 110 104 } 111 105 112 106 userSession.Values[SessionDid] = sessData.AccountDID.String() ··· 365 359 }, 366 360 }, nil 367 361 } 368 - 369 - func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { 370 - parsedDid, err := syntax.ParseDID(did) 371 - if err != nil { 372 - return "", fmt.Errorf("invalid DID: %w", err) 373 - } 374 - 375 - ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier()) 376 - if err != nil { 377 - return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err) 378 - } 379 - 380 - host := ident.PDSEndpoint() 381 - if host == "" { 382 - return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 383 - } 384 - 385 - authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host) 386 - if err != nil { 387 - return "", fmt.Errorf("resolving auth server: %w", err) 388 - } 389 - 390 - authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 391 - if err != nil { 392 - return "", fmt.Errorf("fetching auth server metadata: %w", err) 393 - } 394 - 395 - scopes := make([]string, 0, len(TangledScopes)+len(extraScopes)) 396 - scopes = append(scopes, TangledScopes...) 397 - scopes = append(scopes, extraScopes...) 398 - 399 - loginHint := did 400 - if ident.Handle != "" && !ident.Handle.IsInvalidHandle() { 401 - loginHint = ident.Handle.String() 402 - } 403 - 404 - info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint) 405 - if err != nil { 406 - return "", fmt.Errorf("auth request failed: %w", err) 407 - } 408 - 409 - info.AccountDID = &parsedDid 410 - o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) 411 - 412 - if err := o.SetAuthReturn(w, r, returnURL, false); err != nil { 413 - return "", fmt.Errorf("failed to set auth return: %w", err) 414 - } 415 - 416 - redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s", 417 - authserverMeta.AuthorizationEndpoint, 418 - url.QueryEscape(o.ClientApp.Config.ClientID), 419 - url.QueryEscape(info.RequestURI), 420 - ) 421 - 422 - return redirectURL, nil 423 - }
+5
appview/ogcard/.gitignore
··· 1 + node_modules/ 2 + bun.lock 3 + output/ 4 + .wrangler/ 5 + .DS_Store
-640
appview/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "html/template" 11 - "image" 12 - "image/color" 13 - "io" 14 - "log" 15 - "math" 16 - "net/http" 17 - "strings" 18 - "sync" 19 - "time" 20 - 21 - "github.com/goki/freetype" 22 - "github.com/goki/freetype/truetype" 23 - "github.com/srwiley/oksvg" 24 - "github.com/srwiley/rasterx" 25 - "golang.org/x/image/draw" 26 - "golang.org/x/image/font" 27 - "tangled.org/core/appview/pages" 28 - 29 - _ "golang.org/x/image/webp" // for processing webp images 30 - ) 31 - 32 - type Card struct { 33 - Img *image.RGBA 34 - Font *truetype.Font 35 - Margin int 36 - Width int 37 - Height int 38 - } 39 - 40 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 41 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 42 - if err != nil { 43 - return nil, err 44 - } 45 - return truetype.Parse(interVar) 46 - }) 47 - 48 - // DefaultSize returns the default size for a card 49 - func DefaultSize() (int, int) { 50 - return 1200, 630 51 - } 52 - 53 - // NewCard creates a new card with the given dimensions in pixels 54 - func NewCard(width, height int) (*Card, error) { 55 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 56 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 57 - 58 - font, err := fontCache() 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &Card{ 64 - Img: img, 65 - Font: font, 66 - Margin: 0, 67 - Width: width, 68 - Height: height, 69 - }, nil 70 - } 71 - 72 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 73 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 74 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 75 - bounds := c.Img.Bounds() 76 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 77 - if vertical { 78 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 79 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 80 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 81 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 82 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 83 - } 84 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 85 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 86 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 87 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 88 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 89 - } 90 - 91 - // SetMargin sets the margins for the card 92 - func (c *Card) SetMargin(margin int) { 93 - c.Margin = margin 94 - } 95 - 96 - type ( 97 - VAlign int64 98 - HAlign int64 99 - ) 100 - 101 - const ( 102 - Top VAlign = iota 103 - Middle 104 - Bottom 105 - ) 106 - 107 - const ( 108 - Left HAlign = iota 109 - Center 110 - Right 111 - ) 112 - 113 - // DrawText draws text within the card, respecting margins and alignment 114 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 115 - ft := freetype.NewContext() 116 - ft.SetDPI(72) 117 - ft.SetFont(c.Font) 118 - ft.SetFontSize(sizePt) 119 - ft.SetClip(c.Img.Bounds()) 120 - ft.SetDst(c.Img) 121 - ft.SetSrc(image.NewUniform(textColor)) 122 - 123 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 124 - fontHeight := ft.PointToFixed(sizePt).Ceil() 125 - 126 - bounds := c.Img.Bounds() 127 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 128 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 129 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 130 - 131 - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 132 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 133 - // knowing the total height, which is related to how many lines we'll have. 134 - lines := make([]string, 0) 135 - textWords := strings.Split(text, " ") 136 - currentLine := "" 137 - heightTotal := 0 138 - 139 - for { 140 - if len(textWords) == 0 { 141 - // Ran out of words. 142 - if currentLine != "" { 143 - heightTotal += fontHeight 144 - lines = append(lines, currentLine) 145 - } 146 - break 147 - } 148 - 149 - nextWord := textWords[0] 150 - proposedLine := currentLine 151 - if proposedLine != "" { 152 - proposedLine += " " 153 - } 154 - proposedLine += nextWord 155 - 156 - proposedLineWidth := font.MeasureString(face, proposedLine) 157 - if proposedLineWidth.Ceil() > boxWidth { 158 - // no, proposed line is too big; we'll use the last "currentLine" 159 - heightTotal += fontHeight 160 - if currentLine != "" { 161 - lines = append(lines, currentLine) 162 - currentLine = "" 163 - // leave nextWord in textWords and keep going 164 - } else { 165 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 166 - // regardless as a line by itself. It will be clipped by the drawing routine. 167 - lines = append(lines, nextWord) 168 - textWords = textWords[1:] 169 - } 170 - } else { 171 - // yes, it will fit 172 - currentLine = proposedLine 173 - textWords = textWords[1:] 174 - } 175 - } 176 - 177 - textY := 0 178 - switch valign { 179 - case Top: 180 - textY = fontHeight 181 - case Bottom: 182 - textY = boxHeight - heightTotal + fontHeight 183 - case Middle: 184 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 185 - } 186 - 187 - for _, line := range lines { 188 - lineWidth := font.MeasureString(face, line) 189 - 190 - textX := 0 191 - switch halign { 192 - case Left: 193 - textX = 0 194 - case Right: 195 - textX = boxWidth - lineWidth.Ceil() 196 - case Center: 197 - textX = (boxWidth - lineWidth.Ceil()) / 2 198 - } 199 - 200 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 201 - _, err := ft.DrawString(line, pt) 202 - if err != nil { 203 - return nil, err 204 - } 205 - 206 - textY += fontHeight 207 - } 208 - 209 - return lines, nil 210 - } 211 - 212 - // DrawTextAt draws text at a specific position with the given alignment 213 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 214 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 215 - return err 216 - } 217 - 218 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 219 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 220 - ft := freetype.NewContext() 221 - ft.SetDPI(72) 222 - ft.SetFont(c.Font) 223 - ft.SetFontSize(sizePt) 224 - ft.SetClip(c.Img.Bounds()) 225 - ft.SetDst(c.Img) 226 - ft.SetSrc(image.NewUniform(textColor)) 227 - 228 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 229 - fontHeight := ft.PointToFixed(sizePt).Ceil() 230 - lineWidth := font.MeasureString(face, text) 231 - textWidth := lineWidth.Ceil() 232 - 233 - // Adjust position based on alignment 234 - adjustedX := x 235 - adjustedY := y 236 - 237 - switch halign { 238 - case Left: 239 - // x is already at the left position 240 - case Right: 241 - adjustedX = x - textWidth 242 - case Center: 243 - adjustedX = x - textWidth/2 244 - } 245 - 246 - switch valign { 247 - case Top: 248 - adjustedY = y + fontHeight 249 - case Bottom: 250 - adjustedY = y 251 - case Middle: 252 - adjustedY = y + fontHeight/2 253 - } 254 - 255 - pt := freetype.Pt(adjustedX, adjustedY) 256 - _, err := ft.DrawString(text, pt) 257 - return textWidth, err 258 - } 259 - 260 - func (c *Card) FontHeight(sizePt float64) int { 261 - ft := freetype.NewContext() 262 - ft.SetDPI(72) 263 - ft.SetFont(c.Font) 264 - ft.SetFontSize(sizePt) 265 - return ft.PointToFixed(sizePt).Ceil() 266 - } 267 - 268 - func (c *Card) TextWidth(text string, sizePt float64) int { 269 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 270 - lineWidth := font.MeasureString(face, text) 271 - textWidth := lineWidth.Ceil() 272 - return textWidth 273 - } 274 - 275 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 276 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 277 - // Draw the text multiple times with slight offsets to create bold effect 278 - offsets := []struct{ dx, dy int }{ 279 - {0, 0}, // original 280 - {1, 0}, // right 281 - {0, 1}, // down 282 - {1, 1}, // diagonal 283 - } 284 - 285 - var width int 286 - for _, offset := range offsets { 287 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 288 - if err != nil { 289 - return 0, err 290 - } 291 - if width == 0 { 292 - width = w 293 - } 294 - } 295 - return width, nil 296 - } 297 - 298 - func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 299 - // Convert color to hex string for SVG 300 - rgba, isRGBA := iconColor.(color.RGBA) 301 - if !isRGBA { 302 - r, g, b, a := iconColor.RGBA() 303 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 304 - } 305 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 306 - 307 - // Replace currentColor with our desired color in the SVG 308 - svgString := string(svgData) 309 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 310 - 311 - // Make the stroke thicker 312 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 313 - 314 - // Parse SVG 315 - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 316 - if err != nil { 317 - return nil, fmt.Errorf("failed to parse SVG: %w", err) 318 - } 319 - 320 - return icon, nil 321 - } 322 - 323 - func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { 324 - svgData, err := pages.Files.ReadFile(svgPath) 325 - if err != nil { 326 - return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 327 - } 328 - 329 - icon, err := BuildSVGIconFromData(svgData, iconColor) 330 - if err != nil { 331 - return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) 332 - } 333 - 334 - return icon, nil 335 - } 336 - 337 - func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { 338 - return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 339 - } 340 - 341 - func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { 342 - icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 343 - if err != nil { 344 - return err 345 - } 346 - 347 - c.DrawSVGIcon(icon, x, y, size) 348 - 349 - return nil 350 - } 351 - 352 - func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 353 - tpl, err := template.New("dolly"). 354 - ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 355 - if err != nil { 356 - return fmt.Errorf("failed to read dolly template: %w", err) 357 - } 358 - 359 - var svgData bytes.Buffer 360 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 361 - return fmt.Errorf("failed to execute dolly template: %w", err) 362 - } 363 - 364 - icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) 365 - if err != nil { 366 - return err 367 - } 368 - 369 - c.DrawSVGIcon(icon, x, y, size) 370 - 371 - return nil 372 - } 373 - 374 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 375 - func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { 376 - // Set the icon size 377 - w, h := float64(size), float64(size) 378 - icon.SetTarget(0, 0, w, h) 379 - 380 - // Create a temporary RGBA image for the icon 381 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 382 - 383 - // Create scanner and rasterizer 384 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 385 - raster := rasterx.NewDasher(size, size, scanner) 386 - 387 - // Draw the icon 388 - icon.Draw(raster, 1.0) 389 - 390 - // Draw the icon onto the card at the specified position 391 - bounds := c.Img.Bounds() 392 - destRect := image.Rect(x, y, x+size, y+size) 393 - 394 - // Make sure we don't draw outside the card bounds 395 - if destRect.Max.X > bounds.Max.X { 396 - destRect.Max.X = bounds.Max.X 397 - } 398 - if destRect.Max.Y > bounds.Max.Y { 399 - destRect.Max.Y = bounds.Max.Y 400 - } 401 - 402 - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 403 - } 404 - 405 - // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 406 - func (c *Card) DrawImage(img image.Image) { 407 - bounds := c.Img.Bounds() 408 - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 409 - srcBounds := img.Bounds() 410 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 411 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 412 - 413 - var scale float64 414 - if srcAspect > targetAspect { 415 - // Image is wider than target, scale by width 416 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 417 - } else { 418 - // Image is taller or equal, scale by height 419 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 420 - } 421 - 422 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 423 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 424 - 425 - // Center the image within the target rectangle 426 - offsetX := (targetRect.Dx() - newWidth) / 2 427 - offsetY := (targetRect.Dy() - newHeight) / 2 428 - 429 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 430 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 431 - } 432 - 433 - func fallbackImage() image.Image { 434 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 435 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 436 - img.Set(0, 0, color.White) 437 - return img 438 - } 439 - 440 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 441 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 442 - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 443 - // this rendering process to be slowed down 444 - client := &http.Client{ 445 - Timeout: 1 * time.Second, // 1 second timeout 446 - } 447 - 448 - resp, err := client.Get(url) 449 - if err != nil { 450 - log.Printf("error when fetching external image from %s: %v", url, err) 451 - return nil, false 452 - } 453 - defer resp.Body.Close() 454 - 455 - if resp.StatusCode != http.StatusOK { 456 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 457 - return nil, false 458 - } 459 - 460 - contentType := resp.Header.Get("Content-Type") 461 - 462 - body := resp.Body 463 - bodyBytes, err := io.ReadAll(body) 464 - if err != nil { 465 - log.Printf("error when fetching external image from %s: %v", url, err) 466 - return nil, false 467 - } 468 - 469 - // Handle SVG separately 470 - if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 471 - return convertSVGToPNG(bodyBytes) 472 - } 473 - 474 - // Support content types are in-sync with the allowed custom avatar file types 475 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 476 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 477 - return nil, false 478 - } 479 - 480 - bodyBuffer := bytes.NewReader(bodyBytes) 481 - _, imgType, err := image.DecodeConfig(bodyBuffer) 482 - if err != nil { 483 - log.Printf("error when decoding external image from %s: %v", url, err) 484 - return nil, false 485 - } 486 - 487 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 488 - if (contentType == "image/png" && imgType != "png") || 489 - (contentType == "image/jpeg" && imgType != "jpeg") || 490 - (contentType == "image/gif" && imgType != "gif") || 491 - (contentType == "image/webp" && imgType != "webp") { 492 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 493 - return nil, false 494 - } 495 - 496 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 497 - if err != nil { 498 - log.Printf("error w/ bodyBuffer.Seek") 499 - return nil, false 500 - } 501 - img, _, err := image.Decode(bodyBuffer) 502 - if err != nil { 503 - log.Printf("error when decoding external image from %s: %v", url, err) 504 - return nil, false 505 - } 506 - 507 - return img, true 508 - } 509 - 510 - // convertSVGToPNG converts SVG data to a PNG image 511 - func convertSVGToPNG(svgData []byte) (image.Image, bool) { 512 - // Parse the SVG 513 - icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 514 - if err != nil { 515 - log.Printf("error parsing SVG: %v", err) 516 - return nil, false 517 - } 518 - 519 - // Set a reasonable size for the rasterized image 520 - width := 256 521 - height := 256 522 - icon.SetTarget(0, 0, float64(width), float64(height)) 523 - 524 - // Create an image to draw on 525 - rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 526 - 527 - // Fill with white background 528 - draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 529 - 530 - // Create a scanner and rasterize the SVG 531 - scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 532 - raster := rasterx.NewDasher(width, height, scanner) 533 - 534 - icon.Draw(raster, 1.0) 535 - 536 - return rgba, true 537 - } 538 - 539 - func (c *Card) DrawExternalImage(url string) { 540 - image, ok := c.fetchExternalImage(url) 541 - if !ok { 542 - image = fallbackImage() 543 - } 544 - c.DrawImage(image) 545 - } 546 - 547 - // DrawCircularExternalImage draws an external image as a circle at the specified position 548 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 549 - img, ok := c.fetchExternalImage(url) 550 - if !ok { 551 - img = fallbackImage() 552 - } 553 - 554 - // Create a circular mask 555 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 556 - center := size / 2 557 - radius := float64(size / 2) 558 - 559 - // Scale the source image to fit the circle 560 - srcBounds := img.Bounds() 561 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 562 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 563 - 564 - // Draw the image with circular clipping 565 - for cy := range size { 566 - for cx := range size { 567 - // Calculate distance from center 568 - dx := float64(cx - center) 569 - dy := float64(cy - center) 570 - distance := math.Sqrt(dx*dx + dy*dy) 571 - 572 - // Only draw pixels within the circle 573 - if distance <= radius { 574 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 575 - } 576 - } 577 - } 578 - 579 - // Draw the circle onto the card 580 - bounds := c.Img.Bounds() 581 - destRect := image.Rect(x, y, x+size, y+size) 582 - 583 - // Make sure we don't draw outside the card bounds 584 - if destRect.Max.X > bounds.Max.X { 585 - destRect.Max.X = bounds.Max.X 586 - } 587 - if destRect.Max.Y > bounds.Max.Y { 588 - destRect.Max.Y = bounds.Max.Y 589 - } 590 - 591 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 592 - 593 - return nil 594 - } 595 - 596 - // DrawRect draws a rect with the given color 597 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 598 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 599 - } 600 - 601 - // drawRoundedRect draws a filled rounded rectangle on the given card 602 - func (card *Card) DrawRoundedRect(x, y, width, height, cornerRadius int, fillColor color.RGBA) { 603 - cardBounds := card.Img.Bounds() 604 - for py := y; py < y+height; py++ { 605 - for px := x; px < x+width; px++ { 606 - // calculate distance from corners 607 - dx := 0 608 - dy := 0 609 - 610 - // check which corner region we're in 611 - if px < x+cornerRadius && py < y+cornerRadius { 612 - // top-left corner 613 - dx = x + cornerRadius - px 614 - dy = y + cornerRadius - py 615 - } else if px >= x+width-cornerRadius && py < y+cornerRadius { 616 - // top-right corner 617 - dx = px - (x + width - cornerRadius - 1) 618 - dy = y + cornerRadius - py 619 - } else if px < x+cornerRadius && py >= y+height-cornerRadius { 620 - // bottom-left corner 621 - dx = x + cornerRadius - px 622 - dy = py - (y + height - cornerRadius - 1) 623 - } else if px >= x+width-cornerRadius && py >= y+height-cornerRadius { 624 - // Bottom-right corner 625 - dx = px - (x + width - cornerRadius - 1) 626 - dy = py - (y + height - cornerRadius - 1) 627 - } 628 - 629 - // if we're in a corner, check if we're within the radius 630 - inCorner := (dx > 0 || dy > 0) 631 - withinRadius := dx*dx+dy*dy <= cornerRadius*cornerRadius 632 - 633 - // draw pixel if not in corner, or in corner and within radius 634 - // check bounds relative to the card's image bounds 635 - if (!inCorner || withinRadius) && px >= 0 && px < cardBounds.Dx() && py >= 0 && py < cardBounds.Dy() { 636 - card.Img.Set(px+cardBounds.Min.X, py+cardBounds.Min.Y, fillColor) 637 - } 638 - } 639 - } 640 - }
+117
appview/ogcard/client.go
··· 1 + package ogcard 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "time" 11 + ) 12 + 13 + type Client struct { 14 + host string 15 + client *http.Client 16 + } 17 + 18 + func NewClient(host string) *Client { 19 + return &Client{ 20 + host: host, 21 + client: &http.Client{ 22 + Timeout: 10 * time.Second, 23 + }, 24 + } 25 + } 26 + 27 + type LabelData struct { 28 + Name string `json:"name"` 29 + Color string `json:"color"` 30 + } 31 + 32 + type LanguageData struct { 33 + Color string `json:"color"` 34 + Percentage float32 `json:"percentage"` 35 + } 36 + 37 + type RepositoryCardPayload struct { 38 + Type string `json:"type"` 39 + RepoName string `json:"repoName"` 40 + OwnerHandle string `json:"ownerHandle"` 41 + Stars int `json:"stars"` 42 + Pulls int `json:"pulls"` 43 + Issues int `json:"issues"` 44 + UpdatedAt string `json:"updatedAt"` 45 + AvatarUrl string `json:"avatarUrl"` 46 + Languages []LanguageData `json:"languages"` 47 + } 48 + 49 + type IssueCardPayload struct { 50 + Type string `json:"type"` 51 + RepoName string `json:"repoName"` 52 + OwnerHandle string `json:"ownerHandle"` 53 + AvatarUrl string `json:"avatarUrl"` 54 + Title string `json:"title"` 55 + IssueNumber int `json:"issueNumber"` 56 + Status string `json:"status"` 57 + Labels []LabelData `json:"labels"` 58 + CommentCount int `json:"commentCount"` 59 + ReactionCount int `json:"reactionCount"` 60 + CreatedAt string `json:"createdAt"` 61 + } 62 + 63 + type PullRequestCardPayload struct { 64 + Type string `json:"type"` 65 + RepoName string `json:"repoName"` 66 + OwnerHandle string `json:"ownerHandle"` 67 + AvatarUrl string `json:"avatarUrl"` 68 + Title string `json:"title"` 69 + PullRequestNumber int `json:"pullRequestNumber"` 70 + Status string `json:"status"` 71 + FilesChanged int `json:"filesChanged"` 72 + Additions int `json:"additions"` 73 + Deletions int `json:"deletions"` 74 + Rounds int `json:"rounds"` 75 + CommentCount int `json:"commentCount"` 76 + ReactionCount int `json:"reactionCount"` 77 + CreatedAt string `json:"createdAt"` 78 + } 79 + 80 + func (c *Client) doRequest(ctx context.Context, path string, payload any) ([]byte, error) { 81 + body, err := json.Marshal(payload) 82 + if err != nil { 83 + return nil, fmt.Errorf("marshal payload: %w", err) 84 + } 85 + 86 + url := fmt.Sprintf("%s/%s", c.host, path) 87 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 88 + if err != nil { 89 + return nil, fmt.Errorf("create request: %w", err) 90 + } 91 + req.Header.Set("Content-Type", "application/json") 92 + 93 + resp, err := c.client.Do(req) 94 + if err != nil { 95 + return nil, fmt.Errorf("do request: %w", err) 96 + } 97 + defer resp.Body.Close() 98 + 99 + if resp.StatusCode != http.StatusOK { 100 + respBody, _ := io.ReadAll(resp.Body) 101 + return nil, fmt.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(respBody)) 102 + } 103 + 104 + return io.ReadAll(resp.Body) 105 + } 106 + 107 + func (c *Client) RenderRepositoryCard(ctx context.Context, payload RepositoryCardPayload) ([]byte, error) { 108 + return c.doRequest(ctx, "repository", payload) 109 + } 110 + 111 + func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { 112 + return c.doRequest(ctx, "issue", payload) 113 + } 114 + 115 + func (c *Client) RenderPullRequestCard(ctx context.Context, payload PullRequestCardPayload) ([]byte, error) { 116 + return c.doRequest(ctx, "pullRequest", payload) 117 + }
+28
appview/ogcard/package.json
··· 1 + { 2 + "name": "@tangled/ogcard-worker", 3 + "version": "1.0.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "dev": "wrangler dev", 8 + "deploy": "wrangler deploy", 9 + "typecheck": "tsc --noEmit", 10 + "test": "bun test", 11 + "test:fonts": "tsx scripts/test-fonts.ts" 12 + }, 13 + "dependencies": { 14 + "@resvg/resvg-js": "^2.6.2", 15 + "@resvg/resvg-wasm": "^2.6.2", 16 + "lucide-static": "^0.577.0", 17 + "preact": "^10.29.0", 18 + "satori": "^0.25.0", 19 + "zod": "^4.3.6" 20 + }, 21 + "devDependencies": { 22 + "@cloudflare/workers-types": "^4.20260317.1", 23 + "@types/node": "^25.5.0", 24 + "tsx": "^4.21.0", 25 + "typescript": "^5.9.3", 26 + "wrangler": "^4.75.0" 27 + } 28 + }
appview/ogcard/src/__tests__/assets/avatar.jpg

This is a binary file and will not be displayed.

+70
appview/ogcard/src/__tests__/fixtures.ts
··· 1 + import type { RepositoryCardData, IssueCardData, PullRequestCardData } from "../validation"; 2 + 3 + const LONG_TITLE = "Fix critical memory leak in WebSocket connection handler that causes server crashes under high load conditions in production environments"; 4 + 5 + export const createRepoData = (avatarUrl: string): RepositoryCardData => ({ 6 + type: "repository", 7 + repoName: "lexicons", 8 + ownerHandle: "standard.site", 9 + stars: 34, 10 + pulls: 3, 11 + issues: 8, 12 + updatedAt: "updated 2 hours ago", 13 + avatarUrl, 14 + languages: [ 15 + { color: "#3178c6", percentage: 65 }, 16 + { color: "#f1e05a", percentage: 20 }, 17 + { color: "#e34c26", percentage: 10 }, 18 + { color: "#cccccc", percentage: 3 }, 19 + { color: "#cccccc", percentage: 2 }, 20 + ], 21 + }); 22 + 23 + export const createIssueData = (avatarUrl: string, overrides?: Partial<IssueCardData>): IssueCardData => ({ 24 + type: "issue", 25 + repoName: "lexicons", 26 + ownerHandle: "standard.site", 27 + avatarUrl, 28 + title: "blobContent field in addition to textContent", 29 + issueNumber: 8, 30 + status: "open", 31 + labels: [ 32 + { name: "bug", color: "#d73a4a" }, 33 + { name: "help-wanted", color: "#008672" }, 34 + { name: "enhancement", color: "#0052cc" }, 35 + ], 36 + commentCount: 12, 37 + reactionCount: 5, 38 + createdAt: "29 jan 2026", 39 + ...overrides, 40 + }); 41 + 42 + export const createPullRequestData = (avatarUrl: string, overrides?: Partial<PullRequestCardData>): PullRequestCardData => ({ 43 + type: "pullRequest", 44 + repoName: "lexicons", 45 + ownerHandle: "standard.site", 46 + avatarUrl, 47 + title: "Add author lexicon", 48 + pullRequestNumber: 1, 49 + status: "open", 50 + filesChanged: 2, 51 + additions: 116, 52 + deletions: 59, 53 + rounds: 3, 54 + commentCount: 12, 55 + reactionCount: 31, 56 + createdAt: "29 jan 2026", 57 + ...overrides, 58 + }); 59 + 60 + export const createLongTitleIssueData = (avatarUrl: string, overrides?: Partial<IssueCardData>): IssueCardData => ({ 61 + ...createIssueData(avatarUrl), 62 + title: LONG_TITLE, 63 + ...overrides, 64 + }); 65 + 66 + export const createLongTitlePullRequestData = (avatarUrl: string, overrides?: Partial<PullRequestCardData>): PullRequestCardData => ({ 67 + ...createPullRequestData(avatarUrl), 68 + title: LONG_TITLE, 69 + ...overrides, 70 + });
+135
appview/ogcard/src/__tests__/render.test.ts
··· 1 + import { test, describe, beforeAll } from "bun:test"; 2 + import { writeFileSync, mkdirSync, readFileSync } from "fs"; 3 + import { join } from "path"; 4 + import { h } from "preact"; 5 + import { renderCard } from "../lib/render"; 6 + import { RepositoryCard } from "../components/cards/repository"; 7 + import { IssueCard } from "../components/cards/issue"; 8 + import { PullRequestCard } from "../components/cards/pull-request"; 9 + import { 10 + repositoryCardSchema, 11 + issueCardSchema, 12 + pullRequestCardSchema, 13 + } from "../validation"; 14 + import { 15 + createRepoData, 16 + createIssueData, 17 + createPullRequestData, 18 + createLongTitleIssueData, 19 + createLongTitlePullRequestData, 20 + } from "./fixtures"; 21 + 22 + const outputDir = join(process.cwd(), "output"); 23 + let avatarDataUri: string; 24 + 25 + const loadAvatar = (): string => { 26 + const avatarPath = join( 27 + process.cwd(), 28 + "src", 29 + "__tests__", 30 + "assets", 31 + "avatar.jpg", 32 + ); 33 + const avatarBase64 = readFileSync(avatarPath).toString("base64"); 34 + return `data:image/jpeg;base64,${avatarBase64}`; 35 + }; 36 + 37 + beforeAll(() => { 38 + mkdirSync(outputDir, { recursive: true }); 39 + avatarDataUri = loadAvatar(); 40 + }); 41 + 42 + const savePng = (filename: string, buffer: Buffer) => { 43 + writeFileSync(join(outputDir, filename), buffer); 44 + }; 45 + 46 + const renderAndSave = async <T>( 47 + component: ReturnType<typeof h<T>>, 48 + filename: string, 49 + ) => { 50 + const { png } = await renderCard(component as any); 51 + savePng(filename, png); 52 + }; 53 + 54 + describe("repository card", () => { 55 + test("renders open repository card", async () => { 56 + const data = createRepoData(avatarDataUri); 57 + const validated = repositoryCardSchema.parse(data); 58 + await renderAndSave(h(RepositoryCard, validated), "repository-card.png"); 59 + }); 60 + }); 61 + 62 + describe("issue cards", () => { 63 + test("renders open issue", async () => { 64 + const data = createIssueData(avatarDataUri); 65 + const validated = issueCardSchema.parse(data); 66 + await renderAndSave(h(IssueCard, validated), "issue-card.png"); 67 + }); 68 + 69 + test("renders closed issue", async () => { 70 + const data = createIssueData(avatarDataUri, { 71 + issueNumber: 5, 72 + status: "closed", 73 + labels: [{ name: "wontfix", color: "#6a737d" }], 74 + reactionCount: 2, 75 + }); 76 + const validated = issueCardSchema.parse(data); 77 + await renderAndSave(h(IssueCard, validated), "issue-card-closed.png"); 78 + }); 79 + 80 + test("renders issue with long title", async () => { 81 + const data = createLongTitleIssueData(avatarDataUri, { 82 + issueNumber: 42, 83 + }); 84 + const validated = issueCardSchema.parse(data); 85 + await renderAndSave(h(IssueCard, validated), "issue-card-long-title.png"); 86 + }); 87 + }); 88 + 89 + describe("pull request cards", () => { 90 + test("renders open pull request", async () => { 91 + const data = createPullRequestData(avatarDataUri); 92 + const validated = pullRequestCardSchema.parse(data); 93 + await renderAndSave(h(PullRequestCard, validated), "pull-request-card.png"); 94 + }); 95 + 96 + test("renders merged pull request", async () => { 97 + const data = createPullRequestData(avatarDataUri, { 98 + pullRequestNumber: 2, 99 + status: "merged", 100 + title: "Implement OAuth2 authentication flow", 101 + filesChanged: 5, 102 + additions: 342, 103 + deletions: 28, 104 + }); 105 + const validated = pullRequestCardSchema.parse(data); 106 + await renderAndSave( 107 + h(PullRequestCard, validated), 108 + "pull-request-card-merged.png", 109 + ); 110 + }); 111 + 112 + test("renders closed pull request", async () => { 113 + const data = createPullRequestData(avatarDataUri, { 114 + pullRequestNumber: 3, 115 + status: "closed", 116 + title: "WIP: Experimental feature", 117 + }); 118 + const validated = pullRequestCardSchema.parse(data); 119 + await renderAndSave( 120 + h(PullRequestCard, validated), 121 + "pull-request-card-closed.png", 122 + ); 123 + }); 124 + 125 + test("renders pull request with long title", async () => { 126 + const data = createLongTitlePullRequestData(avatarDataUri, { 127 + pullRequestNumber: 42, 128 + }); 129 + const validated = pullRequestCardSchema.parse(data); 130 + await renderAndSave( 131 + h(PullRequestCard, validated), 132 + "pull-request-card-long-title.png", 133 + ); 134 + }); 135 + });
+54
appview/ogcard/src/components/cards/issue.tsx
··· 1 + import { Card, Row, Col } from "../shared/layout"; 2 + import { TangledLogo } from "../shared/logo"; 3 + import { IssueStatusBadge } from "../shared/status-badge"; 4 + import { CardHeader } from "../shared/card-header"; 5 + import { LabelList } from "../shared/label-pill"; 6 + import { FooterStats } from "../shared/footer-stats"; 7 + import type { IssueCardData } from "../../validation"; 8 + 9 + export function IssueCard(data: IssueCardData) { 10 + return ( 11 + <Card style={{ justifyContent: "space-between" }}> 12 + <Col style={{ gap: 48 }}> 13 + <Col style={{ gap: 32 }}> 14 + <Row style={{ justifyContent: "space-between" }}> 15 + <CardHeader 16 + avatarUrl={data.avatarUrl} 17 + ownerHandle={data.ownerHandle} 18 + repoName={data.repoName} 19 + /> 20 + <IssueStatusBadge status={data.status} /> 21 + </Row> 22 + 23 + <div 24 + style={{ 25 + fontFamily: "Inter", 26 + fontSize: 64, 27 + fontWeight: 600, 28 + color: "#000000", 29 + display: "block", 30 + lineClamp: `2 "... #${data.issueNumber}"`, 31 + }}> 32 + {data.title} 33 + </div> 34 + </Col> 35 + 36 + <LabelList labels={data.labels} /> 37 + </Col> 38 + 39 + <div 40 + style={{ 41 + display: "flex", 42 + alignItems: "flex-end", 43 + justifyContent: "space-between", 44 + }}> 45 + <FooterStats 46 + createdAt={data.createdAt} 47 + reactionCount={data.reactionCount} 48 + commentCount={data.commentCount} 49 + /> 50 + <TangledLogo /> 51 + </div> 52 + </Card> 53 + ); 54 + }
+162
appview/ogcard/src/components/cards/pull-request.tsx
··· 1 + import { Card, Row, Col } from "../shared/layout"; 2 + import { TangledLogo } from "../shared/logo"; 3 + import { StatusBadge } from "../shared/status-badge"; 4 + import { CardHeader } from "../shared/card-header"; 5 + import { FooterStats } from "../shared/footer-stats"; 6 + import { FileDiff, RefreshCw } from "../../icons/lucide"; 7 + import { COLORS } from "../shared/constants"; 8 + import type { PullRequestCardData } from "../../validation"; 9 + 10 + interface FilesChangedPillProps { 11 + filesChanged: number; 12 + additions: number; 13 + deletions: number; 14 + } 15 + 16 + function FilesChangedPill({ 17 + filesChanged, 18 + additions, 19 + deletions, 20 + }: FilesChangedPillProps) { 21 + return ( 22 + <Row 23 + style={{ 24 + overflow: "hidden", 25 + borderRadius: 18, 26 + backgroundColor: "#fff", 27 + border: `4px solid ${COLORS.label.border}`, 28 + }}> 29 + <Row 30 + style={{ 31 + gap: 16, 32 + padding: "16px 28px", 33 + }}> 34 + <FileDiff size={34} color="#202020" /> 35 + <span 36 + style={{ 37 + fontFamily: "Inter", 38 + fontSize: 36, 39 + color: "#202020", 40 + fontWeight: 400, 41 + }}> 42 + {filesChanged} files 43 + </span> 44 + </Row> 45 + <Row style={{ gap: 0 }}> 46 + <Row 47 + style={{ 48 + padding: "16px 10px 16px 11px", 49 + backgroundColor: COLORS.diff.additions.bg, 50 + }}> 51 + <span 52 + style={{ 53 + fontFamily: "Inter", 54 + fontSize: 36, 55 + color: COLORS.diff.additions.text, 56 + fontWeight: 500, 57 + }}> 58 + +{additions} 59 + </span> 60 + </Row> 61 + <Row 62 + style={{ 63 + padding: "16px 16px 16px 11px", 64 + backgroundColor: COLORS.diff.deletions.bg, 65 + }}> 66 + <span 67 + style={{ 68 + fontFamily: "Inter", 69 + fontSize: 36, 70 + color: COLORS.diff.deletions.text, 71 + fontWeight: 500, 72 + }}> 73 + -{deletions} 74 + </span> 75 + </Row> 76 + </Row> 77 + </Row> 78 + ); 79 + } 80 + 81 + interface MetricPillProps { 82 + value: number; 83 + label: string; 84 + } 85 + 86 + function RoundsPill({ value, label }: MetricPillProps) { 87 + return ( 88 + <Row 89 + style={{ 90 + gap: 16, 91 + padding: "16px 28px", 92 + borderRadius: 18, 93 + backgroundColor: "#fff", 94 + border: `4px solid ${COLORS.label.border}`, 95 + }}> 96 + <RefreshCw size={36} color="#202020" /> 97 + <span 98 + style={{ 99 + fontFamily: "Inter", 100 + fontSize: 36, 101 + color: "#202020", 102 + fontWeight: 400, 103 + }}> 104 + {value} {label} 105 + </span> 106 + </Row> 107 + ); 108 + } 109 + 110 + export function PullRequestCard(data: PullRequestCardData) { 111 + return ( 112 + <Card style={{ justifyContent: "space-between" }}> 113 + <Col style={{ gap: 48 }}> 114 + <Col style={{ gap: 32 }}> 115 + <Row style={{ justifyContent: "space-between" }}> 116 + <CardHeader 117 + avatarUrl={data.avatarUrl} 118 + ownerHandle={data.ownerHandle} 119 + repoName={data.repoName} 120 + /> 121 + <StatusBadge status={data.status} /> 122 + </Row> 123 + 124 + <span 125 + style={{ 126 + fontFamily: "Inter", 127 + fontSize: 64, 128 + fontWeight: 600, 129 + color: "#000000", 130 + display: "block", 131 + lineClamp: `2 "... #${data.pullRequestNumber}"`, 132 + }}> 133 + {data.title} 134 + </span> 135 + </Col> 136 + 137 + <Row style={{ gap: 16 }}> 138 + <FilesChangedPill 139 + filesChanged={data.filesChanged} 140 + additions={data.additions} 141 + deletions={data.deletions} 142 + /> 143 + <RoundsPill value={data.rounds} label="rounds" /> 144 + </Row> 145 + </Col> 146 + 147 + <div 148 + style={{ 149 + display: "flex", 150 + alignItems: "flex-end", 151 + justifyContent: "space-between", 152 + }}> 153 + <FooterStats 154 + createdAt={data.createdAt} 155 + reactionCount={data.reactionCount} 156 + commentCount={data.commentCount} 157 + /> 158 + <TangledLogo /> 159 + </div> 160 + </Card> 161 + ); 162 + }
+61
appview/ogcard/src/components/cards/repository.tsx
··· 1 + import { Card, Row, Col } from "../shared/layout"; 2 + import { Avatar } from "../shared/avatar"; 3 + import { LanguageCircles } from "../shared/language-circles"; 4 + import { Metrics } from "../shared/metrics"; 5 + import { TangledLogo } from "../shared/logo"; 6 + import type { RepositoryCardData } from "../../validation"; 7 + 8 + export function RepositoryCard(data: RepositoryCardData) { 9 + return ( 10 + <Card> 11 + <LanguageCircles languages={data.languages} /> 12 + 13 + <Col style={{ gap: 64 }}> 14 + <Col style={{ gap: 24 }}> 15 + <span 16 + style={{ 17 + fontFamily: "Inter", 18 + fontSize: 144, 19 + fontWeight: 600, 20 + color: "#000000", 21 + }}> 22 + {data.repoName} 23 + </span> 24 + 25 + <Row style={{ gap: 16 }}> 26 + <Avatar src={data.avatarUrl} size={64} /> 27 + <span 28 + style={{ 29 + fontFamily: "Inter", 30 + fontSize: 48, 31 + fontWeight: 500, 32 + color: "#000000", 33 + }}> 34 + {data.ownerHandle} 35 + </span> 36 + </Row> 37 + </Col> 38 + 39 + <Metrics stars={data.stars} pulls={data.pulls} issues={data.issues} /> 40 + </Col> 41 + 42 + <Row 43 + style={{ 44 + alignItems: "flex-end", 45 + justifyContent: "space-between", 46 + flexGrow: 1, 47 + }}> 48 + <div 49 + style={{ 50 + fontFamily: "Inter", 51 + fontSize: 32, 52 + color: "#7D7D7D", 53 + }}> 54 + {data.updatedAt} 55 + </div> 56 + 57 + <TangledLogo /> 58 + </Row> 59 + </Card> 60 + ); 61 + }
+30
appview/ogcard/src/components/shared/avatar.tsx
··· 1 + interface AvatarProps { 2 + src: string; 3 + size?: number; 4 + } 5 + 6 + export function Avatar({ src, size = 64 }: AvatarProps) { 7 + const avatarSrc = src.includes("avatar.tangled.sh") && !src.includes("format=") 8 + ? `${src}${src.includes("?") ? "&" : "?"}format=jpeg` 9 + : src; 10 + 11 + return ( 12 + <div 13 + style={{ 14 + width: size, 15 + height: size, 16 + borderRadius: size / 2, 17 + overflow: "hidden", 18 + display: "flex", 19 + alignItems: "center", 20 + justifyContent: "center", 21 + }}> 22 + <img 23 + src={avatarSrc} 24 + width={size} 25 + height={size} 26 + style={{ objectFit: "cover" }} 27 + /> 28 + </div> 29 + ); 30 + }
+25
appview/ogcard/src/components/shared/card-header.tsx
··· 1 + import { Row } from "./layout"; 2 + import { Avatar } from "./avatar"; 3 + 4 + interface CardHeaderProps { 5 + avatarUrl: string; 6 + ownerHandle: string; 7 + repoName: string; 8 + } 9 + 10 + export function CardHeader({ avatarUrl, ownerHandle, repoName }: CardHeaderProps) { 11 + return ( 12 + <Row style={{ gap: 16 }}> 13 + <Avatar src={avatarUrl} size={64} /> 14 + <span 15 + style={{ 16 + fontFamily: "Inter", 17 + fontSize: 48, 18 + color: "#000000", 19 + fontWeight: 500, 20 + }}> 21 + {ownerHandle} / {repoName} 22 + </span> 23 + </Row> 24 + ); 25 + }
+26
appview/ogcard/src/components/shared/constants.ts
··· 1 + export const COLORS = { 2 + text: "#000000", 3 + textSecondary: "#7D7D7D", 4 + icon: "#404040", 5 + status: { 6 + open: { bg: "#16A34A", text: "#ffffff" }, 7 + closed: { bg: "#1f2937", text: "#ffffff" }, 8 + merged: { bg: "#7C3AED", text: "#ffffff" }, 9 + }, 10 + label: { 11 + text: "#202020", 12 + border: "#E6E6E6", 13 + }, 14 + diff: { 15 + additions: { bg: "#dcfce7", text: "#15803d" }, 16 + deletions: { bg: "#fee2e2", text: "#b91c1c" }, 17 + }, 18 + } as const; 19 + 20 + export const TYPOGRAPHY = { 21 + title: { fontSize: 64, fontWeight: 600 }, 22 + repoName: { fontSize: 144, fontWeight: 600 }, 23 + ownerHandle: { fontSize: 48, fontWeight: 500 }, 24 + body: { fontSize: 36, fontWeight: 400 }, 25 + meta: { fontSize: 32, fontWeight: 400 }, 26 + } as const;
+23
appview/ogcard/src/components/shared/footer-stats.tsx
··· 1 + import { Row } from "./layout"; 2 + import { Calendar, MessageSquare, SmilePlus } from "../../icons/lucide"; 3 + import { StatItem } from "./stat-item"; 4 + 5 + interface FooterStatsProps { 6 + createdAt: string; 7 + reactionCount: number; 8 + commentCount: number; 9 + } 10 + 11 + export function FooterStats({ 12 + createdAt, 13 + reactionCount, 14 + commentCount, 15 + }: FooterStatsProps) { 16 + return ( 17 + <Row style={{ gap: 64 }}> 18 + <StatItem Icon={Calendar} value={createdAt} /> 19 + <StatItem Icon={SmilePlus} value={reactionCount} /> 20 + <StatItem Icon={MessageSquare} value={commentCount} /> 21 + </Row> 22 + ); 23 + }
+55
appview/ogcard/src/components/shared/label-pill.tsx
··· 1 + import { Row } from "./layout"; 2 + import { COLORS } from "./constants"; 3 + 4 + interface LabelPillProps { 5 + name: string; 6 + color: string; 7 + } 8 + 9 + export function LabelPill({ name, color }: LabelPillProps) { 10 + return ( 11 + <Row 12 + style={{ 13 + gap: 16, 14 + padding: "16px 28px", 15 + borderRadius: 18, 16 + backgroundColor: "#fff", 17 + border: `4px solid ${COLORS.label.border}`, 18 + }}> 19 + <div 20 + style={{ 21 + width: 24, 22 + height: 24, 23 + borderRadius: "50%", 24 + backgroundColor: color, 25 + }} 26 + /> 27 + <span 28 + style={{ 29 + fontFamily: "Inter", 30 + fontSize: 36, 31 + color: COLORS.label.text, 32 + fontWeight: 400, 33 + }}> 34 + {name} 35 + </span> 36 + </Row> 37 + ); 38 + } 39 + 40 + interface LabelListProps { 41 + labels: Array<{ name: string; color: string }>; 42 + max?: number; 43 + } 44 + 45 + export function LabelList({ labels, max = 5 }: LabelListProps) { 46 + if (labels.length === 0) return null; 47 + 48 + return ( 49 + <Row style={{ gap: 12 }}> 50 + {labels.slice(0, max).map((label, i) => ( 51 + <LabelPill key={i} name={label.name} color={label.color} /> 52 + ))} 53 + </Row> 54 + ); 55 + }
+51
appview/ogcard/src/components/shared/language-circles.tsx
··· 1 + import type { Language } from "../../validation"; 2 + 3 + interface LanguageCirclesProps { 4 + languages: Language[]; 5 + } 6 + 7 + const MAX_RADIUS = 380; 8 + const MIN_RADIUS = 85; 9 + const RADIUS_STEP = (MAX_RADIUS - MIN_RADIUS) / 4; 10 + 11 + function languageToRadius(percentage: number, maxRadius: number = MAX_RADIUS): number { 12 + const minRadius = MIN_RADIUS; 13 + const scaledRadius = minRadius + (percentage / 100) * (maxRadius - minRadius); 14 + return Math.max(minRadius, Math.min(maxRadius, scaledRadius)); 15 + } 16 + 17 + export function LanguageCircles({ languages }: LanguageCirclesProps) { 18 + const sortedLanguages = [...languages] 19 + .sort((a, b) => b.percentage - a.percentage) 20 + .slice(0, 5); 21 + 22 + return ( 23 + <div 24 + style={{ 25 + position: "absolute", 26 + right: 0, 27 + top: 0, 28 + width: 0, 29 + height: 0, 30 + display: "flex", 31 + }}> 32 + {sortedLanguages.map((lang, i) => { 33 + const r = languageToRadius(lang.percentage); 34 + return ( 35 + <div 36 + key={i} 37 + style={{ 38 + position: "absolute", 39 + right: -r, 40 + top: -r, 41 + width: r * 2, 42 + height: r * 2, 43 + borderRadius: "50%", 44 + background: lang.color, 45 + }} 46 + /> 47 + ); 48 + })} 49 + </div> 50 + ); 51 + }
+45
appview/ogcard/src/components/shared/layout.tsx
··· 1 + import type { ComponentChildren } from "preact"; 2 + 3 + interface StyleProps { 4 + style?: Record<string, string | number>; 5 + children?: ComponentChildren; 6 + } 7 + 8 + export function Card({ children, style }: StyleProps) { 9 + return ( 10 + <div 11 + style={{ 12 + width: 1200, 13 + height: 630, 14 + background: "white", 15 + display: "flex", 16 + flexDirection: "column", 17 + padding: 48, 18 + ...style, 19 + }}> 20 + {children} 21 + </div> 22 + ); 23 + } 24 + 25 + export function Row({ children, style }: StyleProps) { 26 + return ( 27 + <div 28 + style={{ 29 + display: "flex", 30 + flexDirection: "row", 31 + alignItems: "center", 32 + ...style, 33 + }}> 34 + {children} 35 + </div> 36 + ); 37 + } 38 + 39 + export function Col({ children, style }: StyleProps) { 40 + return ( 41 + <div style={{ display: "flex", flexDirection: "column", ...style }}> 42 + {children} 43 + </div> 44 + ); 45 + }
+69
appview/ogcard/src/components/shared/logo.tsx
··· 1 + // Tangled logo - user will customize this 2 + export function TangledLogo() { 3 + return ( 4 + <div 5 + style={{ 6 + width: 256, 7 + height: 70, 8 + display: "contents", 9 + }}> 10 + <svg 11 + width="256" 12 + height="70" 13 + viewBox="0 0 256 70" 14 + fill="none" 15 + xmlns="http://www.w3.org/2000/svg"> 16 + <path 17 + d="M38.6562 30.0449L39.168 30.2402L39.6807 30.4346L40.0234 30.8076L40.3672 31.1807L40.4922 31.7363L40.6162 32.292L39.9473 35.0566L39.7441 36.6553L39.5049 41.5752L39.3906 42.0312L39.2773 42.4863L38.9443 42.8193L38.6104 43.1514L37.9229 43.4463L37.3574 43.4414L36.793 43.4375L36.4531 43.2549L36.1143 43.0723L35.7949 42.6689L35.4756 42.2666L35.3438 41.7139L35.2109 41.1621L35.3564 37.832L35.5674 35.2646L36.002 33.0186L36.165 32.3838L36.3271 31.748L36.7324 30.9541L37.5293 30.2979L38.0928 30.1719L38.6562 30.0449Z" 18 + fill="black" 19 + /> 20 + <path 21 + d="M30.5889 31.1201L30.8682 31.4277L31.1484 31.7354L31.2695 32.0986L31.3916 32.4619V33.3789L31.1904 33.9082L30.8477 35.5654L30.8486 38.1523L31.1074 40.5127L30.5547 41.7197L30.1074 42.0156L29.6611 42.3115L28.4502 42.3838L28.0098 42.1787L27.5693 41.9727L27.04 41.2539L26.9248 40.9336L26.8086 40.6123L26.6738 39.8008L26.4814 36.9756L26.6992 34.3018L26.8467 33.6553L26.9932 33.0078L27.4502 31.7852L28.0137 31.1162L28.8779 30.7236H29.7334L30.5889 31.1201Z" 22 + fill="black" 23 + /> 24 + <path 25 + fill-rule="evenodd" 26 + clip-rule="evenodd" 27 + d="M45.4551 0L48.0215 0.143555L50.1611 0.571289L51.8721 1.12793L53.5869 1.91602L55.0186 2.78613L56.5781 3.96191L58.3262 5.74609L59.2383 6.91309L59.6455 7.55957L60.0518 8.20605L60.5176 9.16797L60.9824 10.1309L61.2656 10.9355L61.5479 11.7402L61.9658 13.7363L61.999 13.7607L62.0332 13.7852L64.707 15.0918L66.5674 16.3906L68.4297 18.1523L69.5566 19.5645L70.5576 21.1123L71.4766 23.0723L72.0156 24.6768L72.5146 27.0293L72.5107 30.9873L72.1279 32.8848L71.8564 33.7539L71.584 34.623L70.8457 36.3145L69.9648 37.832L68.8193 39.3867L67.2871 41L65.5625 42.3418L63.6367 43.4707L63.5068 43.5762L63.376 43.6816L63.5449 44.0723L63.7148 44.4629L63.9775 45.1045L64.2393 45.7461L65.3809 50.1318L65.5762 51.041L65.7725 51.9492L65.7617 56.0137L65.2803 58.2598L64.7393 59.8643L63.5947 62.2168L62.2529 64.1426L61.3398 65.0898L60.4277 66.0381L58.7158 67.3643L58.0547 67.7627L57.3926 68.1602L56.6641 68.4814L55.9355 68.8018L55.668 68.9443L55.4004 69.0859L53.9033 69.5225L51.9668 69.8672L50.2666 69.8496L49.8389 69.8301L48.7695 69.8105L48.3955 69.8877L48.0205 69.9639L47.8271 69.8506L47.6328 69.7373L46.418 69.6748L45.5889 69.5967L44.7607 69.5176V69.3584L44.1455 69.2383L43.5303 69.1172L43.0264 68.876L42.5225 68.6338L42.5146 68.3857L40.9121 67.5215L39.252 66.2988L37.6768 64.7842L36.3486 63.0723L35.3242 61.3613L35.2842 61.3184L35.2441 61.2744L34.3906 62.2441L33.0488 63.5322L31.9307 64.4111L31.1709 64.9014L30.4121 65.3926L28.915 66.0859L28.9307 66.1836L28.9473 66.2812L28.4307 66.4189L27.915 66.5576L25.8828 67.165L24.8135 67.4365L22.46 67.5928L20.3213 67.3818L20.209 67.29L20.0967 67.1992L19.6338 67.3203L19.4824 67.168L19.3301 67.0166L18.7031 66.8779L18.0752 66.7383L16.2891 66.1172L14.332 65.1357L12.3652 63.7656L10.5332 62.0312L9.18457 60.292L8.41992 59.0078L7.78125 57.7246L7.02539 55.6924L6.52441 53.3398L6.52734 49.1689L6.91406 47.251L7.56738 45.2109L8.51465 43.292L8.72559 42.9502L8.93652 42.6094L8.34277 42.2012L7.80762 41.8965L7.27246 41.5928L5.66895 40.416L4.09082 38.9014L2.95703 37.4893L2.47852 36.748L1.99902 36.0078L1.61035 35.208L1.2207 34.4092L0.859375 33.3926L0.49707 32.377L0 30.0244L0.0126953 25.8525L0.610352 23.291L1.40625 21.2383L1.9082 20.3164L2.41113 19.3955L3.06738 18.5059L3.72461 17.6172L5.24121 16.04L5.93555 15.4834L6.63086 14.9277L8.02148 14.0195L9.95117 13.0859L11.0166 12.6934L12.0859 10.665L13.3633 8.84766L15.252 6.89062L16.0215 6.30273L16.792 5.71582L18.6221 4.63867L20.5225 3.85156L21.7842 3.44922L24.1719 2.97266L27.7012 2.99023L30.375 3.5459L32.1934 4.21094L33.1846 4.70605L34.1768 5.2002L35.6152 3.80762L36.5781 3.05176L37.0596 2.74512L37.541 2.4375L37.9688 2.19043L38.3965 1.94238L39.0918 1.61523L39.7861 1.28711L41.3906 0.693359L43.3164 0.251953L45.4551 0ZM39.8984 21.0156L39.7324 21.3662L38.7773 22.457L37.7549 23.1094L37.0898 23.3311L36.4248 23.5537H34.7402L33.5654 23.1494L33.2246 22.9727L32.8848 22.7969L32.1631 22.1328L31.4424 21.4678L31.1543 21.2139L30.8672 20.959L29.5518 22.1494L29.0537 22.3848L28.5566 22.6211L28.0225 22.7646L27.4873 22.9092L26.8281 22.9102L26.1699 22.9121L25.4941 22.7012L24.8184 22.4912L24.5127 22.7812L24.207 23.0723L23.8438 23.5645L23.4814 24.0576L22.9707 24.248L22.4609 24.4375L21.7676 24.9531L21.4385 25.2959L21.1104 25.6387L18.9746 28.8174L16.3428 34.3018L15.4658 36.334L15.1318 37.6572L15.084 38.2793L15.0371 38.9014L15.1396 39.6318L15.2432 40.3613L15.5322 40.9531L15.8223 41.5439L16.2539 41.9209L16.6855 42.2969L17.8115 42.8223L19.4658 42.834L20.1611 42.54L20.8564 42.2471L21.498 41.7891L23.71 40.0234L23.791 40.0732L23.8711 40.123L24.0127 42.9297L24.5439 46.0146L25.0791 48.2061L25.8701 50.0244L26.1865 50.5049L26.502 50.9863L27.167 51.7002L28.8779 52.9375L29.7461 53.377L31.0381 53.7109L32.5146 53.8965L33.1289 53.8584L33.7441 53.8213L34.3896 53.7686L35.0361 53.7168L36.2783 53.3086L37.0127 52.9541L37.7471 52.6006L39.3975 51.3564L40.7764 49.8105L42.0938 48.0986L43.2275 46.3877L44.5176 44.1416L45.3223 42.2656L45.4082 42.2129L45.4941 42.1592L46.6807 43.7354L47.6641 44.6426L48.2715 44.9209L48.8779 45.2002L49.7871 45.2432L50.6963 45.2852L51.9795 44.8027L53.1855 43.5723L53.4639 42.7344L53.7412 41.8955L54.0879 40.0781L53.9785 37.5107L53.5176 34.9492L53.001 33.0186L52.1523 30.5996L51.1514 28.7402L50.4463 27.7783L50.0723 27.418L49.6982 27.0586L49.8115 25.96L49.4424 24.4629L48.6377 22.751L47.5391 21.5381L46.4971 20.7734L45.8828 21.1426L45.2686 21.5127L43.6631 21.8701L43.0449 21.8047L42.4268 21.7402L41.0137 21.209L40.6572 20.9375L40.3018 20.666H40.0645L39.8984 21.0156Z" 28 + fill="black" 29 + /> 30 + <path 31 + fill-rule="evenodd" 32 + clip-rule="evenodd" 33 + d="M171.79 22.4316C173.259 22.4316 174.489 22.6826 175.479 23.1836C176.47 23.6732 177.268 24.2882 177.871 25.0283C178.486 25.7568 178.958 26.474 179.288 27.1797H179.562V22.7734H186.787V49.2646C186.787 51.4507 186.24 53.2782 185.147 54.7471C184.054 56.2273 182.539 57.3432 180.604 58.0947C178.679 58.8462 176.465 59.2226 173.96 59.2227C171.603 59.2227 169.582 58.9032 167.896 58.2656C166.223 57.628 164.89 56.7683 163.899 55.6865C162.909 54.6049 162.266 53.4037 161.97 52.083L168.699 51.1777C168.904 51.6559 169.229 52.1061 169.673 52.5273C170.117 52.9598 170.703 53.3127 171.432 53.5859C172.172 53.8592 173.071 53.9961 174.13 53.9961C175.713 53.9961 177.017 53.6197 178.042 52.8682C179.078 52.1166 179.596 50.8813 179.596 49.1621V44.3623H179.288C178.969 45.0911 178.491 45.7806 177.854 46.4297C177.216 47.0785 176.396 47.6077 175.395 48.0176C174.393 48.4275 173.197 48.6328 171.808 48.6328C169.838 48.6328 168.044 48.1775 166.427 47.2666C164.821 46.3443 163.54 44.9379 162.584 43.0479C161.639 41.1463 161.167 38.7434 161.167 35.8398C161.167 32.8679 161.65 30.3853 162.618 28.3926C163.586 26.3999 164.873 24.9086 166.479 23.918C168.095 22.9274 169.866 22.4317 171.79 22.4316ZM174.113 28.2217C172.918 28.2217 171.91 28.5463 171.09 29.1953C170.27 29.833 169.65 30.7217 169.229 31.8604C168.807 32.999 168.597 34.3141 168.597 35.8057C168.597 37.32 168.807 38.6299 169.229 39.7344C169.661 40.8273 170.281 41.6759 171.09 42.2793C171.91 42.8714 172.918 43.167 174.113 43.167C175.286 43.167 176.277 42.8765 177.085 42.2959C177.905 41.7039 178.531 40.8615 178.964 39.7686C179.408 38.6641 179.63 37.3428 179.63 35.8057C179.63 34.2685 179.414 32.9359 178.981 31.8086C178.549 30.67 177.922 29.7874 177.103 29.1611C176.283 28.5349 175.286 28.2217 174.113 28.2217Z" 34 + fill="black" 35 + /> 36 + <path 37 + fill-rule="evenodd" 38 + clip-rule="evenodd" 39 + d="M215.798 22.4316C217.528 22.4317 219.139 22.7107 220.631 23.2686C222.134 23.8151 223.444 24.6407 224.56 25.7451C225.687 26.8496 226.564 28.2392 227.19 29.9131C227.817 31.5754 228.13 33.5224 228.13 35.7539V37.7529H210.264V37.7695C210.264 39.0676 210.503 40.1897 210.981 41.1348C211.471 42.0796 212.16 42.808 213.048 43.3203C213.936 43.8327 214.99 44.0889 216.208 44.0889C217.016 44.0888 217.756 43.9757 218.428 43.748C219.1 43.5203 219.675 43.1781 220.153 42.7227C220.631 42.2672 220.996 41.7091 221.246 41.0488L227.976 41.4932C227.634 43.1101 226.934 44.5225 225.875 45.7295C224.827 46.925 223.472 47.8585 221.81 48.5303C220.159 49.1906 218.251 49.5205 216.088 49.5205C213.389 49.5205 211.066 48.974 209.119 47.8809C207.184 46.7764 205.692 45.2165 204.645 43.2012C203.597 41.1744 203.073 38.7776 203.073 36.0107C203.073 33.3121 203.597 30.9435 204.645 28.9053C205.692 26.867 207.167 25.2783 209.068 24.1396C210.981 23.0011 213.225 22.4316 215.798 22.4316ZM215.917 27.8633C214.813 27.8633 213.833 28.1195 212.979 28.6318C212.137 29.1328 211.476 29.8102 210.998 30.6641C210.557 31.4413 210.317 32.3013 210.273 33.2432H221.28C221.28 32.1957 221.052 31.2674 220.597 30.459C220.141 29.6508 219.509 29.0189 218.701 28.5635C217.904 28.0967 216.976 27.8633 215.917 27.8633Z" 40 + fill="black" 41 + /> 42 + <path 43 + fill-rule="evenodd" 44 + clip-rule="evenodd" 45 + d="M118.389 22.4316C119.846 22.4316 121.241 22.6028 122.573 22.9443C123.917 23.2859 125.107 23.8149 126.144 24.5322C127.191 25.2496 128.017 26.1725 128.62 27.2998C129.224 28.4157 129.525 29.7536 129.525 31.3135V49.0088H122.625V45.3701H122.42C121.999 46.1898 121.435 46.9129 120.729 47.5391C120.024 48.1539 119.175 48.6382 118.185 48.9912C117.194 49.3328 116.049 49.5039 114.751 49.5039C113.077 49.5039 111.586 49.2134 110.276 48.6328C108.967 48.0407 107.93 47.1696 107.167 46.0195C106.416 44.8581 106.04 43.4114 106.04 41.6807C106.04 40.2233 106.308 38.9993 106.843 38.0088C107.378 37.0181 108.107 36.2207 109.029 35.6172C109.952 35.0138 110.999 34.5584 112.172 34.251C113.356 33.9435 114.597 33.7278 115.896 33.6025C117.421 33.4431 118.651 33.2948 119.585 33.1582C120.519 33.0102 121.196 32.7934 121.617 32.5088C122.038 32.2241 122.249 31.803 122.249 31.2451V31.1426C122.249 30.0609 121.908 29.2239 121.225 28.6318C120.553 28.0397 119.596 27.7441 118.354 27.7441C117.045 27.7442 116.004 28.0346 115.229 28.6152C114.455 29.1845 113.943 29.9014 113.692 30.7666L106.963 30.2207C107.304 28.6266 107.976 27.2483 108.978 26.0869C109.98 24.9141 111.273 24.0149 112.855 23.3887C114.449 22.7511 116.294 22.4317 118.389 22.4316ZM122.301 36.8477C122.073 36.9956 121.76 37.1316 121.361 37.2568C120.974 37.3707 120.536 37.4796 120.046 37.582C119.556 37.6731 119.067 37.7582 118.577 37.8379C118.088 37.9062 117.644 37.9694 117.245 38.0264C116.391 38.1516 115.644 38.3507 115.007 38.624C114.369 38.8973 113.874 39.2676 113.521 39.7344C113.169 40.1898 112.992 40.7593 112.992 41.4424C112.992 42.4327 113.35 43.1902 114.067 43.7139C114.796 44.2263 115.719 44.4824 116.835 44.4824C117.905 44.4824 118.85 44.2718 119.67 43.8506C120.49 43.4179 121.134 42.8371 121.601 42.1084C122.067 41.3798 122.301 40.554 122.301 39.6318V36.8477Z" 46 + fill="black" 47 + /> 48 + <path 49 + fill-rule="evenodd" 50 + clip-rule="evenodd" 51 + d="M256 14.0283V49.0088H248.826V44.8066H248.52C248.178 45.5353 247.694 46.2583 247.067 46.9756C246.453 47.6815 245.65 48.2685 244.659 48.7354C243.68 49.2022 242.484 49.4355 241.072 49.4355C239.08 49.4355 237.275 48.9231 235.658 47.8984C234.053 46.8623 232.777 45.3419 231.832 43.3379C230.898 41.3224 230.432 38.8512 230.432 35.9248C230.432 32.9188 230.915 30.4194 231.883 28.4268C232.851 26.4227 234.138 24.9252 235.743 23.9346C237.36 22.9326 239.131 22.4317 241.055 22.4316C242.524 22.4316 243.748 22.6826 244.728 23.1836C245.718 23.6732 246.515 24.2883 247.118 25.0283C247.733 25.757 248.201 26.4738 248.52 27.1797H248.741V14.0283H256ZM243.378 28.2217C242.182 28.2217 241.174 28.5463 240.354 29.1953C239.535 29.8444 238.914 30.7445 238.493 31.8945C238.072 33.0445 237.861 34.3764 237.861 35.8906C237.861 37.4163 238.072 38.7657 238.493 39.9385C238.926 41.0999 239.546 42.0114 240.354 42.6719C241.174 43.3209 242.182 43.6455 243.378 43.6455C244.551 43.6455 245.541 43.3261 246.35 42.6885C247.169 42.0394 247.796 41.1341 248.229 39.9727C248.673 38.8113 248.895 37.4505 248.895 35.8906C248.895 34.3309 248.679 32.9761 248.246 31.8262C247.813 30.6761 247.187 29.7874 246.367 29.1611C245.547 28.5349 244.551 28.2217 243.378 28.2217Z" 52 + fill="black" 53 + /> 54 + <path 55 + d="M99.0752 16.4883V22.7734H104.012V28.2393H99.0752V40.9463C99.0752 41.6179 99.178 42.1418 99.3828 42.5176C99.5878 42.882 99.8729 43.1381 100.237 43.2861C100.613 43.4341 101.046 43.5088 101.535 43.5088C101.877 43.5088 102.218 43.4798 102.56 43.4229C102.901 43.3545 103.164 43.3037 103.346 43.2695L104.49 48.6836C104.126 48.7974 103.613 48.9292 102.953 49.0771C102.293 49.2366 101.489 49.333 100.544 49.3672C98.7906 49.4354 97.2534 49.2021 95.9326 48.667C94.6232 48.1318 93.6037 47.3001 92.875 46.1729C92.1464 45.0457 91.7885 43.6224 91.7998 41.9033V28.2393H88.2129V22.7734H91.7998V16.4883H99.0752Z" 56 + fill="black" 57 + /> 58 + <path 59 + d="M198.397 41.1514C198.409 41.9824 198.556 42.5861 198.841 42.9619C199.137 43.3263 199.639 43.5088 200.345 43.5088C200.709 43.4974 200.993 43.4745 201.198 43.4404C201.403 43.4063 201.574 43.3607 201.711 43.3037L202.872 48.5986C202.496 48.7125 202.035 48.8318 201.488 48.957C200.953 49.0709 200.23 49.1446 199.319 49.1787C196.53 49.2812 194.469 48.7575 193.137 47.6074C191.804 46.446 191.132 44.6187 191.121 42.125V14.0283H198.397V41.1514Z" 60 + fill="black" 61 + /> 62 + <path 63 + d="M148.839 22.4316C150.661 22.4316 152.249 22.8299 153.604 23.627C154.959 24.4239 156.012 25.5629 156.764 27.043C157.515 28.5118 157.892 30.2656 157.892 32.3037V49.0088H150.615V33.6025C150.627 31.9972 150.217 30.7443 149.386 29.8447C148.555 28.9338 147.41 28.4785 145.952 28.4785C144.973 28.4785 144.108 28.6891 143.356 29.1104C142.616 29.5317 142.036 30.1466 141.614 30.9551C141.204 31.752 140.994 32.7138 140.982 33.8408V49.0088H133.706V22.7734H140.641V27.4023H140.948C141.529 25.8766 142.502 24.6694 143.868 23.7812C145.235 22.8817 146.892 22.4316 148.839 22.4316Z" 64 + fill="black" 65 + /> 66 + </svg> 67 + </div> 68 + ); 69 + }
+42
appview/ogcard/src/components/shared/metrics.tsx
··· 1 + import { Row, Col } from "./layout"; 2 + import { 3 + Star, 4 + GitPullRequest, 5 + CircleDot, 6 + type LucideIcon, 7 + } from "../../icons/lucide"; 8 + 9 + interface MetricsProps { 10 + stars: number; 11 + pulls: number; 12 + issues: number; 13 + } 14 + 15 + // Display stars, pulls, issues with Lucide icons 16 + export function Metrics({ stars, pulls, issues }: MetricsProps) { 17 + return ( 18 + <Row style={{ gap: 56, alignItems: "flex-start" }}> 19 + <MetricItem value={stars} label="stars" Icon={Star} /> 20 + <MetricItem value={pulls} label="pulls" Icon={GitPullRequest} /> 21 + <MetricItem value={issues} label="issues" Icon={CircleDot} /> 22 + </Row> 23 + ); 24 + } 25 + 26 + interface MetricItemProps { 27 + value: number; 28 + label: string; 29 + Icon: LucideIcon; 30 + } 31 + 32 + function MetricItem({ value, label, Icon }: MetricItemProps) { 33 + return ( 34 + <Col style={{ gap: 12 }}> 35 + <Row style={{ gap: 12, alignItems: "center" }}> 36 + <span style={{ fontSize: 48, fontWeight: 500 }}>{value}</span> 37 + <Icon size={48} /> 38 + </Row> 39 + <span style={{ fontSize: 24, opacity: 0.75 }}>{label}</span> 40 + </Col> 41 + ); 42 + }
+23
appview/ogcard/src/components/shared/stat-item.tsx
··· 1 + import { Row } from "./layout"; 2 + import type { LucideIcon } from "../../icons/lucide"; 3 + 4 + interface StatItemProps { 5 + Icon: LucideIcon; 6 + value: string | number; 7 + } 8 + 9 + export function StatItem({ Icon, value }: StatItemProps) { 10 + return ( 11 + <Row style={{ gap: 16 }}> 12 + <Icon size={36} color="#404040" /> 13 + <span 14 + style={{ 15 + fontFamily: "Inter", 16 + fontSize: 36, 17 + color: "#404040", 18 + }}> 19 + {value} 20 + </span> 21 + </Row> 22 + ); 23 + }
+89
appview/ogcard/src/components/shared/status-badge.tsx
··· 1 + import { Row } from "./layout"; 2 + import { 3 + CircleDot, 4 + Ban, 5 + GitPullRequest, 6 + GitPullRequestClosed, 7 + GitMerge, 8 + } from "../../icons/lucide"; 9 + import { COLORS } from "./constants"; 10 + 11 + const STATUS_CONFIG = { 12 + open: { 13 + Icon: CircleDot, 14 + bg: COLORS.status.open.bg, 15 + text: COLORS.status.open.text, 16 + }, 17 + closed: { 18 + Icon: Ban, 19 + bg: COLORS.status.closed.bg, 20 + text: COLORS.status.closed.text, 21 + }, 22 + merged: { 23 + Icon: GitMerge, 24 + bg: COLORS.status.merged.bg, 25 + text: COLORS.status.merged.text, 26 + }, 27 + } as const; 28 + 29 + interface StatusBadgeProps { 30 + status: "open" | "closed" | "merged"; 31 + } 32 + 33 + export function StatusBadge({ status }: StatusBadgeProps) { 34 + const config = 35 + status === "merged" 36 + ? STATUS_CONFIG.merged 37 + : status === "closed" 38 + ? STATUS_CONFIG.closed 39 + : STATUS_CONFIG.open; 40 + const Icon = config.Icon; 41 + 42 + return ( 43 + <Row 44 + style={{ 45 + gap: 12, 46 + padding: "14px 26px 14px 24px", 47 + borderRadius: 18, 48 + backgroundColor: config.bg, 49 + }}> 50 + <Icon size={48} color={config.text} /> 51 + <span 52 + style={{ 53 + fontFamily: "Inter", 54 + fontSize: 48, 55 + color: config.text, 56 + fontWeight: 500, 57 + }}> 58 + {status} 59 + </span> 60 + </Row> 61 + ); 62 + } 63 + 64 + export function IssueStatusBadge({ status }: { status: "open" | "closed" }) { 65 + const config = 66 + status === "closed" ? STATUS_CONFIG.closed : STATUS_CONFIG.open; 67 + const Icon = config.Icon; 68 + 69 + return ( 70 + <Row 71 + style={{ 72 + gap: 12, 73 + padding: "14px 26px 14px 24px", 74 + borderRadius: 18, 75 + backgroundColor: config.bg, 76 + }}> 77 + <Icon size={48} color={config.text} /> 78 + <span 79 + style={{ 80 + fontFamily: "Inter", 81 + fontSize: 48, 82 + color: config.text, 83 + fontWeight: 500, 84 + }}> 85 + {status} 86 + </span> 87 + </Row> 88 + ); 89 + }
+50
appview/ogcard/src/icons/lucide.tsx
··· 1 + import { h } from "preact"; 2 + import iconNodes from "lucide-static/icon-nodes.json"; 3 + 4 + interface IconProps { 5 + size?: number; 6 + color?: string; 7 + strokeWidth?: number; 8 + } 9 + 10 + type IconNodeEntry = [string, Record<string, string | number>]; 11 + 12 + function createIcon(name: string) { 13 + const nodes = (iconNodes as unknown as Record<string, IconNodeEntry[]>)[name]; 14 + if (!nodes) throw new Error(`Icon "${name}" not found`); 15 + 16 + return function Icon({ size = 24, color = "currentColor", strokeWidth = 2 }: IconProps = {}) { 17 + return h( 18 + "svg", 19 + { 20 + xmlns: "http://www.w3.org/2000/svg", 21 + width: size, 22 + height: size, 23 + viewBox: "0 0 24 24", 24 + fill: "none", 25 + stroke: color, 26 + strokeWidth, 27 + strokeLinecap: "round" as const, 28 + strokeLinejoin: "round" as const, 29 + }, 30 + nodes.map(([tag, attrs], i) => h(tag, { key: i, ...attrs })), 31 + ); 32 + }; 33 + } 34 + 35 + export const Star = createIcon("star"); 36 + export const GitPullRequest = createIcon("git-pull-request"); 37 + export const GitPullRequestClosed = createIcon("git-pull-request-closed"); 38 + export const GitMerge = createIcon("git-merge"); 39 + export const CircleDot = createIcon("circle-dot"); 40 + export const Calendar = createIcon("calendar"); 41 + export const MessageSquare = createIcon("message-square"); 42 + export const MessageSquareCode = createIcon("message-square-code"); 43 + export const ThumbsUp = createIcon("thumbs-up"); 44 + export const ThumbsDown = createIcon("thumbs-down"); 45 + export const Ban = createIcon("ban"); 46 + export const SmilePlus = createIcon("smile-plus"); 47 + export const FileDiff = createIcon("file-diff"); 48 + export const RefreshCw = createIcon("refresh-cw"); 49 + 50 + export type LucideIcon = typeof Star;
+92
appview/ogcard/src/index.tsx
··· 1 + import { cardPayloadSchema } from "./validation"; 2 + import { renderCard } from "./lib/render"; 3 + import { RepositoryCard } from "./components/cards/repository"; 4 + import { IssueCard } from "./components/cards/issue"; 5 + import { PullRequestCard } from "./components/cards/pull-request"; 6 + import { z } from "zod"; 7 + 8 + declare global { 9 + interface CacheStorage { 10 + default: Cache; 11 + } 12 + } 13 + 14 + interface Env { 15 + ENVIRONMENT: string; 16 + } 17 + 18 + export default { 19 + async fetch(request: Request, env: Env): Promise<Response> { 20 + if (request.method !== "POST") { 21 + return new Response("Method not allowed", { status: 405 }); 22 + } 23 + 24 + const url = new URL(request.url); 25 + const cardType = url.pathname.split("/").pop(); 26 + 27 + try { 28 + const body = await request.json(); 29 + const payload = cardPayloadSchema.parse(body); 30 + 31 + let component; 32 + switch (payload.type) { 33 + case "repository": 34 + component = <RepositoryCard {...payload} />; 35 + break; 36 + case "issue": 37 + component = <IssueCard {...payload} />; 38 + break; 39 + case "pullRequest": 40 + component = <PullRequestCard {...payload} />; 41 + break; 42 + default: 43 + return new Response("Unknown card type", { status: 400 }); 44 + } 45 + 46 + const cacheKeyUrl = new URL(request.url); 47 + cacheKeyUrl.searchParams.set("payload", JSON.stringify(payload)); 48 + const cacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" }); 49 + const cache = caches.default; 50 + const cached = await cache.match(cacheKey); 51 + 52 + if (cached) { 53 + return cached; 54 + } 55 + 56 + const { png: pngBuffer } = await renderCard(component); 57 + 58 + const response = new Response(pngBuffer as any, { 59 + headers: { 60 + "Content-Type": "image/png", 61 + "Cache-Control": "public, max-age=3600", 62 + }, 63 + }); 64 + 65 + await cache.put(cacheKey, response.clone()); 66 + 67 + return response; 68 + } catch (error) { 69 + if (error instanceof z.ZodError) { 70 + return new Response( 71 + JSON.stringify({ errors: (error as z.ZodError).issues }), 72 + { 73 + status: 400, 74 + headers: { "Content-Type": "application/json" }, 75 + }, 76 + ); 77 + } 78 + 79 + console.error("Error generating card:", error); 80 + const errorMessage = error instanceof Error ? error.message : String(error); 81 + const errorStack = error instanceof Error ? error.stack : ""; 82 + console.error("Error stack:", errorStack); 83 + return new Response(JSON.stringify({ 84 + error: errorMessage, 85 + stack: errorStack 86 + }), { 87 + status: 500, 88 + headers: { "Content-Type": "application/json" } 89 + }); 90 + } 91 + }, 92 + };
+35
appview/ogcard/src/lib/fonts.ts
··· 1 + export interface FontData { 2 + name: string; 3 + data: ArrayBuffer; 4 + weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; 5 + style: "normal" | "italic"; 6 + } 7 + 8 + const FONT_URLS = { 9 + regular: 10 + "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-400-normal.woff", 11 + medium: 12 + "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-500-normal.woff", 13 + semiBold: 14 + "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-600-normal.woff", 15 + }; 16 + 17 + export async function loadFonts(): Promise<FontData[]> { 18 + const [regular, medium, semiBold] = await Promise.all([ 19 + fetchFont(FONT_URLS.regular), 20 + fetchFont(FONT_URLS.medium), 21 + fetchFont(FONT_URLS.semiBold), 22 + ]); 23 + 24 + return [ 25 + { name: "Inter", data: regular, weight: 400, style: "normal" }, 26 + { name: "Inter", data: medium, weight: 500, style: "normal" }, 27 + { name: "Inter", data: semiBold, weight: 600, style: "normal" }, 28 + ]; 29 + } 30 + 31 + async function fetchFont(url: string): Promise<ArrayBuffer> { 32 + const response = await fetch(url); 33 + if (!response.ok) throw new Error(`Failed to load font: ${url}`); 34 + return response.arrayBuffer(); 35 + }
+72
appview/ogcard/src/lib/render.ts
··· 1 + import satori, { init } from "satori/standalone"; 2 + import { loadFonts } from "./fonts"; 3 + import type { VNode } from "preact"; 4 + 5 + const isCloudflareWorkers = typeof caches !== "undefined" && "default" in caches; 6 + 7 + let satoriInitialized = false; 8 + let resvgInitialized = false; 9 + 10 + let Resvg: any; 11 + let initWasm: any; 12 + let wasmModule: any; 13 + 14 + async function initSatori() { 15 + if (satoriInitialized) return; 16 + 17 + const yogaWasm = await fetch("https://unpkg.com/satori@0.25.0/yoga.wasm"); 18 + const wasmBuffer = await yogaWasm.arrayBuffer(); 19 + await init(wasmBuffer); 20 + 21 + satoriInitialized = true; 22 + } 23 + 24 + async function initResvg() { 25 + if (resvgInitialized) return; 26 + 27 + if (isCloudflareWorkers) { 28 + const resvgWasm = await import("@resvg/resvg-wasm"); 29 + Resvg = resvgWasm.Resvg; 30 + initWasm = resvgWasm.initWasm; 31 + 32 + // @ts-ignore 33 + wasmModule = (await import("@resvg/resvg-wasm/index_bg.wasm?module")).default; 34 + 35 + await initWasm(wasmModule); 36 + } else { 37 + const resvgJs = await import("@resvg/resvg-js"); 38 + Resvg = resvgJs.Resvg; 39 + } 40 + 41 + resvgInitialized = true; 42 + } 43 + 44 + export interface RenderResult { 45 + svg: string; 46 + png: Uint8Array; 47 + } 48 + 49 + export async function renderCard(component: VNode): Promise<RenderResult> { 50 + await initSatori(); 51 + await initResvg(); 52 + 53 + const fonts = await loadFonts(); 54 + 55 + const svg = await satori(component as any, { 56 + width: 1200, 57 + height: 630, 58 + fonts, 59 + embedFont: true, 60 + }); 61 + 62 + const resvg = new Resvg(svg, { 63 + fitTo: { mode: "width", value: 1200 }, 64 + }); 65 + 66 + const pngData = resvg.render(); 67 + 68 + return { 69 + svg, 70 + png: pngData.asPng(), 71 + }; 72 + }
+67
appview/ogcard/src/validation.ts
··· 1 + import { z } from "zod"; 2 + 3 + const hexColor = /^#[0-9A-Fa-f]{6}$/; 4 + 5 + const languageSchema = z.object({ 6 + color: z.string().regex(hexColor), 7 + percentage: z.number().min(0).max(100), 8 + }); 9 + 10 + export const repositoryCardSchema = z.object({ 11 + type: z.literal("repository"), 12 + repoName: z.string().min(1).max(100), 13 + ownerHandle: z.string().min(1).max(100), 14 + stars: z.number().int().min(0).max(1000000), 15 + pulls: z.number().int().min(0).max(100000), 16 + issues: z.number().int().min(0).max(100000), 17 + updatedAt: z.string().max(100), 18 + avatarUrl: z.string().url(), 19 + languages: z.array(languageSchema).max(5), 20 + }); 21 + 22 + export const issueCardSchema = z.object({ 23 + type: z.literal("issue"), 24 + repoName: z.string().min(1).max(100), 25 + ownerHandle: z.string().min(1).max(100), 26 + avatarUrl: z.string().url(), 27 + title: z.string().min(1).max(500), 28 + issueNumber: z.number().int().positive(), 29 + status: z.enum(["open", "closed"]), 30 + labels: z.array(z.object({ 31 + name: z.string().max(50), 32 + color: z.string().regex(hexColor), 33 + })).max(10), 34 + commentCount: z.number().int().min(0), 35 + reactionCount: z.number().int().min(0), 36 + createdAt: z.string(), 37 + }); 38 + 39 + export const pullRequestCardSchema = z.object({ 40 + type: z.literal("pullRequest"), 41 + repoName: z.string().min(1).max(100), 42 + ownerHandle: z.string().min(1).max(100), 43 + avatarUrl: z.string().url(), 44 + title: z.string().min(1).max(500), 45 + pullRequestNumber: z.number().int().positive(), 46 + status: z.enum(["open", "closed", "merged"]), 47 + filesChanged: z.number().int().min(0), 48 + additions: z.number().int().min(0), 49 + deletions: z.number().int().min(0), 50 + rounds: z.number().int().min(1), 51 + // reviews: z.number().int().min(0), // TODO: implement review tracking 52 + commentCount: z.number().int().min(0), 53 + reactionCount: z.number().int().min(0), 54 + createdAt: z.string(), 55 + }); 56 + 57 + export const cardPayloadSchema = z.discriminatedUnion("type", [ 58 + repositoryCardSchema, 59 + issueCardSchema, 60 + pullRequestCardSchema, 61 + ]); 62 + 63 + export type Language = z.infer<typeof languageSchema>; 64 + export type RepositoryCardData = z.infer<typeof repositoryCardSchema>; 65 + export type IssueCardData = z.infer<typeof issueCardSchema>; 66 + export type PullRequestCardData = z.infer<typeof pullRequestCardSchema>; 67 + export type CardPayload = z.infer<typeof cardPayloadSchema>;
+19
appview/ogcard/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "lib": ["ES2022"], 6 + "moduleResolution": "bundler", 7 + "types": ["@cloudflare/workers-types", "node"], 8 + "jsx": "react-jsx", 9 + "jsxImportSource": "preact", 10 + "strict": true, 11 + "esModuleInterop": true, 12 + "skipLibCheck": true, 13 + "forceConsistentCasingInFileNames": true, 14 + "resolveJsonModule": true, 15 + "noEmit": true 16 + }, 17 + "include": ["src/**/*"], 18 + "exclude": ["node_modules"] 19 + }
+17
appview/ogcard/wrangler.jsonc
··· 1 + { 2 + "name": "tangled-ogcard-worker", 3 + "main": "src/index.tsx", 4 + "compatibility_date": "2026-03-07", 5 + "compatibility_flags": ["nodejs_compat"], 6 + "account_id": "<YOUR_ACCOUNT_ID>", 7 + "workers_dev": true, 8 + "routes": [ 9 + { 10 + "pattern": "og.tangled.org/*", 11 + "zone_name": "tangled.org", 12 + }, 13 + ], 14 + "vars": { 15 + "ENVIRONMENT": "production", 16 + }, 17 + }
+7 -31
appview/pages/funcmap.go
··· 12 12 "html/template" 13 13 "log" 14 14 "math" 15 - "math/rand" 16 15 "net/url" 17 16 "path/filepath" 18 17 "reflect" ··· 124 123 }, 125 124 "mod": func(a, b int) int { 126 125 return a % b 127 - }, 128 - "randInt": func(bound int) int { 129 - return rand.Intn(bound) 130 126 }, 131 127 "f64": func(a int) float64 { 132 128 return float64(a) ··· 295 291 296 292 lexer := lexers.Get(filepath.Base(path)) 297 293 if lexer == nil { 298 - if firstLine, _, ok := strings.Cut(content, "\n"); ok && strings.HasPrefix(firstLine, "#!") { 299 - // extract interpreter from shebang (handles "#!/usr/bin/env nu", "#!/usr/bin/nu", etc.) 300 - fields := strings.Fields(firstLine[2:]) 301 - if len(fields) > 0 { 302 - interp := filepath.Base(fields[len(fields)-1]) 303 - lexer = lexers.Get(interp) 304 - } 305 - } 306 - } 307 - if lexer == nil { 308 - lexer = lexers.Analyse(content) 309 - } 310 - if lexer == nil { 311 294 lexer = lexers.Fallback 312 295 } 313 296 ··· 466 449 } 467 450 return result 468 451 }, 469 - "isGenerated": func(path string) bool { 470 - return enry.IsGenerated(path, nil) 471 - }, 472 452 // constant values used to define a template 473 453 "const": func() map[string]any { 474 454 return map[string]any{ ··· 481 461 {"Name": "notifications", "Icon": "bell"}, 482 462 {"Name": "knots", "Icon": "volleyball"}, 483 463 {"Name": "spindles", "Icon": "spool"}, 484 - {"Name": "sites", "Icon": "globe"}, 485 464 }, 486 465 "RepoSettingsTabs": []tab{ 487 466 {"Name": "general", "Icon": "sliders-horizontal"}, 488 467 {"Name": "access", "Icon": "users"}, 489 468 {"Name": "pipelines", "Icon": "layers-2"}, 490 469 {"Name": "hooks", "Icon": "webhook"}, 491 - {"Name": "sites", "Icon": "globe"}, 492 470 }, 493 471 } 494 472 }, ··· 526 504 signature := hex.EncodeToString(h.Sum(nil)) 527 505 528 506 // Get avatar CID for cache busting 507 + profile, err := db.GetProfile(p.db, did) 529 508 version := "" 530 - if p.db != nil { 531 - profile, err := db.GetProfile(p.db, did) 532 - if err == nil && profile != nil && profile.Avatar != "" { 533 - // Use first 8 chars of avatar CID as version 534 - if len(profile.Avatar) > 8 { 535 - version = profile.Avatar[:8] 536 - } else { 537 - version = profile.Avatar 538 - } 509 + if err == nil && profile != nil && profile.Avatar != "" { 510 + // Use first 8 chars of avatar CID as version 511 + if len(profile.Avatar) > 8 { 512 + version = profile.Avatar[:8] 513 + } else { 514 + version = profile.Avatar 539 515 } 540 516 } 541 517
+2 -12
appview/pages/htmx.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "html" 6 5 "net/http" 7 6 ) 8 7 9 8 // Notice performs a hx-oob-swap to replace the content of an element with a message. 10 9 // Pass the id of the element and the message to display. 11 10 func (s *Pages) Notice(w http.ResponseWriter, id, msg string) { 12 - escaped := html.EscapeString(msg) 13 - markup := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, escaped) 14 - 15 - w.Header().Set("Content-Type", "text/html") 16 - w.WriteHeader(http.StatusOK) 17 - w.Write([]byte(markup)) 18 - } 19 - 20 - func (s *Pages) NoticeHTML(w http.ResponseWriter, id string, trustedHTML string) { 21 - markup := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, trustedHTML) 11 + html := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, msg) 22 12 23 13 w.Header().Set("Content-Type", "text/html") 24 14 w.WriteHeader(http.StatusOK) 25 - w.Write([]byte(markup)) 15 + w.Write([]byte(html)) 26 16 } 27 17 28 18 // HxRefresh is a client-side full refresh of the page.
-64
appview/pages/markup/extension/dashes.go
··· 1 - package extension 2 - 3 - import ( 4 - "github.com/yuin/goldmark" 5 - gast "github.com/yuin/goldmark/ast" 6 - "github.com/yuin/goldmark/parser" 7 - "github.com/yuin/goldmark/text" 8 - "github.com/yuin/goldmark/util" 9 - ) 10 - 11 - type dashParser struct{} 12 - 13 - func (p *dashParser) Trigger() []byte { 14 - return []byte{'-'} 15 - } 16 - 17 - func (p *dashParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { 18 - line, _ := block.PeekLine() 19 - if len(line) < 2 || line[0] != '-' || line[1] != '-' { 20 - return nil 21 - } 22 - node := gast.NewString([]byte("\u2014")) 23 - node.SetCode(true) 24 - block.Advance(2) 25 - return node 26 - } 27 - 28 - type digitDashParser struct{} 29 - 30 - func (p *digitDashParser) Trigger() []byte { 31 - return []byte{'-'} 32 - } 33 - 34 - func (p *digitDashParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { 35 - line, _ := block.PeekLine() 36 - if len(line) < 2 { 37 - return nil 38 - } 39 - before := block.PrecendingCharacter() 40 - if before < '0' || before > '9' { 41 - return nil 42 - } 43 - if line[1] < '0' || line[1] > '9' { 44 - return nil 45 - } 46 - node := gast.NewString([]byte("\u2013")) 47 - node.SetCode(true) 48 - block.Advance(1) 49 - return node 50 - } 51 - 52 - type dashExt struct{} 53 - 54 - // Dashes replaces "--" with an em-dash (—) and a hyphen between two digits 55 - // with an en-dash (–). Implemented as an inline parser so it operates on the 56 - // raw byte stream, unaffected by hard-wrapped source lines. 57 - var Dashes goldmark.Extender = &dashExt{} 58 - 59 - func (e *dashExt) Extend(m goldmark.Markdown) { 60 - m.Parser().AddOptions(parser.WithInlineParsers( 61 - util.Prioritized(&dashParser{}, 9990), 62 - util.Prioritized(&digitDashParser{}, 9991), 63 - )) 64 - }
+18 -30
appview/pages/markup/markdown.go
··· 13 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 14 "github.com/alecthomas/chroma/v2/styles" 15 15 "github.com/yuin/goldmark" 16 - emoji "github.com/yuin/goldmark-emoji" 16 + "github.com/yuin/goldmark-emoji" 17 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 18 "github.com/yuin/goldmark/ast" 19 19 "github.com/yuin/goldmark/extension" ··· 22 22 "github.com/yuin/goldmark/text" 23 23 "github.com/yuin/goldmark/util" 24 24 callout "gitlab.com/staticnoise/goldmark-callout" 25 - "go.abhg.dev/goldmark/mermaid" 26 25 htmlparse "golang.org/x/net/html" 27 26 28 27 "tangled.org/core/api/tangled" ··· 53 52 Files fs.FS 54 53 } 55 54 56 - func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown { 57 - exts := []goldmark.Extender{ 58 - extension.GFM, 59 - &mermaid.Extender{ 60 - RenderMode: mermaid.RenderModeClient, 61 - NoScript: true, 62 - }, 63 - highlighting.NewHighlighting( 64 - highlighting.WithFormatOptions( 65 - chromahtml.Standalone(false), 66 - chromahtml.WithClasses(true), 55 + func NewMarkdown(hostname string) goldmark.Markdown { 56 + md := goldmark.New( 57 + goldmark.WithExtensions( 58 + extension.GFM, 59 + highlighting.NewHighlighting( 60 + highlighting.WithFormatOptions( 61 + chromahtml.Standalone(false), 62 + chromahtml.WithClasses(true), 63 + ), 64 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 67 65 ), 68 - highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 69 - ), 70 - extension.NewFootnote( 71 - extension.WithFootnoteIDPrefix([]byte("footnote")), 66 + extension.NewFootnote( 67 + extension.WithFootnoteIDPrefix([]byte("footnote")), 68 + ), 69 + callout.CalloutExtention, 70 + textension.AtExt, 71 + textension.NewTangledLinkExt(hostname), 72 + emoji.Emoji, 72 73 ), 73 - callout.CalloutExtention, 74 - textension.AtExt, 75 - textension.NewTangledLinkExt(hostname), 76 - emoji.Emoji, 77 - } 78 - exts = append(exts, extra...) 79 - md := goldmark.New( 80 - goldmark.WithExtensions(exts...), 81 74 goldmark.WithParserOptions( 82 75 parser.WithAutoHeadingID(), 83 76 ), 84 77 goldmark.WithRendererOptions(html.WithUnsafe()), 85 78 ) 86 79 return md 87 - } 88 - 89 - // NewMarkdownWith is an alias for NewMarkdown with extra extensions. 90 - func NewMarkdownWith(hostname string, extra ...goldmark.Extender) goldmark.Markdown { 91 - return NewMarkdown(hostname, extra...) 92 80 } 93 81 94 82 func (rctx *RenderContext) RenderMarkdown(source string) string {
-46
appview/pages/markup/markdown_test.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 - "strings" 6 5 "testing" 7 6 ) 8 - 9 - func TestMermaidExtension(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - markdown string 13 - contains string 14 - notContains string 15 - }{ 16 - { 17 - name: "mermaid block produces pre.mermaid", 18 - markdown: "```mermaid\ngraph TD\n A-->B\n```", 19 - contains: `<pre class="mermaid">`, 20 - notContains: `<code class="language-mermaid"`, 21 - }, 22 - { 23 - name: "mermaid block contains diagram source", 24 - markdown: "```mermaid\ngraph TD\n A-->B\n```", 25 - contains: "graph TD", 26 - }, 27 - { 28 - name: "non-mermaid code block is not affected", 29 - markdown: "```go\nfunc main() {}\n```", 30 - contains: `<pre class="chroma">`, 31 - }, 32 - } 33 - 34 - for _, tt := range tests { 35 - t.Run(tt.name, func(t *testing.T) { 36 - md := NewMarkdown("tangled.org") 37 - 38 - var buf bytes.Buffer 39 - if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 40 - t.Fatalf("failed to convert markdown: %v", err) 41 - } 42 - 43 - result := buf.String() 44 - if !strings.Contains(result, tt.contains) { 45 - t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 46 - } 47 - if tt.notContains != "" && strings.Contains(result, tt.notContains) { 48 - t.Errorf("expected output NOT to contain:\n%s\ngot:\n%s", tt.notContains, result) 49 - } 50 - }) 51 - } 52 - } 53 7 54 8 func TestAtExtension_Rendering(t *testing.T) { 55 9 tests := []struct {
+5 -16
appview/pages/markup/sanitizer.go
··· 10 10 "github.com/microcosm-cc/bluemonday" 11 11 ) 12 12 13 - // shared policies built once at init; safe for concurrent use per bluemonday docs 14 - var ( 15 - sharedDefaultPolicy *bluemonday.Policy 16 - sharedDescriptionPolicy *bluemonday.Policy 17 - ) 18 - 19 - func init() { 20 - sharedDefaultPolicy = buildDefaultPolicy() 21 - sharedDescriptionPolicy = buildDescriptionPolicy() 22 - } 23 - 24 13 type Sanitizer struct { 25 14 defaultPolicy *bluemonday.Policy 26 15 descriptionPolicy *bluemonday.Policy ··· 28 17 29 18 func NewSanitizer() Sanitizer { 30 19 return Sanitizer{ 31 - defaultPolicy: sharedDefaultPolicy, 32 - descriptionPolicy: sharedDescriptionPolicy, 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 33 22 } 34 23 } 35 24 ··· 40 29 return s.descriptionPolicy.Sanitize(html) 41 30 } 42 31 43 - func buildDefaultPolicy() *bluemonday.Policy { 32 + func defaultPolicy() *bluemonday.Policy { 44 33 policy := bluemonday.UGCPolicy() 45 34 46 35 // Allow generally safe attributes ··· 83 72 policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 84 73 85 74 // for code blocks 86 - policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma|mermaid`)).OnElements("pre") 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 87 76 policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 88 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 89 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") ··· 134 123 return policy 135 124 } 136 125 137 - func buildDescriptionPolicy() *bluemonday.Policy { 126 + func descriptionPolicy() *bluemonday.Policy { 138 127 policy := bluemonday.NewPolicy() 139 128 policy.AllowStandardURLs() 140 129
+1 -109
appview/pages/pages.go
··· 88 88 return "templates/" + s + ".html" 89 89 } 90 90 91 - // FuncMap returns the template function map for use by external template consumers. 92 - func (p *Pages) FuncMap() template.FuncMap { 93 - return p.funcMap() 94 - } 95 - 96 - // FragmentPaths returns all fragment template paths from the embedded FS. 97 - func (p *Pages) FragmentPaths() ([]string, error) { 98 - return p.fragmentPaths() 99 - } 100 - 101 - // EmbedFS returns the embedded filesystem containing templates and static assets. 102 - func (p *Pages) EmbedFS() fs.FS { 103 - return p.embedFS 104 - } 105 - 106 - // ParseWith parses the base layout together with all appview fragments and 107 - // an additional template from extraFS identified by extraPath (relative to 108 - // extraFS root). The returned template is ready to ExecuteTemplate with 109 - // "layouts/base" -- primarily for use with the blog. 110 - func (p *Pages) ParseWith(extraFS fs.FS, extraPath string) (*template.Template, error) { 111 - fragmentPaths, err := p.fragmentPaths() 112 - if err != nil { 113 - return nil, err 114 - } 115 - 116 - funcs := p.funcMap() 117 - tpl, err := template.New("layouts/base"). 118 - Funcs(funcs). 119 - ParseFS(p.embedFS, append(fragmentPaths, p.nameToPath("layouts/base"))...) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - err = fs.WalkDir(extraFS, ".", func(path string, d fs.DirEntry, err error) error { 125 - if err != nil { 126 - return err 127 - } 128 - if d.IsDir() || !strings.HasSuffix(path, ".html") { 129 - return nil 130 - } 131 - if path != extraPath && !strings.Contains(path, "fragments/") { 132 - return nil 133 - } 134 - data, err := fs.ReadFile(extraFS, path) 135 - if err != nil { 136 - return err 137 - } 138 - if _, err = tpl.New(path).Parse(string(data)); err != nil { 139 - return err 140 - } 141 - return nil 142 - }) 143 - if err != nil { 144 - return nil, err 145 - } 146 - 147 - return tpl, nil 148 - } 149 - 150 91 func (p *Pages) fragmentPaths() ([]string, error) { 151 92 var fragmentPaths []string 152 93 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { ··· 319 260 320 261 type SignupParams struct { 321 262 CloudflareSiteKey string 322 - EmailId string 323 263 } 324 264 325 265 func (p *Pages) Signup(w io.Writer, params SignupParams) error { ··· 399 339 Timeline []models.TimelineEvent 400 340 Repos []models.Repo 401 341 GfiLabel *models.LabelDefinition 402 - BlueskyPosts []models.BskyPost 403 342 } 404 343 405 344 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { ··· 423 362 LoggedInUser *oauth.MultiAccountUser 424 363 Tab string 425 364 PunchcardPreference models.PunchcardPreference 426 - IsTnglSh bool 427 - IsDeactivated bool 428 - PdsDomain string 429 - HandleOpen bool 430 365 } 431 366 432 367 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { ··· 495 430 return p.execute("user/settings/notifications", w, params) 496 431 } 497 432 498 - type UserSiteSettingsParams struct { 499 - LoggedInUser *oauth.MultiAccountUser 500 - Claim *models.DomainClaim 501 - SitesDomain string 502 - IsTnglHandle bool 503 - Tab string 504 - } 505 - 506 - func (p *Pages) UserSiteSettings(w io.Writer, params UserSiteSettingsParams) error { 507 - params.Tab = "sites" 508 - return p.execute("user/settings/sites", w, params) 509 - } 510 - 511 433 type UpgradeBannerParams struct { 512 434 Registrations []models.Registration 513 435 Spindles []models.Spindle ··· 854 776 RepoInfo repoinfo.RepoInfo 855 777 Active string 856 778 BreadCrumbs [][]string 857 - Path string 779 + TreePath string 858 780 Raw bool 859 781 HTMLReadme template.HTML 860 782 EmailToDid map[string]string ··· 1079 1001 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params) 1080 1002 } 1081 1003 1082 - type RepoSiteSettingsParams struct { 1083 - LoggedInUser *oauth.MultiAccountUser 1084 - RepoInfo repoinfo.RepoInfo 1085 - Active string 1086 - Tab string 1087 - Branches []types.Branch 1088 - SiteConfig *models.RepoSite 1089 - OwnerClaim *models.DomainClaim 1090 - Deploys []models.SiteDeploy 1091 - IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or "" 1092 - } 1093 - 1094 - func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error { 1095 - params.Active = "settings" 1096 - params.Tab = "sites" 1097 - return p.executeRepo("repo/settings/sites", w, params) 1098 - } 1099 - 1100 1004 type RepoIssuesParams struct { 1101 1005 LoggedInUser *oauth.MultiAccountUser 1102 1006 RepoInfo repoinfo.RepoInfo ··· 1634 1538 } 1635 1539 1636 1540 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1637 - } 1638 - 1639 - func (p *Pages) DangerPasswordTokenStep(w io.Writer) error { 1640 - return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil) 1641 - } 1642 - 1643 - func (p *Pages) DangerPasswordSuccess(w io.Writer) error { 1644 - return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil) 1645 - } 1646 - 1647 - func (p *Pages) DangerDeleteTokenStep(w io.Writer) error { 1648 - return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil) 1649 1541 } 1650 1542 1651 1543 func (p *Pages) Error500(w io.Writer) error {
-283
appview/pages/templates/fragments/line-quote-button.html
··· 1 - {{ define "fragments/line-quote-button" }} 2 - <button 3 - id="line-quote-btn" 4 - type="button" 5 - aria-label="Quote line in comment" 6 - class="hidden fixed z-50 p-0.5 rounded bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer shadow-sm transition-opacity opacity-0 flex flex-col items-start" 7 - style="pointer-events: none;" 8 - > 9 - {{ i "message-square-quote" "w-3.5 h-3.5" }} 10 - <span id="line-quote-btn-end" class="hidden mt-auto rotate-180 opacity-50"> 11 - {{ i "message-square-quote" "w-3.5 h-3.5" }} 12 - </span> 13 - </button> 14 - <script> 15 - (() => { 16 - const btn = document.getElementById('line-quote-btn'); 17 - if (!btn) return; 18 - const btnEnd = document.getElementById('line-quote-btn-end'); 19 - 20 - const textarea = () => 21 - document.getElementById('pull-comment-textarea') 22 - || document.getElementById('comment-textarea'); 23 - 24 - const lineOf = (el) => 25 - el?.closest?.('span[id*="-O"]') 26 - || el?.closest?.('span[id*="-N"]'); 27 - 28 - const anchorOf = (el) => { 29 - const link = el.querySelector('a[href^="#"]'); 30 - return link ? link.getAttribute('href').slice(1) : el.id || null; 31 - }; 32 - 33 - const fileOf = (el) => { 34 - const d = el.closest('details[id^="file-"]'); 35 - return d ? d.id.replace(/^file-/, '') : null; 36 - }; 37 - 38 - const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1]; 39 - 40 - const columnOf = (el) => el.closest('.flex-col'); 41 - 42 - const linesInColumn = (col) => 43 - Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]')) 44 - .filter(s => s.querySelector('a[href^="#"]')); 45 - 46 - let dragLines = null; 47 - 48 - const rangeBetween = (a, b) => { 49 - const col = columnOf(a); 50 - if (!col || col !== columnOf(b)) return []; 51 - const all = dragLines || linesInColumn(col); 52 - const ai = all.indexOf(a); 53 - const bi = all.indexOf(b); 54 - if (ai === -1 || bi === -1) return []; 55 - return all.slice(Math.min(ai, bi), Math.max(ai, bi) + 1); 56 - }; 57 - 58 - const clearHl = (cls) => 59 - document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls)); 60 - 61 - const applyHl = (a, b, cls) => { 62 - clearHl(cls); 63 - const sel = rangeBetween(a, b); 64 - sel.forEach(el => el.classList.add(cls)); 65 - return sel; 66 - }; 67 - 68 - const highlightFromHash = () => { 69 - clearHl('line-range-hl'); 70 - const hash = decodeURIComponent(window.location.hash.slice(1)); 71 - if (!hash) return; 72 - const parts = hash.split('~'); 73 - const startEl = document.getElementById(parts[0]); 74 - 75 - if (!startEl) { 76 - const params = new URLSearchParams(window.location.search); 77 - const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p)); 78 - if (hasCombined && params.get('diff') !== 'unified') { 79 - params.set('diff', 'unified'); 80 - window.location.replace( 81 - `${window.location.pathname}?${params}${window.location.hash}` 82 - ); 83 - } 84 - return; 85 - } 86 - 87 - const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl; 88 - if (!endEl) return; 89 - 90 - const details = startEl.closest('details'); 91 - if (details) details.open = true; 92 - 93 - applyHl(startEl, endEl, 'line-range-hl'); 94 - requestAnimationFrame(() => 95 - startEl.scrollIntoView({ behavior: 'smooth', block: 'center' })); 96 - }; 97 - 98 - if (document.readyState === 'loading') { 99 - document.addEventListener('DOMContentLoaded', highlightFromHash); 100 - } else { 101 - highlightFromHash(); 102 - } 103 - window.addEventListener('hashchange', highlightFromHash); 104 - 105 - let dragging = false; 106 - let dragAnchor = null; 107 - let dragCurrent = null; 108 - let hoverTarget = null; 109 - 110 - const commentBtn = () => 111 - document.querySelector('[hx-get$="/comment"]:not(form *)'); 112 - 113 - const openCommentForm = () => { 114 - const ta = textarea(); 115 - if (ta) return Promise.resolve(ta); 116 - const trigger = commentBtn(); 117 - if (!trigger) return Promise.resolve(null); 118 - trigger.click(); 119 - return new Promise(resolve => { 120 - const handler = () => { 121 - const ta = textarea(); 122 - if (!ta) return; 123 - document.body.removeEventListener('htmx:afterSettle', handler); 124 - clearTimeout(timer); 125 - resolve(ta); 126 - }; 127 - const timer = setTimeout(() => { 128 - document.body.removeEventListener('htmx:afterSettle', handler); 129 - resolve(null); 130 - }, 5000); 131 - document.body.addEventListener('htmx:afterSettle', handler); 132 - }); 133 - }; 134 - 135 - const showBtn = (lineEl) => { 136 - if ((!textarea() && !commentBtn()) || !anchorOf(lineEl)) return; 137 - const rect = lineEl.getBoundingClientRect(); 138 - Object.assign(btn.style, { 139 - top: `${rect.top + rect.height / 2 - btn.offsetHeight / 2}px`, 140 - left: `${rect.left + 4}px`, 141 - height: '', 142 - opacity: '1', 143 - pointerEvents: 'auto', 144 - }); 145 - btn.classList.remove('hidden'); 146 - }; 147 - 148 - const hideBtn = () => { 149 - if (dragging) return; 150 - Object.assign(btn.style, { opacity: '0', pointerEvents: 'none', height: '' }); 151 - btnEnd.classList.add('hidden'); 152 - setTimeout(() => { if (btn.style.opacity === '0') btn.classList.add('hidden'); }, 150); 153 - }; 154 - 155 - const stretchBtn = (a, b) => { 156 - const aRect = a.getBoundingClientRect(); 157 - const bRect = b.getBoundingClientRect(); 158 - const top = Math.min(aRect.top, bRect.top); 159 - const bottom = Math.max(aRect.bottom, bRect.bottom); 160 - const multiLine = a !== b; 161 - Object.assign(btn.style, { 162 - top: `${top}px`, 163 - left: `${aRect.left + 4}px`, 164 - height: `${bottom - top}px`, 165 - }); 166 - if (multiLine) { btnEnd.classList.remove('hidden'); } 167 - else { btnEnd.classList.add('hidden'); } 168 - }; 169 - 170 - document.addEventListener('mouseover', (e) => { 171 - if (dragging || e.target === btn || btn.contains(e.target)) return; 172 - const el = lineOf(e.target); 173 - if (el && el !== hoverTarget) { hoverTarget = el; showBtn(el); } 174 - }); 175 - 176 - document.addEventListener('mouseout', (e) => { 177 - if (dragging) return; 178 - const el = lineOf(e.target); 179 - if (!el || lineOf(e.relatedTarget) === el || e.relatedTarget === btn || btn.contains(e.relatedTarget)) return; 180 - hoverTarget = null; 181 - hideBtn(); 182 - }); 183 - 184 - btn.addEventListener('mouseleave', (e) => { 185 - if (!dragging && !lineOf(e.relatedTarget)) { hoverTarget = null; hideBtn(); } 186 - }); 187 - 188 - btn.addEventListener('mousedown', (e) => { 189 - if (e.button !== 0 || !hoverTarget) return; 190 - e.preventDefault(); 191 - dragging = true; 192 - dragAnchor = dragCurrent = hoverTarget; 193 - const col = columnOf(hoverTarget); 194 - dragLines = col ? linesInColumn(col) : null; 195 - applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 196 - btn.style.pointerEvents = 'none'; 197 - document.body.style.userSelect = 'none'; 198 - }); 199 - 200 - document.addEventListener('mousemove', (e) => { 201 - if (!dragging) return; 202 - const el = lineOf(document.elementFromPoint(e.clientX, e.clientY)); 203 - if (!el || el === dragCurrent) return; 204 - if (columnOf(el) !== columnOf(dragAnchor)) return; 205 - dragCurrent = el; 206 - applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 207 - stretchBtn(dragAnchor, dragCurrent); 208 - }); 209 - 210 - const insertIntoTextarea = (ta, selected) => { 211 - const first = selected[0]; 212 - const last = selected[selected.length - 1]; 213 - const fNum = lineNumOf(first); 214 - const firstAnchor = anchorOf(first); 215 - 216 - if (!fNum || !firstAnchor) return; 217 - 218 - const file = fileOf(first); 219 - const lNum = lineNumOf(last); 220 - const lastAnchor = anchorOf(last); 221 - 222 - const label = selected.length === 1 223 - ? (file ? `${file}:${fNum}` : `L${fNum}`) 224 - : (file ? `${file}:${fNum}-${lNum}` : `L${fNum}-${lNum}`); 225 - 226 - const fragment = selected.length === 1 227 - ? firstAnchor 228 - : `${firstAnchor}~${lastAnchor}`; 229 - 230 - const md = `[\`${label}\`](${window.location.pathname}${window.location.search}#${fragment})`; 231 - 232 - const { selectionStart: s, selectionEnd: end, value } = ta; 233 - const before = value.slice(0, s); 234 - const after = value.slice(end); 235 - let pre = '', suf = ''; 236 - if (s === end && before.length > 0) { 237 - const cur = before.slice(before.lastIndexOf('\n') + 1); 238 - if (cur.length > 0) { 239 - const nextNl = after.indexOf('\n'); 240 - const rest = nextNl === -1 ? after : after.slice(0, nextNl); 241 - if (rest.trim().length === 0) { pre = '\n'; } 242 - else { pre = before.endsWith(' ') ? '' : ' '; suf = after.startsWith(' ') ? '' : ' '; } 243 - } 244 - } 245 - ta.value = before + pre + md + suf + after; 246 - ta.selectionStart = ta.selectionEnd = s + pre.length + md.length + suf.length; 247 - ta.focus(); 248 - ta.dispatchEvent(new Event('input', { bubbles: true })); 249 - }; 250 - 251 - document.addEventListener('mouseup', () => { 252 - if (!dragging) return; 253 - dragging = false; 254 - document.body.style.userSelect = ''; 255 - 256 - const selected = rangeBetween(dragAnchor, dragCurrent); 257 - if (selected.length > 0) { 258 - openCommentForm().then(ta => { 259 - if (ta) insertIntoTextarea(ta, selected); 260 - }); 261 - } 262 - 263 - clearHl('line-quote-hl'); 264 - dragLines = null; 265 - dragAnchor = dragCurrent = hoverTarget = null; 266 - hideBtn(); 267 - }); 268 - 269 - btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); 270 - 271 - const cancelDrag = () => { 272 - if (!dragging) return; 273 - dragging = false; 274 - document.body.style.userSelect = ''; 275 - clearHl('line-quote-hl'); 276 - dragLines = null; 277 - dragAnchor = dragCurrent = hoverTarget = null; 278 - hideBtn(); 279 - }; 280 - window.addEventListener('blur', cancelDrag); 281 - })(); 282 - </script> 283 - {{ end }}
+2 -19
appview/pages/templates/layouts/base.html
··· 39 39 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 40 40 41 41 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 42 - 43 - <script> 44 - document.addEventListener('DOMContentLoaded', () => { 45 - const nodes = document.querySelectorAll('pre.mermaid'); 46 - if (!nodes.length) return; 47 - const script = document.createElement('script'); 48 - script.src = '/static/mermaid.min.js'; 49 - script.onload = async () => { 50 - mermaid.initialize({ 51 - startOnLoad: true, 52 - theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default', 53 - }); 54 - await mermaid.run({ nodes }); 55 - }; 56 - document.head.appendChild(script); 57 - }); 58 - </script> 59 42 <title>{{ block "title" . }}{{ end }}</title> 60 43 {{ block "extrameta" . }}{{ end }} 61 44 </head> 62 - <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200 {{ block "bodyClasses" . }} {{ end }}"> 45 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 63 46 {{ block "topbarLayout" . }} 64 - <header class="w-full col-span-full md:col-span-1 md:col-start-2 drop-shadow-sm dark:text-white bg-white dark:bg-gray-800" style="z-index: 20;"> 47 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 65 48 66 49 {{ if .LoggedInUser }} 67 50 <div id="upgrade-banner"
+1 -1
appview/pages/templates/layouts/fragments/footer.html
··· 1 - {{ define "layouts/fragments/footerFull" }} 1 + {{ define "layouts/fragments/footer" }} 2 2 <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 3 <div class="mx-auto px-4"> 4 4 <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8">
-19
appview/pages/templates/layouts/fragments/footerMinimal.html
··· 1 - {{ define "layouts/fragments/footer" }} 2 - <footer class="w-full px-6 py-4 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700"> 3 - <div class="max-w-screen-lg mx-auto flex flex-wrap justify-center items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"> 4 - <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 5 - <a href="/" hx-boost="true" class="no-underline hover:no-underline flex items-center"> 6 - {{ template "fragments/dolly/logo" (dict "Classes" "size-5 text-gray-500 dark:text-gray-400") }} 7 - </a> 8 - <span>&copy; 2026 Tangled Labs Oy.</span> 9 - </div> 10 - <a href="https://blog.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">blog</a> 11 - <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">docs</a> 12 - <a href="https://tangled.org/tangled.org/core" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">source</a> 13 - <a href="https://chat.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">discord</a> 14 - <a href="https://bsky.app/profile/tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">bluesky</a> 15 - <a href="/terms" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">terms</a> 16 - <a href="/privacy" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">privacy</a> 17 - </div> 18 - </footer> 19 - {{ end }}
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="mx-auto space-x-4 px-6 py-2"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
+3 -4
appview/pages/templates/repo/blob.html
··· 31 31 {{ end }} 32 32 </div> 33 33 <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 34 - <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ pathEscape .Ref }}">{{ .Ref }}</a></span> 34 + <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 35 35 36 36 {{ if .BlobView.ShowingText }} 37 37 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> ··· 45 45 46 46 {{ if .BlobView.HasRawView }} 47 47 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 48 - <a href="/{{ .RepoInfo.FullName }}/raw/{{ pathEscape .Ref }}/{{ .Path }}">view raw</a> 48 + <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 49 49 {{ end }} 50 50 51 51 {{ if .BlobView.ShowToggle }} 52 52 <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 53 - <a href="/{{ .RepoInfo.FullName }}/blob/{{ pathEscape .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 53 + <a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 54 54 view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 55 55 </a> 56 56 {{ end }} ··· 152 152 showWrapContentToggleOnOverflow(); 153 153 })(); 154 154 </script> 155 - {{ template "repo/fragments/permalinkShortcut" . }} 156 155 {{ end }}
+5 -1
appview/pages/templates/repo/commit.html
··· 127 127 </div> 128 128 {{ end }} 129 129 130 - 130 + {{ define "footerLayout" }} 131 + <footer class="col-span-full mt-12"> 132 + {{ template "layouts/fragments/footer" . }} 133 + </footer> 134 + {{ end }} 131 135 132 136 {{ define "contentAfter" }} 133 137 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
+5 -1
appview/pages/templates/repo/compare/compare.html
··· 26 26 </div> 27 27 {{ end }} 28 28 29 - 29 + {{ define "footerLayout" }} 30 + <footer class="px-1 col-span-full mt-12"> 31 + {{ template "layouts/fragments/footer" . }} 32 + </footer> 33 + {{ end }} 30 34 31 35 {{ define "contentAfter" }} 32 36 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
+20 -90
appview/pages/templates/repo/fragments/diff.html
··· 8 8 #filesToggle:not(:checked) ~ div div#resize-files { display: none; } 9 9 </style> 10 10 11 - <div id="diff-area"> 12 - {{ template "diffTopbar" . }} 13 - {{ block "diffLayout" . }} {{ end }} 14 - {{ template "fragments/resizable" }} 15 - {{ template "activeFileHighlight" }} 16 - {{ template "fragments/line-quote-button" }} 17 - </div> 11 + {{ template "diffTopbar" . }} 12 + {{ block "diffLayout" . }} {{ end }} 13 + {{ template "fragments/resizable" }} 18 14 {{ end }} 19 15 20 16 {{ define "diffTopbar" }} ··· 88 84 {{ $id := index . 0 }} 89 85 {{ $target := index . 1 }} 90 86 {{ $direction := index . 2 }} 91 - <div id="{{ $id }}" 87 + <div id="{{ $id }}" 92 88 data-resizer="vertical" 93 89 data-target="{{ $target }}" 94 90 data-direction="{{ $direction }}" ··· 141 137 {{ $idx := index . 0 }} 142 138 {{ $file := index . 1 }} 143 139 {{ $isSplit := index . 2 }} 144 - {{ $isGenerated := false }} 145 140 {{ with $file }} 146 - {{ $n := .Names }} 147 - {{ if $n.New }} 148 - {{ $isGenerated = isGenerated $n.New }} 149 - {{ else if $n.Old }} 150 - {{ $isGenerated = isGenerated $n.Old }} 151 - {{ end }} 152 - <details {{ if not $isGenerated }}open{{ end }} id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 141 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 153 142 <summary class="list-none cursor-pointer sticky top-12 group-open:border-b border-gray-200 dark:border-gray-700"> 154 143 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 155 144 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> ··· 158 147 {{ template "repo/fragments/diffStatPill" .Stats }} 159 148 160 149 <div class="flex gap-2 items-center overflow-x-auto"> 150 + {{ $n := .Names }} 161 151 {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 162 152 {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 163 153 {{ else if $n.New }} 164 154 {{ $n.New }} 165 155 {{ else }} 166 156 {{ $n.Old }} 167 - {{ end }} 168 - {{ if $isGenerated }} 169 - <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default"> 170 - {{ i "circle-question-mark" "size-4" }} 171 - </span> 172 157 {{ end }} 173 158 </div> 174 159 </div> ··· 219 204 </span> 220 205 </label> 221 206 <script> 222 - (() => { 207 + document.addEventListener('DOMContentLoaded', function() { 223 208 const checkbox = document.getElementById('collapseToggle'); 224 - const diffArea = document.getElementById('diff-area'); 209 + const details = document.querySelectorAll('details[id^="file-"]'); 225 210 226 - checkbox.addEventListener('change', () => { 227 - document.querySelectorAll('details[id^="file-"]').forEach(detail => { 211 + checkbox.addEventListener('change', function() { 212 + details.forEach(detail => { 228 213 detail.open = checkbox.checked; 229 214 }); 230 215 }); 231 216 232 - if (window.__collapseToggleHandler) { 233 - diffArea.removeEventListener('toggle', window.__collapseToggleHandler, true); 234 - } 235 - 236 - const handler = (e) => { 237 - if (!e.target.matches('details[id^="file-"]')) return; 238 - const details = document.querySelectorAll('details[id^="file-"]'); 239 - const allOpen = Array.from(details).every(d => d.open); 240 - const allClosed = Array.from(details).every(d => !d.open); 241 - 242 - if (allOpen) checkbox.checked = true; 243 - else if (allClosed) checkbox.checked = false; 244 - }; 245 - 246 - window.__collapseToggleHandler = handler; 247 - diffArea.addEventListener('toggle', handler, true); 248 - })(); 249 - </script> 250 - {{ end }} 251 - 252 - {{ define "activeFileHighlight" }} 253 - <script> 254 - (() => { 255 - if (window.__activeFileScrollHandler) { 256 - document.removeEventListener('scroll', window.__activeFileScrollHandler); 257 - } 258 - 259 - const filetreeLinks = document.querySelectorAll('.filetree-link'); 260 - if (filetreeLinks.length === 0) return; 261 - 262 - const linkMap = new Map(); 263 - filetreeLinks.forEach(link => { 264 - const path = link.getAttribute('data-path'); 265 - if (path) linkMap.set('file-' + path, link); 266 - }); 217 + details.forEach(detail => { 218 + detail.addEventListener('toggle', function() { 219 + const allOpen = Array.from(details).every(d => d.open); 220 + const allClosed = Array.from(details).every(d => !d.open); 267 221 268 - let currentActive = null; 269 - const setActive = (link) => { 270 - if (link && link !== currentActive) { 271 - if (currentActive) currentActive.classList.remove('font-bold'); 272 - link.classList.add('font-bold'); 273 - currentActive = link; 274 - } 275 - }; 276 - 277 - filetreeLinks.forEach(link => { 278 - link.addEventListener('click', () => setActive(link)); 279 - }); 280 - 281 - const topbar = document.querySelector('.sticky.top-0.z-30'); 282 - const headerHeight = topbar ? topbar.offsetHeight : 0; 283 - 284 - const updateActiveFile = () => { 285 - const diffFiles = document.querySelectorAll('details[id^="file-"]'); 286 - Array.from(diffFiles).some(file => { 287 - const rect = file.getBoundingClientRect(); 288 - if (rect.top <= headerHeight && rect.bottom > headerHeight) { 289 - setActive(linkMap.get(file.id)); 290 - return true; 222 + if (allOpen) { 223 + checkbox.checked = true; 224 + } else if (allClosed) { 225 + checkbox.checked = false; 291 226 } 292 - return false; 293 227 }); 294 - }; 295 - 296 - window.__activeFileScrollHandler = updateActiveFile; 297 - document.addEventListener('scroll', updateActiveFile); 298 - updateActiveFile(); 299 - })(); 228 + }); 229 + }); 300 230 </script> 301 231 {{ end }}
+19 -27
appview/pages/templates/repo/fragments/diffOpts.html
··· 4 4 {{ $active = "split" }} 5 5 {{ end }} 6 6 7 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 7 + {{ $unified := 8 + (dict 9 + "Key" "unified" 10 + "Value" "unified" 11 + "Icon" "square-split-vertical" 12 + "Meta" "") }} 13 + {{ $split := 14 + (dict 15 + "Key" "split" 16 + "Value" "split" 17 + "Icon" "square-split-horizontal" 18 + "Meta" "") }} 19 + {{ $values := list $unified $split }} 9 20 10 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden" 11 - hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault(); this.querySelectorAll('button').forEach(b => { const active=b===t; b.classList.toggle('bg-white',active); b.classList.toggle('dark:bg-gray-700',active); b.classList.toggle('shadow-sm',active); b.classList.toggle('bg-gray-100',!active); b.classList.toggle('dark:bg-gray-800',!active); b.classList.toggle('shadow-inner',!active); })"> 12 - <button 13 - hx-get="?diff=unified" 14 - hx-target="#diff-files" 15 - hx-select="#diff-files" 16 - hx-swap="outerHTML" 17 - hx-push-url="true" 18 - class="group p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if eq $active "unified" }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 19 - {{ i "square-split-vertical" "size-4 inline group-[.htmx-request]:hidden" }} 20 - {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 - unified 22 - </button> 23 - <button 24 - hx-get="?diff=split" 25 - hx-target="#diff-files" 26 - hx-select="#diff-files" 27 - hx-swap="outerHTML" 28 - hx-push-url="true" 29 - class="group p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if eq $active "split" }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 30 - {{ i "square-split-horizontal" "size-4 inline group-[.htmx-request]:hidden" }} 31 - {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 - split 33 - </button> 34 - </div> 21 + {{ template "fragments/tabSelector" 22 + (dict 23 + "Name" "diff" 24 + "Values" $values 25 + "Active" $active) }} 35 26 {{ end }} 27 +
+4 -13
appview/pages/templates/repo/fragments/fileTree.html
··· 1 1 {{ define "repo/fragments/fileTree" }} 2 - {{/* tailwind safelist: 3 - group/level-1 group/level-2 group/level-3 group/level-4 group/level-5 group/level-6 4 - group/level-7 group/level-8 group/level-9 group/level-10 group/level-11 group/level-12 5 - group-open/level-1:hidden group-open/level-2:hidden group-open/level-3:hidden group-open/level-4:hidden group-open/level-5:hidden group-open/level-6:hidden 6 - group-open/level-7:hidden group-open/level-8:hidden group-open/level-9:hidden group-open/level-10:hidden group-open/level-11:hidden group-open/level-12:hidden 7 - group-open/level-1:block group-open/level-2:block group-open/level-3:block group-open/level-4:block group-open/level-5:block group-open/level-6:block 8 - group-open/level-7:block group-open/level-8:block group-open/level-9:block group-open/level-10:block group-open/level-11:block group-open/level-12:block 9 - */}} 10 2 {{ if and .Name .IsDirectory }} 11 - <details open class="group/level-{{ .Level }}"> 3 + <details open> 12 4 <summary class="cursor-pointer list-none pt-1"> 13 - <span class="tree-directory inline-flex items-center gap-2"> 14 - {{ i "folder" (printf "flex-shrink-0 size-4 group-open/level-%d:hidden" .Level)}} 15 - {{ i "folder-open" (printf "flex-shrink-0 size-4 hidden group-open/level-%d:block" .Level)}} 5 + <span class="tree-directory inline-flex items-center gap-2 "> 6 + {{ i "folder" "flex-shrink-0 size-4 fill-current" }} 16 7 <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 17 8 </span> 18 9 </summary> ··· 25 16 {{ else if .Name }} 26 17 <div class="tree-file flex items-center gap-2 pt-1"> 27 18 {{ i "file" "flex-shrink-0 size-4" }} 28 - <a href="#file-{{ .Path }}" data-path="{{ .Path }}" class="filetree-link filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 19 + <a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 29 20 </div> 30 21 {{ else }} 31 22 {{ range $child := .Children }}
+6 -6
appview/pages/templates/repo/fragments/lastCommitPanel.html
··· 1 1 {{ define "repo/fragments/lastCommitPanel" }} 2 2 {{ $messageParts := splitN .LastCommitInfo.Message "\n\n" 2 }} 3 - <div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap text-sm gap-2"> 4 - <div class="flex flex-wrap items-center gap-1"> 3 + <div class="pb-2 mb-3 border-b border-gray-200 dark:border-gray-700 flex flex-col md:flex-row md:items-center md:justify-between text-sm gap-2"> 4 + <div class="flex flex-col md:flex-row md:items-center gap-1"> 5 5 {{ if .LastCommitInfo.Author }} 6 6 {{ $authorDid := index .EmailToDid .LastCommitInfo.Author.Email }} 7 7 <span class="flex items-center gap-1"> ··· 12 12 <a href="mailto:{{ .LastCommitInfo.Author.Email }}" class="no-underline hover:underline">{{ .LastCommitInfo.Author.Name }}</a> 13 13 {{ end }} 14 14 </span> 15 - <span class="px-1 select-none before:content-['\00B7'] text-gray-400 dark:text-gray-500"></span> 15 + <span class="hidden md:inline px-1 select-none before:content-['\00B7']"></span> 16 16 {{ end }} 17 17 <a href="/{{ .RepoInfo.FullName }}/commit/{{ .LastCommitInfo.Hash }}" 18 18 class="inline no-underline hover:underline dark:text-white"> 19 19 {{ index $messageParts 0 }} 20 20 </a> 21 - <span class="px-1 select-none before:content-['\00B7'] text-gray-400 dark:text-gray-500"></span> 22 - <span class="text-gray-400 dark:text-gray-500">{{ template "repo/fragments/shortTimeAgo" .LastCommitInfo.When }}</span> 21 + <span class="hidden md:inline px-1 select-none before:content-['\00B7']"></span> 22 + <span class="text-gray-400 dark:text-gray-500">{{ template "repo/fragments/time" .LastCommitInfo.When }}</span> 23 23 </div> 24 24 <a href="/{{ .RepoInfo.FullName }}/commit/{{ .LastCommitInfo.Hash.String }}" 25 25 class="no-underline hover:underline text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded font-mono text-xs w-fit"> 26 26 {{ slice .LastCommitInfo.Hash.String 0 8 }} 27 27 </a> 28 28 </div> 29 - {{ end }} 29 + {{ end }}
-20
appview/pages/templates/repo/fragments/permalinkShortcut.html
··· 1 - {{ define "repo/fragments/permalinkShortcut" }} 2 - <script> 3 - (() => { 4 - {{ if .LastCommitInfo }} 5 - const abortController = new AbortController(); 6 - document.addEventListener('keydown', (e) => { 7 - if(e.key === 'y' && !e.ctrlKey && !e.metaKey && !e.altKey) { 8 - if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 9 - const permalinkUrl = '/{{ .RepoInfo.FullName }}/tree/{{ .LastCommitInfo.Hash }}/{{ .Path }}'; 10 - window.location.href = permalinkUrl; 11 - } 12 - }, {signal: abortController.signal}); 13 - 14 - document.body.addEventListener('htmx:beforeCleanupElement', (e) => { 15 - abortController.abort(); 16 - }, {signal: abortController.signal}); 17 - {{ end }} 18 - })(); 19 - </script> 20 - {{ end }}
-1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 22 22 <button 23 23 class="btn-create py-0 flex gap-1 items-center group text-sm" 24 24 hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 - hx-trigger="click, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#edit-textarea-{{ .Comment.Id }}" 26 25 hx-include="#edit-textarea-{{ .Comment.Id }}" 27 26 hx-target="#comment-body-{{ .Comment.Id }}" 28 27 hx-swap="outerHTML">
+2 -8
appview/pages/templates/repo/issues/fragments/newComment.html
··· 3 3 <form 4 4 id="comment-form" 5 5 hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 - hx-trigger="submit, keydown[commentButtonEnabled() && (ctrlKey || metaKey) && key=='Enter'] from:#comment-textarea" 7 - hx-indicator="#comment-button" 8 - hx-disabled-elt="#comment-form button" 9 6 hx-on::after-request="if(event.detail.successful) this.reset()" 10 7 > 11 8 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> ··· 27 24 <div class="flex gap-2 mt-2"> 28 25 <button 29 26 id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 30 28 type="submit" 29 + hx-disabled-elt="#comment-button" 31 30 class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 32 31 disabled 33 32 > ··· 102 101 {{ end }} 103 102 104 103 <script> 105 - function commentButtonEnabled() { 106 - const commentButton = document.getElementById('comment-button'); 107 - return !commentButton.hasAttribute('disabled'); 108 - } 109 - 110 104 function updateCommentForm() { 111 105 const textarea = document.getElementById('comment-textarea'); 112 106 const commentButton = document.getElementById('comment-button');
-1
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 6 6 {{ else }} 7 7 hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 8 {{ end }} 9 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:(#title,#body)" 10 9 hx-swap="none" 11 10 hx-indicator="#spinner"> 12 11 <div class="flex flex-col gap-2">
+5 -2
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 3 3 class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 4 id="reply-form-{{ .Comment.Id }}" 5 5 hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#reply-{{.Comment.Id}}-textarea" 7 6 hx-on::after-request="if(event.detail.successful) this.reset()" 8 7 hx-disabled-elt="#reply-{{ .Comment.Id }}" 9 8 > ··· 14 13 class="w-full p-2" 15 14 placeholder="Leave a reply..." 16 15 autofocus 17 - rows="3"></textarea> 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 18 21 19 22 <input 20 23 type="text"
-2
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 5 5 {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 6 6 <form 7 7 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 8 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#pull-comment-textarea" 9 8 hx-swap="none" 10 9 hx-on::after-request="if(event.detail.successful) this.reset()" 11 10 hx-disabled-elt="#reply-{{ .RoundNumber }}" 12 11 class="w-full flex flex-wrap gap-2 group" 13 12 > 14 13 <textarea 15 - id="pull-comment-textarea" 16 14 name="body" 17 15 class="w-full p-2 rounded border" 18 16 rows=8
-1
appview/pages/templates/repo/pulls/new.html
··· 7 7 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 10 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:(#patch,#title,#body)" 11 10 hx-indicator="#create-pull-spinner" 12 11 hx-swap="none" 13 12 >
+7 -18
appview/pages/templates/repo/pulls/pull.html
··· 154 154 {{ define "subsPanel" }} 155 155 {{ $root := index . 2 }} 156 156 {{ $pull := $root.Pull }} 157 - {{ $bgColor := "bg-gray-600 dark:bg-gray-700" }} 158 - 159 - {{ if $pull.State.IsOpen }} 160 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 161 - {{ else if $pull.State.IsMerged }} 162 - {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 163 - {{ else if $pull.State.IsDeleted }} 164 - {{ $bgColor = "bg-red-600 dark:bg-red-700" }} 165 - {{ end }} 166 - 167 157 <!-- backdrop overlay - only visible on mobile when open --> 168 158 <div id="bottomSheetBackdrop" class="fixed inset-0 bg-black/50 md:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div> 169 159 <!-- right panel - bottom sheet on mobile, side panel on desktop --> ··· 173 163 flex gap-4 items-center justify-between 174 164 rounded-t-2xl md:rounded-t cursor-pointer list-none p-4 md:h-12 175 165 text-white md:text-black md:dark:text-white 176 - {{ $bgColor }} 166 + bg-green-600 dark:bg-green-700 177 167 md:bg-white md:dark:bg-gray-800 178 - drop-shadow-sm border-t md:border-x border-gray-200 dark:border-gray-700"> 168 + drop-shadow-sm 169 + border-t md:border-x md:border-t-0 border-gray-200 dark:border-gray-700"> 179 170 <h2 class="">History</h2> 180 171 {{ template "subsPanelSummary" $ }} 181 172 </summary> ··· 261 252 {{ $root := index . 3 }} 262 253 {{ $round := $item.RoundNumber }} 263 254 <div class=" 264 - w-full shadow-sm bg-gray-50 dark:bg-gray-900 border border-t-0 255 + w-full shadow-sm bg-gray-50 dark:bg-gray-900 border-2 border-t-0 265 256 {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 266 257 {{ if eq $round $root.ActiveRound }} 267 258 border-blue-200 dark:border-blue-700 ··· 280 271 {{ $root := index . 3 }} 281 272 {{ $round := $item.RoundNumber }} 282 273 <div class=" 283 - {{ if ne $round 0 }}rounded-t{{ end }} 274 + {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 284 275 px-6 py-4 pr-2 pt-2 285 276 bg-white dark:bg-gray-800 286 - 287 277 {{ if eq $round $root.ActiveRound }} 288 - border-t border-t-blue-200 dark:border-t-blue-700 278 + border-t-2 border-blue-200 dark:border-blue-700 289 279 {{ else }} 290 - border-t border-t-gray-200 dark:border-t-gray-700 280 + border-b-2 border-gray-200 dark:border-gray-700 291 281 {{ end }} 292 - 293 282 flex gap-2 sticky top-0 z-20"> 294 283 <!-- left column: just profile picture --> 295 284 <div class="flex-shrink-0 pt-2">
-268
appview/pages/templates/repo/settings/sites.html
··· 1 - {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 - <div class="col-span-1"> 6 - {{ template "repo/settings/fragments/sidebar" . }} 7 - </div> 8 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 - {{ template "repoSiteSettings" . }} 10 - </div> 11 - </section> 12 - {{ end }} 13 - 14 - {{ define "repoSiteSettings" }} 15 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start"> 16 - <div class="col-span-1 md:col-span-2"> 17 - <h2 class="text-sm pb-2 uppercase font-bold">Git Sites</h2> 18 - <p class="text-gray-500 dark:text-gray-400"> 19 - Serve a static site directly from this repository. 20 - Choose a branch and the directory containing your <code>index.html</code>. 21 - Only repository owners can configure sites. 22 - </p> 23 - </div> 24 - </div> 25 - 26 - {{ if and .SiteConfig .OwnerClaim }} 27 - {{ if .SiteConfig.IsIndex }} 28 - <div class="flex items-center gap-2 px-3 py-2 rounded border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 text-sm text-green-800 dark:text-green-300"> 29 - {{ i "circle-check" "size-4 shrink-0" }} 30 - live at <a class="underline font-mono" href="https://{{ .OwnerClaim.Domain }}">{{ .OwnerClaim.Domain }}</a> 31 - </div> 32 - {{ else }} 33 - <div class="flex items-center gap-2 px-3 py-2 rounded border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 text-sm text-green-800 dark:text-green-300"> 34 - {{ i "circle-check" "size-4 shrink-0" }} 35 - live at <a class="underline font-mono" href="https://{{ .OwnerClaim.Domain }}/{{ .RepoInfo.Name }}">{{ .OwnerClaim.Domain }}/{{ .RepoInfo.Name }}</a> 36 - </div> 37 - {{ end }} 38 - {{ else if and .SiteConfig (not .OwnerClaim) }} 39 - <div class="flex items-center gap-2 px-3 py-2 rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-800 dark:text-amber-300"> 40 - {{ i "triangle-alert" "size-4 shrink-0" }} 41 - site is configured but not live &mdash; <a class="underline" href="/settings/sites">claim a domain</a> to publish it. 42 - </div> 43 - {{ else if and (not .SiteConfig) .OwnerClaim }} 44 - <div class="flex items-center gap-2 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-sm text-gray-600 dark:text-gray-400"> 45 - {{ i "circle-dashed" "size-4 shrink-0" }} 46 - not enabled &mdash; configure a branch below to publish to <span class="font-mono">{{ .OwnerClaim.Domain }}</span>. 47 - </div> 48 - {{ else }} 49 - <div class="flex items-center gap-2 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-sm text-gray-600 dark:text-gray-400"> 50 - {{ i "circle-dashed" "size-4 shrink-0" }} 51 - not enabled &mdash; configure a branch below and <a class="underline" href="/settings/sites">claim a domain</a> to publish. 52 - </div> 53 - {{ end }} 54 - 55 - <form 56 - hx-put="/{{ $.RepoInfo.FullName }}/settings/sites" 57 - hx-indicator="#sites-spinner" 58 - hx-swap="none" 59 - class="flex flex-col gap-4" 60 - > 61 - <fieldset class="flex flex-col gap-4" {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 62 - 63 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 64 - <div class="col-span-1 md:col-span-2"> 65 - <h2 class="text-sm pb-2 uppercase font-bold">Branch</h2> 66 - <p class="text-gray-500 dark:text-gray-400"> 67 - The branch to build and deploy the site from. 68 - </p> 69 - </div> 70 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 71 - <select 72 - id="sites-branch" 73 - name="branch" 74 - required 75 - class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 76 - <option value="" disabled {{ if not .SiteConfig }}selected{{ end }}> 77 - Choose a branch 78 - </option> 79 - {{ range .Branches }} 80 - <option value="{{ .Name }}" 81 - {{ if and $.SiteConfig (eq .Name $.SiteConfig.Branch) }}selected{{ end }}> 82 - {{ .Name }}{{ if .IsDefault }} (default){{ end }} 83 - </option> 84 - {{ end }} 85 - </select> 86 - </div> 87 - </div> 88 - 89 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 90 - <div class="col-span-1 md:col-span-2"> 91 - <h2 class="text-sm pb-2 uppercase font-bold">Deploy directory</h2> 92 - <p class="text-gray-500 dark:text-gray-400"> 93 - Path within the repository that contains your <code>index.html</code>. 94 - Use <code>/</code> for the root, or a subdirectory like <code>/docs</code>. 95 - </p> 96 - </div> 97 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 98 - <input 99 - type="text" 100 - id="sites-dir" 101 - name="dir" 102 - placeholder="/" 103 - value="{{ if .SiteConfig }}{{ .SiteConfig.Dir }}{{ else }}/{{ end }}" 104 - pattern="\/.*" 105 - class="font-mono w-full" 106 - /> 107 - </div> 108 - </div> 109 - 110 - <div class="flex flex-col gap-2"> 111 - <h2 class="text-sm pb-2 uppercase font-bold">Site type</h2> 112 - <p class="text-gray-500 dark:text-gray-400"> 113 - An <strong>index site</strong> is served at the root of your sites domain. 114 - A <strong>sub-path site</strong> is served under the repository name. 115 - </p> 116 - <div class="flex flex-col sm:flex-row gap-3 pt-1"> 117 - <label class="flex items-start gap-2 flex-1 border rounded p-3 118 - {{ if and .IndexSiteTakenBy (not (and .SiteConfig .SiteConfig.IsIndex)) }} 119 - border-gray-100 dark:border-gray-800 opacity-50 cursor-not-allowed 120 - {{ else }} 121 - border-gray-200 dark:border-gray-700 cursor-pointer 122 - {{ end }}"> 123 - <input 124 - type="radio" 125 - name="is_index" 126 - value="true" 127 - class="mt-0.5" 128 - {{ if and .SiteConfig .SiteConfig.IsIndex }}checked{{ end }} 129 - {{ if not .SiteConfig }}checked{{ end }} 130 - {{ if and .IndexSiteTakenBy (not (and .SiteConfig .SiteConfig.IsIndex)) }}disabled{{ end }} 131 - /> 132 - <div> 133 - <span class="font-medium">index site</span> 134 - <p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> 135 - {{ if .OwnerClaim }} 136 - <code>{{ .OwnerClaim.Domain }}</code> 137 - {{ else }} 138 - e.g. <code>you.tngl.page</code> 139 - {{ end }} 140 - </p> 141 - {{ if and .IndexSiteTakenBy (not (and .SiteConfig .SiteConfig.IsIndex)) }} 142 - <p class="text-xs text-amber-600 dark:text-amber-400 mt-1"> 143 - already used by <code>{{ .IndexSiteTakenBy }}</code> 144 - </p> 145 - {{ end }} 146 - </div> 147 - </label> 148 - <label class="flex items-start gap-2 cursor-pointer flex-1 border border-gray-200 dark:border-gray-700 rounded p-3"> 149 - <input 150 - type="radio" 151 - name="is_index" 152 - value="false" 153 - class="mt-0.5" 154 - {{ if and .SiteConfig (not .SiteConfig.IsIndex) }}checked{{ end }} 155 - /> 156 - <div> 157 - <span class="font-medium">sub-path site</span> 158 - <p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> 159 - {{ if .OwnerClaim }} 160 - <code>{{ .OwnerClaim.Domain }}/{{ $.RepoInfo.Name }}</code> 161 - {{ else }} 162 - e.g. <code>you.tngl.page/{{ $.RepoInfo.Name }}</code> 163 - {{ end }} 164 - </p> 165 - </div> 166 - </label> 167 - </div> 168 - </div> 169 - 170 - <div id="repo-sites-error" class="text-red-500 dark:text-red-400"></div> 171 - 172 - <div class="flex justify-end items-center gap-2"> 173 - <button 174 - type="submit" 175 - class="btn-create flex items-center gap-2 group"> 176 - {{ i "save" "size-4" }} 177 - save 178 - <span id="sites-spinner" class="group"> 179 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 180 - </span> 181 - </button> 182 - </div> 183 - 184 - </fieldset> 185 - </form> 186 - 187 - {{ if .SiteConfig }} 188 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 189 - <div class="col-span-1 md:col-span-2"> 190 - <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Disable Site</h2> 191 - <p class="text-red-500 dark:text-red-400"> 192 - Removes the site configuration for this repository. The site will no longer be served. 193 - </p> 194 - </div> 195 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 196 - <form 197 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/sites" 198 - hx-confirm="Disable site for {{ $.RepoInfo.Name }}? The configuration will be removed." 199 - hx-swap="none"> 200 - <button 201 - type="submit" 202 - class="btn group flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 203 - {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 204 - {{ i "trash-2" "size-4" }} 205 - disable 206 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 207 - </button> 208 - </form> 209 - </div> 210 - </div> 211 - {{ end }} 212 - 213 - <div class="flex flex-col gap-3"> 214 - <h2 class="text-sm uppercase font-bold">Recent Deploys</h2> 215 - {{ if .Deploys }} 216 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded"> 217 - {{ range .Deploys }} 218 - <div class="flex flex-col gap-1 p-3 text-sm"> 219 - <div class="flex items-center gap-2"> 220 - {{ if eq .Status "success" }} 221 - <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 222 - {{ i "circle-check" "size-3" }} 223 - success 224 - </span> 225 - {{ else }} 226 - <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"> 227 - {{ i "circle-x" "size-3" }} 228 - failed 229 - </span> 230 - {{ end }} 231 - {{ if eq .Trigger "push" }} 232 - <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> 233 - {{ i "git-commit-horizontal" "size-3" }} 234 - push 235 - </span> 236 - {{ else if eq .Trigger "config_change" }} 237 - <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"> 238 - {{ i "settings" "size-3" }} 239 - config change 240 - </span> 241 - {{ end }} 242 - <span class="font-mono text-xs text-gray-600 dark:text-gray-400"> 243 - {{ .Branch }}{{ if ne .Dir "/" }}{{ .Dir }}{{ end }} 244 - </span> 245 - {{ if .CommitSHA }} 246 - <span class="font-mono text-xs text-gray-400 dark:text-gray-500"> 247 - {{ slice .CommitSHA 0 7 }} 248 - </span> 249 - {{ end }} 250 - <span class="ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap"> 251 - {{ template "repo/fragments/shortTimeAgo" .CreatedAt }} 252 - </span> 253 - </div> 254 - {{ if .Error }} 255 - <div class="mt-1 text-xs font-mono text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded p-2 break-all"> 256 - {{ .Error }} 257 - </div> 258 - {{ end }} 259 - </div> 260 - {{ end }} 261 - </div> 262 - {{ else }} 263 - <div class="flex items-center justify-center p-6 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded"> 264 - no deploys yet 265 - </div> 266 - {{ end }} 267 - </div> 268 - {{ end }}
+3 -4
appview/pages/templates/repo/tree.html
··· 59 59 {{ range .Files }} 60 60 <div class="grid grid-cols-12 gap-4 items-center py-1"> 61 61 <div class="col-span-8 md:col-span-4"> 62 - {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.Path .Name }} 62 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }} 63 63 {{ $icon := "folder" }} 64 64 {{ $iconStyle := "size-4 fill-current" }} 65 65 66 66 {{ if .IsSubmodule }} 67 - {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.Path .Name }} 67 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 68 68 {{ $icon = "folder-input" }} 69 69 {{ $iconStyle = "size-4" }} 70 70 {{ end }} 71 71 72 72 {{ if .IsFile }} 73 - {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.Path .Name }} 73 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 74 74 {{ $icon = "file" }} 75 75 {{ $iconStyle = "size-4" }} 76 76 {{ end }} ··· 99 99 100 100 </div> 101 101 </main> 102 - {{ template "repo/fragments/permalinkShortcut" . }} 103 102 {{end}} 104 103 105 104 {{ define "repoAfter" }}
+30 -27
appview/pages/templates/timeline/fragments/hero.html
··· 1 1 {{ define "timeline/fragments/hero" }} 2 - <div class="mx-auto max-w-[100rem] grid grid-cols-1 md:grid-cols-2 text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 - <h1 class="font-bold text-5xl md:text-6xl lg:text-8xl">tightly-knit<br>social coding.</h1> 4 - <div id="hero-right" class="space-y-6"> 5 - <p class="text-xl"> 6 - The next-generation social coding platform. 7 - </p> 8 - <p class="text-xl"> 9 - We envision a place where developers have ownership of their code, 10 - communities can freely self-govern and most importantly, coding can 11 - be social and fun again. 12 - </p> 13 - <form class="flex gap-2 items-stretch" method="get" action="/signup"> 14 - <input 15 - type="email" 16 - id="email" 17 - name="id" 18 - tabindex="4" 19 - required 20 - placeholder="Enter your email" 21 - class="py-2 w-full md:w-fit" 22 - /> 23 - <button class="btn-create flex items-center gap-2 text-base whitespace-nowrap" type="submit"> 24 - join now 25 - {{ i "arrow-right" "size-4" }} 26 - </button> 27 - </form> 28 - </div> 2 + <div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 + <div class="flex flex-col gap-6"> 4 + <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 + 6 + <p class="text-lg"> 7 + Tangled is a decentralized Git hosting and collaboration platform. 8 + </p> 9 + <p class="text-lg"> 10 + We envision a place where developers have complete ownership of their 11 + code, open source communities can freely self-govern and most 12 + importantly, coding can be social and fun again. 13 + </p> 14 + 15 + <div class="flex gap-6 items-center"> 16 + <a href="/signup" class="no-underline hover:no-underline "> 17 + <button class="btn-create flex gap-2 px-4 items-center"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </button> 20 + </a> 21 + </div> 22 + </div> 23 + 24 + <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.org/@tangled.org/core" class="block"> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 + </a> 28 + <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 + Monorepo for Tangled, built in the open with the community. 30 + </figcaption> 31 + </figure> 29 32 </div> 30 33 {{ end }}
-179
appview/pages/templates/timeline/fragments/preview.html
··· 1 - {{ define "timeline/fragments/preview" }} 2 - <div class="flex flex-col gap-4 overflow-hidden"> 3 - <div class="relative h-fit md:h-96 w-full"> 4 - <!-- left fade overlay (fixed on left edge) --> 5 - <div class="absolute left-0 top-0 h-full w-16 pointer-events-none z-10 bg-gradient-to-r from-white to-transparent dark:from-gray-900"></div> 6 - 7 - <!-- right fade overlay (fixed on right edge) --> 8 - <div class="absolute right-0 top-0 h-full w-16 pointer-events-none z-10 bg-gradient-to-l from-white to-transparent dark:from-gray-900"></div> 9 - 10 - {{ template "marquee" $ }} 11 - {{ template "marquee-mobile" $ }} 12 - </div> 13 - </div> 14 - {{ end }} 15 - 16 - {{ define "marquee" }} 17 - {{ $rowOffsets := list 10 80 150 220 }} 18 - {{ $prev := -1 }} 19 - {{ $w := mul (len .Timeline) 150 }} 20 - 21 - <div class="absolute h-full hidden md:flex animate-marquee"> 22 - <div class="relative h-full flex-shrink-0 bottom-4 border-b border-gray-200 dark:border-gray-700" style="width: {{ $w }}px;"> 23 - {{ range $i, $e := .Timeline }} 24 - {{ $curr := randInt 4 }} 25 - {{ if eq $curr $prev }} 26 - {{ $curr = mod (add $curr 1) 4 }} 27 - {{ end }} 28 - {{ $offset := index $rowOffsets $curr }} 29 - {{ template "timelineEvent" (list $i $e $offset) }} 30 - {{ $prev = $curr }} 31 - {{ end }} 32 - </div> 33 - 34 - <div 35 - class="absolute left-0 bottom-0 h-3 flex-shrink-0 bg-[linear-gradient(to_right,currentColor_1px,transparent_1px)] bg-[length:6px_100%] bg-repeat-x text-gray-400 dark:text-gray-500" 36 - style="width: {{ $w }}px;"> 37 - </div> 38 - 39 - </div> 40 - {{ end }} 41 - 42 - {{ define "marquee-mobile" }} 43 - <div class="flex md:hidden flex-col gap-4 my-auto"> 44 - {{ range $rowIndex := list 0 1 2 3 }} 45 - {{ $offset := mul (add $rowIndex 2) 10 }} 46 - {{ if eq (mod $rowIndex 2) 0 }} 47 - {{ $offset = mul $offset -1 }} 48 - {{ end }} 49 - <div class="flex gap-4 overflow-hidden" style="padding-left: {{ $offset }}px; "> 50 - <div class="flex gap-4 animate-marquee"> 51 - {{ range $i, $e := $.Timeline }} 52 - {{ if eq (mod $i 4) $rowIndex }} 53 - <div class="flex-shrink-0"> 54 - {{ template "eventCard" (list $ $e) }} 55 - </div> 56 - {{ end }} 57 - {{ end }} 58 - </div> 59 - </div> 60 - {{ end }} 61 - </div> 62 - {{ end }} 63 - 64 - {{ define "eventCard" }} 65 - {{ $root := index . 0 }} 66 - {{ $e := index . 1 }} 67 - {{ with $e }} 68 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm bg-white dark:bg-gray-800 drop-shadow-sm"> 69 - {{ if .Repo }} 70 - {{ template "repoEvent" (list $root .) }} 71 - {{ else if .RepoStar }} 72 - {{ template "starEvent" (list $root .) }} 73 - {{ else if .Follow }} 74 - {{ template "followEvent" (list $root .) }} 75 - {{ end }} 76 - </div> 77 - {{ end }} 78 - {{ end }} 79 - 80 - {{ define "timelineEvent" }} 81 - {{ $i := index . 0 }} 82 - {{ $e := index . 1 }} 83 - {{ $variance := randInt 10 }} 84 - {{ $offset := add (index . 2) $variance }} 85 - {{ $left := mul $i 175 }} 86 - <div 87 - class="absolute left-0" 88 - style="bottom: {{ $offset }}px; left: {{ $left }}px; transform: translateX(-50%); z-index: 5;" 89 - > 90 - {{ template "eventCard" (list $ $e) }} 91 - </div> 92 - 93 - <!-- vertical connector --> 94 - <div 95 - class="absolute w-px bg-gray-300 dark:bg-gray-700" 96 - style="left: {{ $left }}px; bottom: 0px; height: {{ $offset }}px; z-index: 0;" 97 - ></div> 98 - 99 - <!-- dot at the bottom --> 100 - <div 101 - class="absolute size-2 bg-gray-300 dark:bg-gray-600 rounded-full" 102 - style="left: {{ sub $left 3 }}px; bottom: -3px; z-index: 0;"> 103 - </div> 104 - {{ end }} 105 - 106 - {{ define "repoEvent" }} 107 - {{ $root := index . 0 }} 108 - {{ $event := index . 1 }} 109 - {{ $repo := $event.Repo }} 110 - {{ $source := $event.Source }} 111 - {{ $userHandle := resolve $repo.Did }} 112 - <div class="bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex w-max items-stretch divide-x divide-gray-200 dark:divide-gray-700 text-sm"> 113 - <div class="p-4 bg-gray-100 dark:bg-gray-900/50 h-full"> 114 - {{ i "folder-plus" "size-4" }} 115 - </div> 116 - <div class="px-4 flex items-center gap-2"> 117 - {{ template "user/fragments/picHandleLink" $repo.Did }} 118 - {{ with $source }} 119 - {{ $sourceDid := resolve .Did }} 120 - forked 121 - <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 122 - {{ $sourceDid }}/{{ .Name }} 123 - </a> 124 - to 125 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 126 - {{ else }} 127 - created 128 - <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 129 - {{ $repo.Name }} 130 - </a> 131 - {{ end }} 132 - </div> 133 - </div> 134 - {{ end }} 135 - 136 - {{ define "starEvent" }} 137 - {{ $root := index . 0 }} 138 - {{ $event := index . 1 }} 139 - {{ $star := $event.RepoStar }} 140 - {{ with $star }} 141 - {{ $starrerHandle := resolve .Did }} 142 - {{ $repoOwnerHandle := resolve .Repo.Did }} 143 - <div class="bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex w-max items-stretch divide-x divide-gray-200 dark:divide-gray-700 text-sm"> 144 - <div class="p-4 bg-gray-100 dark:bg-gray-900/50 h-full"> 145 - {{ i "star" "size-4" }} 146 - </div> 147 - <div class="px-4 flex items-center gap-2"> 148 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 149 - starred 150 - {{ template "user/fragments/pic" (list $repoOwnerHandle "size-6") }} 151 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 152 - {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 153 - </a> 154 - </div> 155 - </div> 156 - {{ end }} 157 - {{ end }} 158 - 159 - {{ define "followEvent" }} 160 - {{ $root := index . 0 }} 161 - {{ $event := index . 1 }} 162 - {{ $follow := $event.Follow }} 163 - {{ $profile := $event.Profile }} 164 - {{ $followStats := $event.FollowStats }} 165 - {{ $followStatus := $event.FollowStatus }} 166 - 167 - {{ $userHandle := resolve $follow.UserDid }} 168 - {{ $subjectHandle := resolve $follow.SubjectDid }} 169 - <div class="bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex w-max items-stretch divide-x divide-gray-200 dark:divide-gray-700 text-sm"> 170 - <div class="p-4 bg-gray-100 dark:bg-gray-900/50 h-full"> 171 - {{ i "user-plus" "size-4" }} 172 - </div> 173 - <div class="px-4 flex items-center gap-2"> 174 - {{ template "user/fragments/picHandleLink" $userHandle }} 175 - followed 176 - {{ template "user/fragments/picHandleLink" $subjectHandle }} 177 - </div> 178 - </div> 179 - {{ end }}
+66 -490
appview/pages/templates/timeline/home.html
··· 6 6 <meta property="og:type" content="website" /> 7 7 <meta property="og:url" content="https://tangled.org" /> 8 8 <meta property="og:description" content="The next-generation social coding platform." /> 9 - <meta property="og:image" content="https://assets.tangled.network/tangled_og.png" /> 9 + <meta property="og:image" content="https://assets.tangled.network/what-is-tangled-repo.png" /> 10 10 <meta property="og:image:width" content="1200" /> 11 11 <meta property="og:image:height" content="630" /> 12 12 ··· 19 19 <!-- Additional SEO --> 20 20 <meta name="description" content="The next-generation social coding platform. Host repos on your infrastructure with knots, use stacked pull requests, and run CI with spindles." /> 21 21 <link rel="canonical" href="https://tangled.org" /> 22 - {{ end }} 23 22 24 23 25 - {{ define "content" }} 26 - <div class="flex flex-col gap-20 md:gap-40 my-20 md:my-32"> 27 - {{ template "timeline/fragments/hero" . }} 28 - {{ template "timeline/fragments/preview" . }} 29 - {{ template "features1" . }} 30 - {{ template "features2" . }} 31 - {{ template "recentUpdates" . }} 32 - {{ template "community" . }} 33 - </div> 34 24 {{ end }} 35 25 36 - {{ block "topbarLayout" . }} 37 - <header class="max-w-screen-xl mx-auto w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 38 - {{ if .LoggedInUser }} 39 - <div id="upgrade-banner" 40 - hx-get="/upgradeBanner" 41 - hx-trigger="load" 42 - hx-swap="innerHTML"> 43 - </div> 44 - {{ end }} 45 - {{ template "layouts/fragments/topbar" . }} 46 - </header> 47 - {{ end }} 48 26 49 - {{ block "footerLayout" . }} 50 - <footer class="z-10"> 51 - {{ template "layouts/fragments/footerFull" . }} 52 - </footer> 53 - {{ end }} 54 - 55 - {{ block "bodyClasses" . }} 56 - bg-transparent bg-gradient-to-b from-white to-slate-100 dark:bg-none dark:bg-gray-900 57 - {{ end }} 58 - 59 - {{ block "mainLayout" . }} 60 - <div class="flex-grow relative"> 61 - <div 62 - class="absolute opacity-50 dark:opacity-5 inset-x-0 top-0 bottom-[-50px] md:bottom-[-150px] pointer-events-none bg-[url('https://assets.tangled.network/yarn_ball.svg')] bg-no-repeat bg-right-bottom w-full"> 63 - </div> 64 - <div class="max-w-screen-xl mx-auto flex flex-col gap-4 relative z-10"> 65 - {{ block "contentLayout" . }} 66 - <main> 67 - {{ block "content" . }}{{ end }} 68 - </main> 69 - {{ end }} 70 - 71 - {{ block "contentAfterLayout" . }} 72 - <main> 73 - {{ block "contentAfter" . }}{{ end }} 74 - </main> 75 - {{ end }} 76 - </div> 77 - </div> 78 - {{ end }} 79 - 80 - {{ define "features1" }} 81 - {{ $labelStyle := "normal-case cursor-pointer w-auto md:w-full p-4 md:px-6 rounded bg-white dark:bg-gray-800 font-medium text-base md:text-lg opacity-50 border border-gray-200 dark:border-gray-700 relative overflow-hidden" }} 82 - {{ $spanStyle := "z-10 items-center justify-between gap-2 w-full" }} 83 - {{ $connectorStyle := "w-0.5 h-6 bg-gray-300 dark:bg-gray-600 opacity-0 mx-auto" }} 84 - {{ $contentStyle := "hidden bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700 grid-cols-1 animate-fadein" }} 85 - {{ $progressOverlayStyle := "absolute inset-0 bg-gray-600/10 dark:bg-gray-100/10 w-0 transition-none" }} 86 - 87 - <style> 88 - @media (max-width: 768px) { 89 - .features-grid:has(#feature-prs:checked) { 90 - grid-template-columns: 1fr auto auto; 91 - } 92 - 93 - .features-grid:has(#feature-knots:checked) { 94 - grid-template-columns: auto 1fr auto; 95 - } 96 - 97 - .features-grid:has(#feature-spindles:checked) { 98 - grid-template-columns: auto auto 1fr; 99 - } 100 - 101 - #feature-prs:checked ~ label[for="feature-prs"] .label-text, 102 - #feature-knots:checked ~ label[for="feature-knots"] .label-text, 103 - #feature-spindles:checked ~ label[for="feature-spindles"] .label-text { 104 - display: inline-flex !important; 105 - } 106 - #feature-prs:checked ~ label[for="feature-prs"] .icon-only, 107 - #feature-knots:checked ~ label[for="feature-knots"] .icon-only, 108 - #feature-spindles:checked ~ label[for="feature-spindles"] .icon-only { 109 - display: none !important; 110 - } 111 - } 112 - </style> 113 - 114 - <div class="features-grid w-full grid grid-cols-3 gap-x-6 px-2"> 115 - <input type="radio" id="feature-prs" name="feature" class="peer/prs hidden" checked /> 116 - <input type="radio" id="feature-knots" name="feature" class="peer/knots hidden" /> 117 - <input type="radio" id="feature-spindles" name="feature" class="peer/spindles hidden" /> 118 - 119 - <label for="feature-prs" class="{{ $labelStyle }} peer-checked/prs:opacity-100 peer-checked/prs:shadow-sm"> 120 - <span class="label-text hidden md:inline-flex {{ $spanStyle }}">A better way to review {{ i "git-pull-request" "size-5" }}</span> 121 - <span class="icon-only inline-flex md:hidden {{ $spanStyle }}">{{ i "git-pull-request" "size-5" }}</span> 122 - <div class="{{ $progressOverlayStyle }}" data-progress="prs"></div> 123 - </label> 124 - 125 - <label for="feature-knots" class="{{ $labelStyle }} peer-checked/knots:opacity-100 peer-checked/knots:shadow-sm"> 126 - <span class="label-text hidden md:inline-flex {{ $spanStyle }}">Completely self-hostable {{ i "hard-drive" "size-5" }}</span> 127 - <span class="icon-only inline-flex md:hidden {{ $spanStyle }}">{{ i "hard-drive" "size-5" }}</span> 128 - <div class="{{ $progressOverlayStyle }}" data-progress="knots"></div> 129 - </label> 130 - 131 - <label for="feature-spindles" class="{{ $labelStyle }} peer-checked/spindles:opacity-100 peer-checked/spindles:shadow-sm"> 132 - <span class="label-text hidden md:inline-flex {{ $spanStyle }}">Quick and easy CI {{ i "layers-2" "size-5" }}</span> 133 - <span class="icon-only inline-flex md:hidden {{ $spanStyle }}">{{ i "layers-2" "size-5" }}</span> 134 - <div class="{{ $progressOverlayStyle }}" data-progress="spindles"></div> 135 - </label> 136 - 137 - <div class="{{ $connectorStyle }} peer-checked/prs:opacity-100"></div> 138 - <div class="{{ $connectorStyle }} peer-checked/knots:opacity-100"></div> 139 - <div class="{{ $connectorStyle }} peer-checked/spindles:opacity-100"></div> 140 - 141 - {{ $titleStyle := "text-2xl md:text-6xl my-2 md:mb-4 text-black dark:text-white font-medium" }} 142 - {{ $textContentStyle := "p-4 md:p-6 md:text-xl" }} 143 - {{ $imgContentStyle := "w-full overflow-hidden place-content-center bg-gradient-to-b from-slate-50 to-slate-100 dark:from-gray-800 dark:to-gray-900 border-t border-gray-200 dark:border-gray-700" }} 144 - {{ $linkDesktopStyle := "hover:no-underline items-center gap-2 p-3 text-base btn" }} 145 - 146 - <div class="col-span-3 {{ $contentStyle }} peer-checked/prs:grid"> 147 - <div class="{{ $textContentStyle }} flex gap-4 md:gap-8"> 148 - <section class="space-y-4"> 149 - <h1 class="{{ $titleStyle }}">Pull requests, reimagined</h1> 150 - <p class="text-gray-600 dark:text-gray-400"> 151 - Break down large features into small, reviewable chunks. Stack pull 152 - requests on top of each other and ship faster with round-based code 153 - reviews. Tangled natively supports stacking using Jujutsu. 154 - </p> 155 - <a href="https://blog.tangled.org/stacking" class="hover:no-underline inline-flex items-center gap-2 text-sm md:text-base text-black dark:text-white font-medium"> 156 - Learn more 157 - {{ i "arrow-right" "size-4" }} 158 - </a> 159 - </section> 160 - <div class="w-fit hidden md:flex items-center"> 161 - <div class="size-32 rounded-full flex items-center justify-center bg-yellow-100 dark:bg-yellow-900 -mr-4" > 162 - {{ i "wand-sparkles" "size-16 text-yellow-500 dark:text-yellow-500" }} 163 - </div> 164 - <div class="size-40 rounded-full flex items-center justify-center bg-green-100 dark:bg-green-900 z-10"> 165 - {{ i "git-pull-request-arrow" "size-24 text-green-500 dark:text-green-500 rotate-12" }} 166 - </div> 167 - </div> 168 - </div> 169 - <div class="{{ $imgContentStyle }} relative overflow-hidden flex items-center justify-center"> 170 - <div class="w-[120%] md:w-full flex"> 171 - <picture class="w-full"> 172 - <source srcset="https://assets.tangled.network/home-page-prs-dark.svg" media="(prefers-color-scheme: dark)" /> 173 - <img src="https://assets.tangled.network/home-page-prs-light.svg" class="w-full block" /> 174 - </picture> 175 - </div> 176 - </div> 177 - </div> 178 - 179 - <div class="col-span-3 {{ $contentStyle }} peer-checked/knots:grid"> 180 - <div class="{{ $textContentStyle }} flex place-content-between gap-4 md:gap-8"> 181 - <section class="space-y-4"> 182 - <h1 class="{{ $titleStyle }}">Run it at home</h1> 183 - <p class="text-gray-600 dark:text-gray-400"> 184 - Host your repositories on your own infrastructure with <a href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide" class="no-underline">knots</a>. 185 - Run CI on your own machines with <a href="https://docs.tangled.org/spindles.html#self-hosting-guide" class="no-underline">spindles</a>. 186 - <br> 187 - Don't want to self-host? All are welcome on our hosted instances. 188 - </p> 189 - <a href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide" class="hover:no-underline inline-flex items-center gap-2 text-sm md:text-base text-black dark:text-white font-medium"> 190 - Learn more 191 - {{ i "arrow-right" "size-4" }} 192 - </a> 193 - </section> 194 - <div class="w-fit hidden md:flex items-center"> 195 - <div class="size-32 rounded-full flex items-center justify-center bg-blue-100 dark:bg-blue-900 -mr-4" > 196 - {{ i "workflow" "size-16 text-blue-500 dark:text-blue-500" }} 197 - </div> 198 - <div class="size-40 rounded-full flex items-center justify-center bg-yellow-100 dark:bg-yellow-900 z-10"> 199 - {{ i "server" "size-24 text-yellow-500 dark:text-yellow-500" }} 200 - </div> 201 - </div> 202 - </div> 203 - <div class="{{ $imgContentStyle }} relative overflow-hidden flex items-center justify-center"> 204 - <div class="w-[120%] md:w-full flex"> 205 - <picture class="w-full"> 206 - <source srcset="https://assets.tangled.network/home-page-self-host-dark.svg" media="(prefers-color-scheme: dark)" /> 207 - <img src="https://assets.tangled.network/home-page-self-host-light.svg" class="w-full block" /> 208 - </picture> 209 - </div> 210 - </div> 211 - </div> 212 - 213 - <div class="col-span-3 {{ $contentStyle }} peer-checked/spindles:grid"> 214 - <div class="{{ $textContentStyle }} flex place-content-between gap-4 md:gap-8"> 215 - <section class="space-y-4"> 216 - <h1 class="{{ $titleStyle }}">Nix-powered CI</h1> 217 - <p class="text-gray-600 dark:text-gray-400"> 218 - Pick and choose dependencies for your CI pipelines from <a 219 - href="https://docs.tangled.org/spindles.html#dependencies" 220 - class="no-underline"><code>nixpkgs</code></a>, one of the biggest 221 - package repositories. 222 - <br> 223 - Support for Docker and MicroVM based runners coming soon! 224 - </p> 225 - <a href="https://docs.tangled.org/spindles.html#pipelines" class="hover:no-underline inline-flex items-center gap-2 text-sm md:text-base text-black dark:text-white font-medium"> 226 - Learn more 227 - {{ i "arrow-right" "size-4" }} 228 - </a> 229 - </section> 230 - <div class="w-fit hidden md:flex items-center"> 231 - <div class="size-32 rounded-full flex items-center justify-center bg-green-100 dark:bg-green-900 -mr-4" > 232 - {{ i "package" "size-16 text-green-500 dark:text-green-500" }} 233 - </div> 234 - <div class="size-40 rounded-full flex items-center justify-center bg-indigo-100 dark:bg-indigo-900 z-10"> 235 - {{ i "snowflake" "size-24 text-indigo-500 dark:text-indigo-500 rotate-12" }} 236 - </div> 237 - </div> 238 - </div> 239 - <div class="{{ $imgContentStyle }} flex items-end justify-center pb-0 overflow-hidden"> 240 - <div class="w-[120%] md:w-full flex"> 241 - <picture class="w-full"> 242 - <source srcset="https://assets.tangled.network/home-page-ci-dark.svg" media="(prefers-color-scheme: dark)" /> 243 - <img src="https://assets.tangled.network/home-page-ci-light.svg" class="w-full block" /> 244 - </picture> 245 - </div> 246 - </div> 247 - </div> 248 - </div> 249 - 250 - <script> 251 - document.addEventListener('DOMContentLoaded', function() { 252 - const featureIds = ['feature-prs', 'feature-knots', 'feature-spindles']; 253 - const progressNames = ['prs', 'knots', 'spindles']; 254 - let currentIndex = 0; 255 - let timerInterval = null; 256 - 257 - function stopTimer() { 258 - if (timerInterval) { 259 - clearInterval(timerInterval); 260 - timerInterval = null; 261 - } 262 - document.querySelectorAll('[data-progress]').forEach(el => { 263 - el.classList.remove('animate-progress'); 264 - }); 265 - } 266 - 267 - const firstProgress = document.querySelector('[data-progress="prs"]'); 268 - if (firstProgress) { 269 - firstProgress.classList.add('animate-progress'); 270 - } 271 - 272 - document.querySelectorAll('label[for^="feature-"]').forEach(label => { 273 - label.addEventListener('click', stopTimer); 274 - }); 275 - 276 - timerInterval = setInterval(function() { 277 - document.querySelectorAll('[data-progress]').forEach(el => { 278 - el.classList.remove('animate-progress'); 279 - void el.offsetWidth; // force reflow 280 - }); 281 - 282 - currentIndex = (currentIndex + 1) % featureIds.length; 283 - document.getElementById(featureIds[currentIndex]).checked = true; 284 - 285 - const activeProgress = document.querySelector('[data-progress="' + progressNames[currentIndex] + '"]'); 286 - if (activeProgress) { 287 - activeProgress.classList.add('animate-progress'); 288 - } 289 - }, 10000); 290 - }); 291 - </script> 292 - {{ end }} 293 - 294 - {{ define "features2" }} 295 - {{ $cardStyle := "bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700" }} 296 - {{ $cardInnerStyle := "flex flex-row items-center gap-4 md:gap-6 p-4 md:p-6 md:pt-8 relative" }} 297 - {{ $contentStyle := "flex-1 flex flex-col" }} 298 - {{ $titleStyle := "text-xl md:text-2xl font-bold mb-2 md:mb-3 text-gray-900 dark:text-gray-100" }} 299 - {{ $descriptionStyle := "text-gray-600 dark:text-gray-300 text-sm md:text-base leading-relaxed mb-5 md:mb-0" }} 300 - {{ $linkMobileStyle := "hover:no-underline inline-flex md:hidden items-center gap-2 text-sm text-black dark:text-white font-medium" }} 301 - {{ $iconContainerStyle := "relative shrink-0 w-24 h-24 md:w-48 md:h-48" }} 302 - {{ $iconCircleStyle := "rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-700" }} 303 - {{ $linkDesktopStyle := "hover:no-underline hidden md:inline-flex absolute -bottom-10 -right-14 items-center gap-2 p-3 text-base btn" }} 304 - 305 - <div class="w-full flex flex-col gap-6 md:gap-40 max-w-5xl mx-auto px-2"> 306 - <div class="{{ $cardStyle }} md:mr-32"> 307 - <div class="{{ $cardInnerStyle }}"> 308 - {{ $moreLink := "https://docs.tangled.org/quick-start-guide.html#login-or-sign-up" }} 309 - <div class="{{ $contentStyle }}"> 310 - <h3 class="{{ $titleStyle }}">Built on AT Protocol</h3> 311 - <p class="{{ $descriptionStyle }}"> 312 - <a class="underline" href="https://atproto.com/">AT Protocol</a> enables federated code-collaboration. Submit 313 - pull-requests or bug-reports to any repository hosted on any 314 - server. 315 - <br> 316 - <br> 317 - Bring an existing AT Protocol account (such as your Bluesky 318 - account), or signup for one with us. 319 - </p> 320 - <a href="{{ $moreLink }}" class="{{ $linkMobileStyle }}"> 321 - Learn more 322 - {{ i "arrow-right" "size-4" }} 323 - </a> 324 - </div> 325 - <div class="{{ $iconContainerStyle }}"> 326 - <div class="{{ $iconCircleStyle }} w-full h-full bg-blue-100 dark:bg-blue-900/50"> 327 - {{ i "at-sign" "size-12 md:size-24" "text-blue-500 dark:text-blue-500" "rotate-12" }} 328 - </div> 329 - <div class="{{ $iconCircleStyle }} absolute -top-2 -left-2 w-10 h-10 md:w-16 md:h-16 rounded-full bg-white dark:bg-gray-800 -rotate-12"> 330 - {{ i "globe" "size-4 md:size-8" "text-blue-500 dark:text-blue-500" }} 331 - </div> 332 - <a href="{{ $moreLink }}" class="{{ $linkDesktopStyle }}"> 333 - Learn more 334 - {{ i "arrow-right" "size-4" }} 335 - </a> 336 - </div> 337 - </div> 338 - </div> 339 - 340 - <div class="{{ $cardStyle }} md:ml-32"> 341 - <div class="{{ $cardInnerStyle }}"> 342 - {{ $moreLink := "https://tangled.org/core" }} 343 - <div class="{{ $contentStyle }}"> 344 - <h3 class="{{ $titleStyle }}">Free and open source</h3> 345 - <p class="{{ $descriptionStyle }}"> 346 - All of Tangled is open source and built with the community! 347 - Check out the <a class="underline" href="https://tangled.org/core">monorepo</a> and join in on the fun. 348 - <br> 349 - <br> 350 - We welcome contributions however big or small. You can start contributing by picking up a 351 - <a class="underline" href="https://tangled.org/tangled.org/core/issues">good-first-issue</a>. 352 - </p> 353 - <a href="{{ $moreLink }}" class="{{ $linkMobileStyle }}"> 354 - View source 355 - {{ i "arrow-right" "size-4" }} 356 - </a> 357 - </div> 358 - <div class="{{ $iconContainerStyle }}"> 359 - <div class="{{ $iconCircleStyle }} w-full h-full bg-green-100 dark:bg-green-900/50"> 360 - {{ i "package-open" "size-12 md:size-24" "text-green-500 dark:text-green-500" "-rotate-12" }} 361 - </div> 362 - <div class="absolute -top-2 -left-2 w-10 h-10 md:w-16 md:h-16 rounded-full bg-white dark:bg-gray-800 flex items-center justify-center border border-gray-200 dark:border-gray-700 rotate-12"> 363 - {{ i "heart" "size-4 md:size-8" "text-green-500 dark:text-green-500" }} 364 - </div> 365 - <a href="{{ $moreLink }}" class="{{ $linkDesktopStyle }}"> 366 - View source 367 - {{ i "arrow-right" "size-4" }} 368 - </a> 369 - </div> 370 - </div> 371 - </div> 372 - 373 - <div class="{{ $cardStyle }} md:mr-32"> 374 - <div class="{{ $cardInnerStyle }}"> 375 - {{ $moreLink := "/timeline" }} 376 - <div class="{{ $contentStyle }}"> 377 - <h3 class="{{ $titleStyle }}">Social coding is back</h3> 378 - <p class="{{ $descriptionStyle }}"> 379 - Discover trending projects, follow your friends and star your favourite repositories. Coding is better together! 380 - </p> 381 - <a href="{{ $moreLink }}" class="{{ $linkMobileStyle }}"> 382 - Explore timeline 383 - {{ i "arrow-right" "size-4" }} 384 - </a> 385 - </div> 386 - <div class="{{ $iconContainerStyle }}"> 387 - <div class="{{ $iconCircleStyle }} w-full h-full bg-amber-100 dark:bg-amber-900/50"> 388 - {{ i "message-circle-heart" "size-12 md:size-24" "text-amber-500 dark:text-amber-500" "rotate-12" }} 389 - </div> 390 - <div class="absolute -top-2 -left-2 w-10 h-10 md:w-16 md:h-16 rounded-full bg-white dark:bg-gray-800 flex items-center justify-center border border-gray-200 dark:border-gray-700 -rotate-12"> 391 - {{ i "users" "size-4 md:size-8" "text-amber-500 dark:text-amber-500" }} 392 - </div> 393 - <a href="{{ $moreLink }}" class="{{ $linkDesktopStyle }}"> 394 - Explore timeline 395 - {{ i "arrow-right" "size-4" }} 396 - </a> 397 - </div> 398 - </div> 399 - </div> 400 - </div> 401 - {{ end }} 402 - 403 - {{ define "changelog" }} 404 - <div class="w-full px-2 py-16 md:py-24"> 405 - <h2 class="text-3xl md:text-4xl font-bold mb-8 text-gray-900 dark:text-gray-100">Changelog</h2> 406 - 407 - <div class="grid grid-cols-1 gap-6 mb-6"> 408 - {{ range $index := list 0 1 2 3 }} 409 - <div class="bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700 p-6"> 410 - <div class="flex items-center gap-2 mb-3"> 411 - <span class="text-xs font-medium text-gray-500 dark:text-gray-400">v1.{{ sub 3 $index }}.0</span> 412 - <span class="text-xs text-gray-400 dark:text-gray-500">•</span> 413 - <span class="text-xs text-gray-500 dark:text-gray-400">Feb {{ sub 16 $index }}, 2026</span> 414 - </div> 415 - <h3 class="text-lg font-semibold mb-2 text-gray-900 dark:text-gray-100">Feature Update {{ sub 4 $index }}</h3> 416 - <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> 417 - Improvements to the platform including bug fixes, performance enhancements, and new features. 418 - </p> 419 - </div> 420 - {{ end }} 421 - </div> 422 - 27 + {{ define "content" }} 28 + <div class="flex flex-col gap-4"> 29 + {{ template "timeline/fragments/hero" . }} 30 + {{ template "features" . }} 31 + {{ template "timeline/fragments/goodfirstissues" . }} 32 + {{ template "timeline/fragments/trending" . }} 33 + {{ template "timeline/fragments/timeline" . }} 423 34 <div class="flex justify-end"> 424 - <a href="/changelog" class="hover:no-underline inline-flex items-center gap-2 px-4 py-2 text-base btn"> 425 - View full changelog 35 + <a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400"> 36 + view more 426 37 {{ i "arrow-right" "size-4" }} 427 38 </a> 428 39 </div> 429 40 </div> 430 41 {{ end }} 431 42 432 - {{ define "recentUpdates" }} 433 - <div class="w-full px-2 py-16 md:py-24"> 434 - <h2 class="px-4 text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-2">Recent updates</h2> 435 - <p class="px-4 mb-8 text-gray-500 dark:text-gray-400"> 436 - Follow <a href="https://bsky.app/profile/tangled.org">@tangled.org</a> on Bluesky for more! 437 - </p> 438 - <div class="columns-1 md:columns-2 lg:columns-3 gap-6"> 439 - {{ range $index, $post := .BlueskyPosts }} 440 - <div class="{{ if ge $index 3 }}hidden md:block{{ end }}"> 441 - {{ template "post" $post }} 43 + 44 + {{ define "feature" }} 45 + {{ $info := index . 0 }} 46 + {{ $bullets := index . 1 }} 47 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 48 + <div class="flex-1"> 49 + <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 50 + <ul class="leading-normal"> 51 + {{ range $bullets }} 52 + <li><p>{{ escapeHtml . }}</p></li> 53 + {{ end }} 54 + </ul> 55 + </div> 56 + <div class="flex-shrink-0 w-96 md:w-1/3"> 57 + <a href="{{ $info.image }}"> 58 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 59 + </a> 442 60 </div> 443 - {{ end }} 444 61 </div> 445 - </div> 446 62 {{ end }} 447 63 448 - {{ define "post" }} 449 - <div class="bg-white dark:bg-gray-800 rounded shadow-sm border border-gray-200 dark:border-gray-700 px-6 py-4 flex flex-col gap-2 break-inside-avoid mb-6"> 450 - <div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400"> 451 - <span class="flex items-center gap-2"> 452 - {{ template "user/fragments/picHandle" "tangled.org" }} 453 - </span> 454 - <span>{{ template "repo/fragments/shortTimeAgo" .CreatedAt }}</span> 455 - </div> 456 - <p class="text-gray-900 dark:text-gray-100 text-base leading-relaxed whitespace-pre-wrap">{{ .Text }}</p> 64 + {{ define "features" }} 65 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 66 + {{ template "feature" (list 67 + (dict 68 + "title" "lightweight git repo hosting" 69 + "image" "https://assets.tangled.network/what-is-tangled-repo.png" 70 + "alt" "A repository hosted on Tangled" 71 + ) 72 + (list 73 + "Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations." 74 + "Add friends to your knot or invite collaborators to your repository." 75 + "Guarded by fine-grained role-based access control." 76 + "Use SSH to push and pull." 77 + ) 78 + ) }} 457 79 458 - {{ if .Embed }} 459 - {{ if .Embed.EmbedImages_View }} 460 - <div class="grid {{ if eq (len .Embed.EmbedImages_View.Images) 1 }}grid-cols-1{{ else if eq (len .Embed.EmbedImages_View.Images) 2 }}grid-cols-2{{ else }}grid-cols-2{{ end }} gap-2"> 461 - {{ range .Embed.EmbedImages_View.Images }} 462 - <img src="{{ .Fullsize }}" alt="{{ .Alt }}" class="rounded w-full h-auto object-cover border border-gray-200 dark:border-gray-700" loading="lazy" /> 463 - {{ end }} 464 - </div> 465 - {{ else if .Embed.EmbedExternal_View }} 466 - <a href="{{ .Embed.EmbedExternal_View.External.Uri }}" target="_blank" rel="noopener noreferrer" class="hover:no-underline block border border-gray-200 dark:border-gray-700 rounded overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"> 467 - {{ if .Embed.EmbedExternal_View.External.Thumb }} 468 - <img src="{{ .Embed.EmbedExternal_View.External.Thumb }}" alt="" class="w-full h-48 object-cover" loading="lazy" /> 469 - {{ end }} 470 - <div class="p-3"> 471 - <div class="font-medium text-gray-900 dark:text-gray-100 text-sm mb-1">{{ .Embed.EmbedExternal_View.External.Title }}</div> 472 - <div class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2">{{ .Embed.EmbedExternal_View.External.Description }}</div> 473 - <div class="text-xs text-gray-500 dark:text-gray-500 mt-1">{{ .Embed.EmbedExternal_View.External.Uri }}</div> 474 - </div> 475 - </a> 476 - {{ else if .Embed.EmbedVideo_View }} 477 - <div class="rounded overflow-hidden bg-gray-100 dark:bg-gray-700 aspect-video flex items-center justify-center"> 478 - <span class="text-gray-500 dark:text-gray-400 text-sm">Video embed</span> 479 - </div> 480 - {{ end }} 481 - {{ end }} 80 + {{ template "feature" (list 81 + (dict 82 + "title" "improved pull request model" 83 + "image" "https://assets.tangled.network/pulls.png" 84 + "alt" "Round-based pull requests." 85 + ) 86 + (list 87 + "An intuitive and effective round-based pull request flow, with inter-diffing between rounds." 88 + "Stacked pull requests using Jujutsu's change IDs." 89 + "Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes." 90 + ) 91 + ) }} 482 92 483 - <a href="https://bsky.app/profile/tangled.org/post/{{ .Rkey }}" 484 - target="_blank" 485 - rel="noopener noreferrer" 486 - class="flex items-center justify-between gap-4 text-sm text-gray-500 dark:text-gray-400 pt-2 no-underline hover:no-underline"> 487 - <div class="flex items-center gap-4"> 488 - <div class="flex items-center gap-1"> 489 - {{ i "heart" "size-4" }} 490 - <span>{{ .LikeCount }}</span> 491 - </div> 492 - <div class="flex items-center gap-1"> 493 - {{ i "repeat-2" "size-4" }} 494 - <span>{{ .RepostCount }}</span> 495 - </div> 496 - <div class="flex items-center gap-1"> 497 - {{ i "message-square" "size-4 -scale-x-1" }} 498 - <span>{{ add64 .ReplyCount .QuoteCount }}</span> 499 - </div> 500 - </div> 501 - {{ i "arrow-up-right" "size-4" }} 502 - </a> 503 - </div> 504 - {{ end }} 505 - 506 - {{ define "community" }} 507 - <div class="w-full px-2 py-16 md:py-24"> 508 - <div class="max-w-2xl mx-auto text-center space-y-6"> 509 - <h2 class="text-3xl md:text-6xl font-bold text-gray-900 dark:text-gray-100">Join the network</h2> 510 - <p class="text-xl text-gray-600 dark:text-gray-300"> 511 - You can participate in the Tangled network with an AT account. If you 512 - don't know what that is, you can sign up for one below. 513 - </p> 514 - <form class="flex gap-2 items-stretch w-full md:max-w-md mx-auto p-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded shadow-sm" method="get" action="/signup"> 515 - <input 516 - type="email" 517 - id="email" 518 - name="id" 519 - tabindex="4" 520 - required 521 - placeholder="Enter your email" 522 - class="py-2 w-full" 523 - /> 524 - <button class="btn-create flex items-center gap-2 text-base whitespace-nowrap" type="submit"> 525 - join now 526 - {{ i "arrow-right" "size-4" }} 527 - </button> 528 - </form> 93 + {{ template "feature" (list 94 + (dict 95 + "title" "run pipelines using spindles" 96 + "image" "https://assets.tangled.network/pipelines.png" 97 + "alt" "CI pipeline running on spindle" 98 + ) 99 + (list 100 + "Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners." 101 + "Natively supports Nix for package management." 102 + "Easily extended to support different execution backends." 103 + ) 104 + ) }} 529 105 </div> 530 106 {{ end }}
+5
appview/pages/templates/timeline/timeline.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 + 11 16 {{ template "timeline/fragments/goodfirstissues" . }} 12 17 {{ template "timeline/fragments/trending" . }} 13 18 {{ template "timeline/fragments/timeline" . }}
-26
appview/pages/templates/user/settings/fragments/dangerDeleteToken.html
··· 1 - {{ define "user/settings/fragments/dangerDeleteToken" }} 2 - <div id="delete-form-container" hx-swap-oob="innerHTML"> 3 - <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 4 - <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for an account deletion code.</p> 5 - <form hx-post="/settings/delete/confirm" hx-swap="none" hx-confirm="This will permanently delete your account. This cannot be undone. Continue?" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 6 - <div class="flex flex-col"> 7 - <label for="delete-token">deletion code</label> 8 - <input type="text" id="delete-token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 9 - </div> 10 - <div class="flex flex-col"> 11 - <label for="delete-password-confirm">password</label> 12 - <input type="password" id="delete-password-confirm" name="password" required autocomplete="current-password" /> 13 - </div> 14 - <div class="flex flex-col"> 15 - <label for="delete-confirmation">confirmation</label> 16 - <input type="text" id="delete-confirmation" name="confirmation" required autocomplete="off" placeholder="delete my account" /> 17 - <span class="text-sm text-gray-500 mt-1">Type <strong>delete my account</strong> to confirm.</span> 18 - </div> 19 - <div class="flex gap-2 pt-2"> 20 - <button type="button" popovertarget="delete-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">cancel</button> 21 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">delete account</button> 22 - </div> 23 - <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 24 - </form> 25 - </div> 26 - {{ end }}
-6
appview/pages/templates/user/settings/fragments/dangerPasswordSuccess.html
··· 1 - {{ define "user/settings/fragments/dangerPasswordSuccess" }} 2 - <div id="password-form-container" hx-swap-oob="innerHTML"> 3 - <label class="uppercase text-sm font-bold p-0">Change password</label> 4 - <p class="text-green-500 dark:text-green-400 pt-2">Password changed.</p> 5 - </div> 6 - {{ end }}
-25
appview/pages/templates/user/settings/fragments/dangerPasswordToken.html
··· 1 - {{ define "user/settings/fragments/dangerPasswordToken" }} 2 - <div id="password-form-container" hx-swap-oob="innerHTML"> 3 - <label class="uppercase text-sm font-bold p-0">Change password</label> 4 - <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for a password reset code.</p> 5 - <form hx-post="/settings/password/reset" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 6 - <div class="flex flex-col"> 7 - <label for="token">reset code</label> 8 - <input type="text" id="token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 9 - </div> 10 - <div class="flex flex-col"> 11 - <label for="new-password">new password</label> 12 - <input type="password" id="new-password" name="new_password" required autocomplete="new-password" /> 13 - </div> 14 - <div class="flex flex-col"> 15 - <label for="confirm-password">confirm new password</label> 16 - <input type="password" id="confirm-password" name="confirm_password" required autocomplete="new-password" /> 17 - </div> 18 - <div class="flex gap-2 pt-2"> 19 - <button type="button" popovertarget="change-password-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">cancel</button> 20 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2">set new password</button> 21 - </div> 22 - <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 23 - </form> 24 - </div> 25 - {{ end }}
-1
appview/pages/templates/user/settings/keys.html
··· 98 98 </span> 99 99 </button> 100 100 </div> 101 - <div id="settings-keys-success" class="text-green-500 dark:text-green-400"></div> 102 101 <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 103 102 </form> 104 103 {{ end }}
+3 -2
appview/pages/templates/user/settings/notifications.html
··· 180 180 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 181 181 </button> 182 182 </div> 183 - <div id="settings-notifications-success" class="text-green-500 dark:text-green-400"></div> 184 - <div id="settings-notifications-error" class="text-red-500 dark:text-red-400"></div> 183 + <div id="settings-notifications-success"></div> 184 + 185 + <div id="settings-notifications-error" class="error"></div> 185 186 </form> 186 187 {{ end }}
+2 -242
appview/pages/templates/user/settings/profile.html
··· 11 11 </div> 12 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 13 {{ template "profile" . }} 14 - {{ if .IsTnglSh }} 15 - {{ template "accountActions" . }} 16 - {{ end }} 17 14 {{ template "punchcard" . }} 18 15 </div> 19 16 </section> ··· 29 26 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 30 27 <div class="flex flex-col gap-1 p-4"> 31 28 <span class="text-sm text-gray-500 dark:text-gray-400">Handle</span> 32 - <div class="flex items-center gap-2"> 33 - <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 34 - {{ if .IsTnglSh }} 35 - {{ if .HandleOpen }} 36 - <button 37 - popovertarget="change-handle-modal" 38 - popovertargetaction="toggle" 39 - class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-pointer">change</button> 40 - {{ else }} 41 - <a href="/settings/handle" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">change</a> 42 - {{ end }} 43 - {{ end }} 44 - </div> 29 + <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 45 30 </div> 46 31 <div class="flex flex-col gap-1 p-4"> 47 32 <span class="text-sm text-gray-500 dark:text-gray-400">Decentralized Identifier (DID)</span> ··· 52 37 <span class="font-bold">{{ .LoggedInUser.Pds }}</span> 53 38 </div> 54 39 </div> 55 - {{ if and .IsTnglSh .HandleOpen }} 56 - <div 57 - id="change-handle-modal" 58 - popover 59 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 60 - <div id="handle-subdomain" class="flex flex-col gap-3"> 61 - <label class="uppercase text-sm font-bold p-0">Change handle</label> 62 - <form hx-post="/settings/handle" hx-swap="none" class="flex flex-col gap-3"> 63 - <input type="hidden" name="type" value="subdomain"> 64 - <div class="flex items-stretch rounded border border-gray-200 dark:border-gray-600 overflow-hidden focus-within:ring-1 focus-within:ring-blue-500 dark:bg-gray-700"> 65 - <input type="text" name="handle" placeholder="username" class="flex-1 px-2 py-1.5 bg-transparent dark:text-white border-0 focus:outline-none focus:ring-0 min-w-0" required> 66 - <span class="px-2 py-1.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300 select-none whitespace-nowrap border-l border-gray-200 dark:border-gray-600 content-center">.{{ .PdsDomain }}</span> 67 - </div> 68 - <div class="flex gap-2 pt-2"> 69 - <button type="button" popovertarget="change-handle-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 70 - {{ i "x" "size-4" }} cancel 71 - </button> 72 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 73 - {{ i "check" "size-4" }} save 74 - </button> 75 - </div> 76 - </form> 77 - <a href="#" id="switch-to-custom" class="text-sm text-gray-400 underline hover:text-gray-600 dark:hover:text-gray-300">I have my own domain</a> 78 - </div> 79 - <div id="handle-custom" style="display: none;" class="flex flex-col gap-3"> 80 - <label class="uppercase text-sm font-bold p-0">Change handle</label> 81 - <form hx-post="/settings/handle" hx-swap="none" class="flex flex-col gap-3"> 82 - <input type="hidden" name="type" value="custom"> 83 - <input id="custom-domain-input" type="text" name="handle" placeholder="mycoolhandle.com" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-2 py-1.5 border border-gray-200 dark:border-gray-600 rounded outline-none focus:ring-1 focus:ring-blue-500" required> 84 - <div class="bg-gray-50 dark:bg-gray-900 rounded p-3 text-gray-500 dark:text-gray-400 flex flex-col gap-2 text-xs"> 85 - <p>Set up one of the following on your domain:</p> 86 - <div> 87 - <p class="font-medium text-gray-700 dark:text-gray-300">DNS TXT record</p> 88 - <p>Add a TXT record for <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">_atproto.<span id="dns-domain">mycoolhandle.com</span></code></p> 89 - <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1.5 break-all">did={{ .LoggedInUser.Did }}</code> 90 - </div> 91 - <div> 92 - <p class="font-medium text-gray-700 dark:text-gray-300">HTTP well-known</p> 93 - <p>Serve your DID at:</p> 94 - <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1 break-all">https://<span id="wk-domain">mycoolhandle.com</span>/.well-known/atproto-did</code> 95 - <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1.5 break-all">{{ .LoggedInUser.Did }}</code> 96 - </div> 97 - </div> 98 - <div class="flex gap-2 pt-2"> 99 - <button type="button" popovertarget="change-handle-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 100 - {{ i "x" "size-4" }} cancel 101 - </button> 102 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 103 - {{ i "check" "size-4" }} verify & save 104 - </button> 105 - </div> 106 - </form> 107 - <a href="#" id="switch-to-subdomain" class="text-sm text-gray-400 underline hover:text-gray-600 dark:hover:text-gray-300">use a {{ .PdsDomain }} subdomain instead</a> 108 - </div> 109 - <div id="handle-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 110 - <div id="handle-success" class="text-green-500 dark:text-green-400 text-sm empty:hidden"></div> 111 - </div> 112 - <script> 113 - document.getElementById('switch-to-custom').addEventListener('click', function(e) { 114 - e.preventDefault(); 115 - document.getElementById('handle-subdomain').style.display = 'none'; 116 - document.getElementById('handle-custom').style.display = ''; 117 - }); 118 - document.getElementById('switch-to-subdomain').addEventListener('click', function(e) { 119 - e.preventDefault(); 120 - document.getElementById('handle-custom').style.display = 'none'; 121 - document.getElementById('handle-subdomain').style.display = ''; 122 - }); 123 - document.getElementById('custom-domain-input').addEventListener('input', function(e) { 124 - var d = e.target.value.trim() || 'mycoolhandle.com'; 125 - document.getElementById('dns-domain').textContent = d; 126 - document.getElementById('wk-domain').textContent = d; 127 - }); 128 - document.getElementById('change-handle-modal').showPopover(); 129 - </script> 130 - {{ end }} 131 - </div> 132 - {{ end }} 133 - 134 - {{ define "accountActions" }} 135 - <div> 136 - <h2 class="text-sm uppercase font-bold">Account</h2> 137 - {{ if .IsDeactivated }} 138 - <div class="mt-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded text-sm text-amber-700 dark:text-amber-300"> 139 - Your account is deactivated. Your profile and repositories are currently inaccessible. Reactivate to restore access. 140 - </div> 141 - {{ end }} 142 - <div class="flex flex-wrap gap-2 pt-2"> 143 - <button 144 - popovertarget="change-password-modal" 145 - popovertargetaction="toggle" 146 - class="btn flex items-center gap-2 text-sm cursor-pointer"> 147 - {{ i "key" "size-4" }} 148 - change password 149 - </button> 150 - {{ if .IsDeactivated }} 151 - <button 152 - popovertarget="reactivate-modal" 153 - popovertargetaction="toggle" 154 - class="btn flex items-center gap-2 text-sm text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-300 dark:border-green-600 cursor-pointer"> 155 - {{ i "play" "size-4" }} 156 - reactivate account 157 - </button> 158 - {{ else }} 159 - <button 160 - popovertarget="deactivate-modal" 161 - popovertargetaction="toggle" 162 - class="btn flex items-center gap-2 text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-300 dark:border-amber-600 cursor-pointer"> 163 - {{ i "pause" "size-4" }} 164 - deactivate account 165 - </button> 166 - {{ end }} 167 - <button 168 - popovertarget="delete-modal" 169 - popovertargetaction="toggle" 170 - class="btn flex items-center gap-2 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border-red-300 dark:border-red-600 cursor-pointer"> 171 - {{ i "trash-2" "size-4" }} 172 - delete account 173 - </button> 174 - </div> 175 - </div> 176 - 177 - <div 178 - id="change-password-modal" 179 - popover 180 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 181 - <div id="password-form-container" class="flex flex-col gap-3"> 182 - <label class="uppercase text-sm font-bold p-0">Change password</label> 183 - <form hx-post="/settings/password/request" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 184 - <div class="flex flex-col"> 185 - <label for="current-password">current password</label> 186 - <input type="password" id="current-password" name="current_password" required autocomplete="current-password" /> 187 - <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 188 - </div> 189 - <div class="flex gap-2 pt-2"> 190 - <button type="button" popovertarget="change-password-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 191 - {{ i "x" "size-4" }} cancel 192 - </button> 193 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 194 - {{ i "key" "size-4" }} send reset code 195 - </button> 196 - </div> 197 - </form> 198 - <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 199 - </div> 200 - </div> 201 - 202 - {{ if .IsDeactivated }} 203 - <div 204 - id="reactivate-modal" 205 - popover 206 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-green-300 dark:border-green-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 207 - <div class="flex flex-col gap-3"> 208 - <label class="uppercase text-sm font-bold p-0 text-green-600 dark:text-green-400">Reactivate account</label> 209 - <p class="text-sm text-gray-500 dark:text-gray-400">This will restore your profile and repositories, making them accessible again.</p> 210 - <form hx-post="/settings/reactivate" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 211 - <div class="flex flex-col"> 212 - <label for="reactivate-password">password</label> 213 - <input type="password" id="reactivate-password" name="password" required autocomplete="current-password" /> 214 - <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 215 - </div> 216 - <div class="flex gap-2 pt-2"> 217 - <button type="button" popovertarget="reactivate-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 218 - {{ i "x" "size-4" }} cancel 219 - </button> 220 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"> 221 - {{ i "play" "size-4" }} reactivate 222 - </button> 223 - </div> 224 - </form> 225 - <div id="reactivate-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 226 - </div> 227 - </div> 228 - {{ else }} 229 - <div 230 - id="deactivate-modal" 231 - popover 232 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-amber-300 dark:border-amber-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 233 - <div class="flex flex-col gap-3"> 234 - <label class="uppercase text-sm font-bold p-0 text-amber-600 dark:text-amber-400">Deactivate account</label> 235 - <p class="text-sm text-gray-500 dark:text-gray-400">Your profile and repositories will become inaccessible. You can reactivate by logging in again.</p> 236 - <form hx-post="/settings/deactivate" hx-swap="none" hx-confirm="Are you sure you want to deactivate your account?" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 237 - <div class="flex flex-col"> 238 - <label for="deactivate-password">password</label> 239 - <input type="password" id="deactivate-password" name="password" required autocomplete="current-password" /> 240 - <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 241 - </div> 242 - <div class="flex gap-2 pt-2"> 243 - <button type="button" popovertarget="deactivate-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 244 - {{ i "x" "size-4" }} cancel 245 - </button> 246 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300"> 247 - {{ i "pause" "size-4" }} deactivate 248 - </button> 249 - </div> 250 - </form> 251 - <div id="deactivate-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 252 - </div> 253 - </div> 254 - {{ end }} 255 - 256 - <div 257 - id="delete-modal" 258 - popover 259 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-red-300 dark:border-red-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 260 - <div id="delete-form-container" class="flex flex-col gap-3"> 261 - <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 262 - <p class="text-sm text-gray-500 dark:text-gray-400">This permanently deletes your account and all associated data. This cannot be undone.</p> 263 - <form hx-post="/settings/delete/request" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 264 - <div class="flex flex-col"> 265 - <label for="delete-password">password</label> 266 - <input type="password" id="delete-password" name="password" required autocomplete="current-password" /> 267 - <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 268 - </div> 269 - <div class="flex gap-2 pt-2"> 270 - <button type="button" popovertarget="delete-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"> 271 - {{ i "x" "size-4" }} cancel 272 - </button> 273 - <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 274 - {{ i "trash-2" "size-4" }} send deletion code 275 - </button> 276 - </div> 277 - </form> 278 - <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 279 - </div> 280 40 </div> 281 41 {{ end }} 282 42 ··· 286 46 <p class="text-gray-500 dark:text-gray-400 pb-2 "> 287 47 Configure punchcard visibility and preferences. 288 48 </p> 289 - <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-3"> 49 + <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-2"> 290 50 <div class="flex items-center gap-2"> 291 51 <input type="checkbox" id="hideMine" name="hideMine" value="on" {{ if eq true $.PunchcardPreference.HideMine }}checked{{ end }}> 292 52 <label for="hideMine" class="my-0 py-0 normal-case font-normal">Hide mine</label>
-138
appview/pages/templates/user/settings/sites.html
··· 1 - {{ define "title" }}{{ .Tab }} settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 - <div class="col-span-1"> 10 - {{ template "user/settings/fragments/sidebar" . }} 11 - </div> 12 - <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 - {{ template "sitesSettings" . }} 14 - </div> 15 - </section> 16 - </div> 17 - {{ end }} 18 - 19 - {{ define "sitesSettings" }} 20 - <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 - <div class="col-span-1 md:col-span-2"> 22 - <h2 class="text-sm pb-2 uppercase font-bold">Git Sites</h2> 23 - {{ if .IsTnglHandle }} 24 - <p class="text-gray-500 dark:text-gray-400"> 25 - Since your handle is on <code>tngl.sh</code>, it doubles as your sites domain&mdash;your site will be served from that subdomain automatically. 26 - </p> 27 - {{ else }} 28 - <p class="text-gray-500 dark:text-gray-400"> 29 - Claim a subdomain of <code>{{ .SitesDomain }}</code> to serve a repository as a static site. 30 - Each account may hold one domain at a time. A released domain enters a 30-day cooldown before it can be claimed again. 31 - </p> 32 - {{ end }} 33 - </div> 34 - {{ if not .Claim }} 35 - <div class="col-span-1 md:col-span-1 md:justify-self-end"> 36 - {{ template "claimDomainButton" . }} 37 - </div> 38 - {{ end }} 39 - </div> 40 - 41 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 42 - {{ if .Claim }} 43 - {{ template "activeClaim" . }} 44 - {{ else }} 45 - <div class="flex items-center justify-center p-2 text-gray-500"> 46 - no domain claimed yet 47 - </div> 48 - {{ end }} 49 - </div> 50 - 51 - 52 - {{ if .Claim }} 53 - <p class="text-gray-500 dark:text-gray-400"> 54 - To deploy your site on this domain, <a href="https://docs.tangled.org/hosting-websites-on-tangled.html">read the docs</a>. 55 - </p> 56 - {{ end }} 57 - {{ end }} 58 - 59 - {{ define "activeClaim" }} 60 - <div class="flex items-center justify-between p-2"> 61 - <div class="flex items-center gap-3"> 62 - {{ i "globe" "size-4 text-gray-400 dark:text-gray-500 flex-shrink-0" }} 63 - <div class="flex items-center gap-2"> 64 - <span class="font-mono">{{ .Claim.Domain }}</span> 65 - <span class="inline-flex items-center gap-1 text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">{{ i "circle-check" "size-3" }} active</span> 66 - </div> 67 - </div> 68 - <form 69 - hx-delete="/settings/sites" 70 - hx-confirm="Release {{ .Claim.Domain }}? The domain will enter a 30-day cooldown before it can be claimed by anyone else." 71 - hx-swap="none" 72 - > 73 - <input type="hidden" name="domain" value="{{ .Claim.Domain }}" /> 74 - <button 75 - type="submit" 76 - class="btn flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 77 - {{ i "trash-2" "size-4" }} 78 - release 79 - </button> 80 - </form> 81 - </div> 82 - {{ end }} 83 - 84 - {{ define "claimDomainButton" }} 85 - <button 86 - class="btn flex items-center gap-2" 87 - popovertarget="claim-domain-modal" 88 - popovertargetaction="toggle"> 89 - {{ i "plus" "size-4" }} 90 - claim domain 91 - </button> 92 - <div 93 - id="claim-domain-modal" 94 - popover 95 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 96 - {{ template "claimDomainModal" . }} 97 - </div> 98 - {{ end }} 99 - 100 - {{ define "claimDomainModal" }} 101 - <form 102 - hx-put="/settings/sites" 103 - hx-indicator="#claim-spinner" 104 - hx-swap="none" 105 - class="flex flex-col gap-2" 106 - > 107 - <label class="uppercase p-0">claim a subdomain</label> 108 - <p class="text-gray-500 dark:text-gray-400">Choose a subdomain under <code>{{ .SitesDomain }}</code>. Only lowercase letters, digits, and hyphens are allowed.</p> 109 - <div class="flex items-stretch rounded border border-gray-200 dark:border-gray-600 overflow-hidden focus-within:ring-1 focus-within:ring-blue-500 dark:bg-gray-700"> 110 - <input 111 - type="text" 112 - name="subdomain" 113 - required 114 - placeholder="yourname" 115 - pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]" 116 - class="flex-1 px-2 py-1.5 bg-transparent dark:text-white border-0 focus:outline-none focus:ring-0 min-w-0" 117 - /> 118 - <span class="px-2 py-1.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300 select-none whitespace-nowrap border-l border-gray-200 dark:border-gray-600">.{{ .SitesDomain }}</span> 119 - </div> 120 - <div class="flex gap-2 pt-2"> 121 - <button 122 - type="button" 123 - popovertarget="claim-domain-modal" 124 - popovertargetaction="hide" 125 - class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 126 - {{ i "x" "size-4" }} cancel 127 - </button> 128 - <button type="submit" class="btn w-1/2 flex items-center"> 129 - <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} claim</span> 130 - <span id="claim-spinner" class="group"> 131 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 132 - </span> 133 - </button> 134 - </div> 135 - <div id="settings-sites-error" class="text-red-500 dark:text-red-400"></div> 136 - <div id="settings-sites-success" class="text-green-500 dark:text-green-400"></div> 137 - </form> 138 - {{ end }}
-1
appview/pages/templates/user/signup.html
··· 20 20 tabindex="4" 21 21 required 22 22 placeholder="jason@bourne.co" 23 - value="{{ .EmailId }}" 24 23 /> 25 24 </div> 26 25 <span class="text-sm text-gray-500 mt-1">
+52 -268
appview/pulls/opengraph.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 - "fmt" 7 - "image" 8 - "image/color" 9 - "image/png" 10 5 "log" 11 6 "net/http" 7 + "time" 12 8 13 9 "tangled.org/core/appview/models" 14 10 "tangled.org/core/appview/ogcard" 15 11 "tangled.org/core/patchutil" 16 - "tangled.org/core/types" 17 12 ) 18 13 19 - func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { 20 - width, height := ogcard.DefaultSize() 21 - mainCard, err := ogcard.NewCard(width, height) 22 - if err != nil { 23 - return nil, err 24 - } 25 - 26 - // Split: content area (75%) and status/stats area (25%) 27 - contentCard, statsArea := mainCard.Split(false, 75) 28 - 29 - // Add padding to content 30 - contentCard.SetMargin(50) 31 - 32 - // Split content horizontally: main content (80%) and avatar area (20%) 33 - mainContent, avatarArea := contentCard.Split(true, 80) 34 - 35 - // Add margin to main content 36 - mainContent.SetMargin(10) 37 - 38 - // Use full main content area for repo name and title 39 - bounds := mainContent.Img.Bounds() 40 - startX := bounds.Min.X + mainContent.Margin 41 - startY := bounds.Min.Y + mainContent.Margin 42 - 43 - // Draw full repository name at top (owner/repo format) 44 - var repoOwner string 45 - owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) 46 - if err != nil { 47 - repoOwner = repo.Did 48 - } else { 49 - repoOwner = "@" + owner.Handle.String() 50 - } 51 - 52 - fullRepoName := repoOwner + " / " + repo.Name 53 - if len(fullRepoName) > 60 { 54 - fullRepoName = fullRepoName[:60] + "…" 55 - } 56 - 57 - grayColor := color.RGBA{88, 96, 105, 255} 58 - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 14 + func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 15 + f, err := s.repoResolver.Resolve(r) 59 16 if err != nil { 60 - return nil, err 61 - } 62 - 63 - // Draw pull request title below repo name with wrapping 64 - titleY := startY + 60 65 - titleX := startX 66 - 67 - // Truncate title if too long 68 - pullTitle := pull.Title 69 - maxTitleLength := 80 70 - if len(pullTitle) > maxTitleLength { 71 - pullTitle = pullTitle[:maxTitleLength] + "…" 72 - } 73 - 74 - // Create a temporary card for the title area to enable wrapping 75 - titleBounds := mainContent.Img.Bounds() 76 - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 77 - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID 78 - 79 - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 80 - titleCard := &ogcard.Card{ 81 - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 82 - Font: mainContent.Font, 83 - Margin: 0, 17 + log.Println("failed to get repo and knot", err) 18 + return 84 19 } 85 20 86 - // Draw wrapped title 87 - lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) 88 - if err != nil { 89 - return nil, err 21 + pull, ok := r.Context().Value("pull").(*models.Pull) 22 + if !ok { 23 + log.Println("pull not found in context") 24 + http.Error(w, "pull not found", http.StatusNotFound) 25 + return 90 26 } 91 27 92 - // Calculate where title ends (number of lines * line height) 93 - lineHeight := 60 // Approximate line height for 54pt font 94 - titleEndY := titleY + (len(lines) * lineHeight) + 10 95 - 96 - // Draw pull ID in gray below the title 97 - pullIdText := fmt.Sprintf("#%d", pull.PullId) 98 - err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 28 + var ownerHandle string 29 + owner, err := s.idResolver.ResolveIdent(context.Background(), f.Did) 99 30 if err != nil { 100 - return nil, err 31 + ownerHandle = f.Did 32 + } else { 33 + ownerHandle = "@" + owner.Handle.String() 101 34 } 102 35 103 - // Get pull author handle (needed for avatar and metadata) 104 36 var authorHandle string 105 37 author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) 106 38 if err != nil { ··· 109 41 authorHandle = "@" + author.Handle.String() 110 42 } 111 43 112 - // Draw avatar circle on the right side 113 - avatarBounds := avatarArea.Img.Bounds() 114 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 115 - if avatarSize > 220 { 116 - avatarSize = 220 117 - } 118 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 119 - avatarY := avatarBounds.Min.Y + 20 120 - 121 - // Get avatar URL for pull author 122 - avatarURL := s.pages.AvatarUrl(authorHandle, "256") 123 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 124 - if err != nil { 125 - log.Printf("failed to draw avatar (non-fatal): %v", err) 126 - } 127 - 128 - // Split stats area: left side for status/stats (80%), right side for dolly (20%) 129 - statusArea, dollyArea := statsArea.Split(true, 80) 130 - 131 - // Draw status and stats 132 - statsBounds := statusArea.Img.Bounds() 133 - statsX := statsBounds.Min.X + 60 // left padding 134 - statsY := statsBounds.Min.Y 135 - 136 - iconColor := color.RGBA{88, 96, 105, 255} 137 - iconSize := 36 138 - textSize := 36.0 139 - labelSize := 28.0 140 - iconBaselineOffset := int(textSize) / 2 141 - 142 - // Draw status (open/merged/closed) with colored icon and text 143 - var statusIcon string 144 - var statusText string 145 - var statusColor color.RGBA 44 + avatarUrl := s.pages.AvatarUrl(authorHandle, "256") 146 45 46 + var status string 147 47 if pull.State.IsOpen() { 148 - statusIcon = "git-pull-request" 149 - statusText = "open" 150 - statusColor = color.RGBA{34, 139, 34, 255} // green 48 + status = "open" 151 49 } else if pull.State.IsMerged() { 152 - statusIcon = "git-merge" 153 - statusText = "merged" 154 - statusColor = color.RGBA{138, 43, 226, 255} // purple 50 + status = "merged" 155 51 } else { 156 - statusIcon = "git-pull-request-closed" 157 - statusText = "closed" 158 - statusColor = color.RGBA{52, 58, 64, 255} // dark gray 159 - } 160 - 161 - statusTextWidth := statusArea.TextWidth(statusText, textSize) 162 - badgePadding := 12 163 - badgeHeight := int(textSize) + (badgePadding * 2) 164 - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) 165 - cornerRadius := 8 166 - badgeX := 60 167 - badgeY := 0 168 - 169 - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) 170 - 171 - whiteColor := color.RGBA{255, 255, 255, 255} 172 - iconX := statsX + badgePadding 173 - iconY := statsY + (badgeHeight-iconSize)/2 174 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) 175 - if err != nil { 176 - log.Printf("failed to draw status icon: %v", err) 177 - } 178 - 179 - textX := statsX + badgePadding + iconSize + badgePadding 180 - textY := statsY + (badgeHeight-int(textSize))/2 - 5 181 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) 182 - if err != nil { 183 - log.Printf("failed to draw status text: %v", err) 184 - } 185 - 186 - currentX := statsX + badgeWidth + 50 187 - 188 - // Draw comment count 189 - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) 190 - if err != nil { 191 - log.Printf("failed to draw comment icon: %v", err) 192 - } 193 - 194 - currentX += iconSize + 15 195 - commentCount := pull.TotalComments() 196 - commentText := fmt.Sprintf("%d comments", commentCount) 197 - if commentCount == 1 { 198 - commentText = "1 comment" 199 - } 200 - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 201 - if err != nil { 202 - log.Printf("failed to draw comment text: %v", err) 203 - } 204 - 205 - commentTextWidth := len(commentText) * 20 206 - currentX += commentTextWidth + 40 207 - 208 - // Draw files changed 209 - err = statusArea.DrawLucideIcon("file-diff", currentX, iconY, iconSize, iconColor) 210 - if err != nil { 211 - log.Printf("failed to draw file diff icon: %v", err) 212 - } 213 - 214 - currentX += iconSize + 15 215 - filesText := fmt.Sprintf("%d files", filesChanged) 216 - if filesChanged == 1 { 217 - filesText = "1 file" 218 - } 219 - err = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) 220 - if err != nil { 221 - log.Printf("failed to draw files text: %v", err) 222 - } 223 - 224 - filesTextWidth := len(filesText) * 20 225 - currentX += filesTextWidth 226 - 227 - // Draw additions (green +) 228 - greenColor := color.RGBA{34, 139, 34, 255} 229 - additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 230 - err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) 231 - if err != nil { 232 - log.Printf("failed to draw additions text: %v", err) 233 - } 234 - 235 - additionsTextWidth := len(additionsText) * 20 236 - currentX += additionsTextWidth + 30 237 - 238 - // Draw deletions (red -) right next to additions 239 - redColor := color.RGBA{220, 20, 60, 255} 240 - deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 241 - err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, ogcard.Left) 242 - if err != nil { 243 - log.Printf("failed to draw deletions text: %v", err) 244 - } 245 - 246 - // Draw dolly logo on the right side 247 - dollyBounds := dollyArea.Img.Bounds() 248 - dollySize := 90 249 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 250 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 251 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 252 - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 253 - if err != nil { 254 - log.Printf("dolly silhouette not available (this is ok): %v", err) 255 - } 256 - 257 - // Draw "opened by @author" and date at the bottom with more spacing 258 - labelY := statsY + iconSize + 30 259 - 260 - // Format the opened date 261 - openedDate := pull.Created.Format("Jan 2, 2006") 262 - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) 263 - 264 - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 265 - if err != nil { 266 - log.Printf("failed to draw metadata: %v", err) 267 - } 268 - 269 - return mainCard, nil 270 - } 271 - 272 - func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 273 - f, err := s.repoResolver.Resolve(r) 274 - if err != nil { 275 - log.Println("failed to get repo and knot", err) 276 - return 52 + status = "closed" 277 53 } 278 54 279 - pull, ok := r.Context().Value("pull").(*models.Pull) 280 - if !ok { 281 - log.Println("pull not found in context") 282 - http.Error(w, "pull not found", http.StatusNotFound) 283 - return 284 - } 55 + var filesChanged int 56 + var additions int64 57 + var deletions int64 285 58 286 - // Calculate diff stats from latest submission using patchutil 287 - var diffStats types.DiffFileStat 288 - filesChanged := 0 289 59 if len(pull.Submissions) > 0 { 290 60 latestSubmission := pull.LatestSubmission() 291 61 niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 292 - diffStats.Insertions = int64(niceDiff.Stat.Insertions) 293 - diffStats.Deletions = int64(niceDiff.Stat.Deletions) 294 62 filesChanged = niceDiff.Stat.FilesChanged 63 + additions = int64(niceDiff.Stat.Insertions) 64 + deletions = int64(niceDiff.Stat.Deletions) 295 65 } 296 66 297 - card, err := s.drawPullSummaryCard(pull, f, diffStats, filesChanged) 298 - if err != nil { 299 - log.Println("failed to draw pull summary card", err) 300 - http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError) 301 - return 67 + commentCount := pull.TotalComments() 68 + 69 + rounds := len(pull.Submissions) 70 + if rounds == 0 { 71 + rounds = 1 302 72 } 303 73 304 - var imageBuffer bytes.Buffer 305 - err = png.Encode(&imageBuffer, card.Img) 74 + payload := ogcard.PullRequestCardPayload{ 75 + Type: "pullRequest", 76 + RepoName: f.Name, 77 + OwnerHandle: ownerHandle, 78 + AvatarUrl: avatarUrl, 79 + Title: pull.Title, 80 + PullRequestNumber: pull.PullId, 81 + Status: status, 82 + FilesChanged: filesChanged, 83 + Additions: int(additions), 84 + Deletions: int(deletions), 85 + Rounds: rounds, 86 + CommentCount: commentCount, 87 + ReactionCount: 0, 88 + CreatedAt: pull.Created.Format(time.RFC3339), 89 + } 90 + 91 + imageBytes, err := s.ogcardClient.RenderPullRequestCard(r.Context(), payload) 306 92 if err != nil { 307 - log.Println("failed to encode pull summary card", err) 308 - http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) 93 + log.Println("failed to render pull request card", err) 94 + http.Error(w, "failed to render pull request card", http.StatusInternalServerError) 309 95 return 310 96 } 311 97 312 - imageBytes := imageBuffer.Bytes() 313 - 314 98 w.Header().Set("Content-Type", "image/png") 315 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 99 + w.Header().Set("Cache-Control", "public, max-age=3600") 316 100 w.WriteHeader(http.StatusOK) 317 101 _, err = w.Write(imageBytes) 318 102 if err != nil { 319 - log.Println("failed to write pull summary card", err) 103 + log.Println("failed to write pull request card", err) 320 104 return 321 105 } 322 106 }
+44 -40
appview/pulls/pulls.go
··· 26 26 "tangled.org/core/appview/models" 27 27 "tangled.org/core/appview/notify" 28 28 "tangled.org/core/appview/oauth" 29 + "tangled.org/core/appview/ogcard" 29 30 "tangled.org/core/appview/pages" 30 31 "tangled.org/core/appview/pages/markup" 31 32 "tangled.org/core/appview/pages/repoinfo" ··· 65 66 logger *slog.Logger 66 67 validator *validator.Validator 67 68 indexer *pulls_indexer.Indexer 69 + ogcardClient *ogcard.Client 68 70 } 69 71 70 72 func New( ··· 94 96 logger: logger, 95 97 validator: validator, 96 98 indexer: indexer, 99 + ogcardClient: ogcard.NewClient(config.Ogcard.Host), 97 100 } 98 101 } 99 102 ··· 560 563 query.Set("state", "open") 561 564 } 562 565 563 - resolve := func(ctx context.Context, ident string) (string, error) { 564 - id, err := s.idResolver.ResolveIdent(ctx, ident) 566 + var authorDid string 567 + if authorHandle := query.Get("author"); authorHandle != nil { 568 + identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 565 569 if err != nil { 566 - return "", err 570 + l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 571 + } else { 572 + authorDid = identity.DID.String() 567 573 } 568 - return id.DID.String(), nil 569 574 } 570 575 571 - authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 576 + var negatedAuthorDid string 577 + if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 578 + identity, err := s.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 579 + if err != nil { 580 + l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 581 + } else { 582 + negatedAuthorDid = identity.DID.String() 583 + } 584 + } 572 585 573 586 labels := query.GetAll("label") 574 587 negatedLabels := query.GetAllNegated("label") 575 - labelValues := query.GetDynamicTags() 576 - negatedLabelValues := query.GetNegatedDynamicTags() 577 588 578 - // resolve DID-format label values: if a dynamic tag's label 579 - // definition has format "did", resolve the handle to a DID 580 - if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 581 - labelDefs, err := db.GetLabelDefinitions( 582 - s.db, 583 - orm.FilterIn("at_uri", f.Labels), 584 - orm.FilterContains("scope", tangled.RepoPullNSID), 585 - ) 586 - if err == nil { 587 - didLabels := make(map[string]bool) 588 - for _, def := range labelDefs { 589 - if def.ValueType.Format == models.ValueTypeFormatDid { 590 - didLabels[def.Name] = true 591 - } 589 + var keywords, negatedKeywords []string 590 + var phrases, negatedPhrases []string 591 + for _, item := range query.Items() { 592 + switch item.Kind { 593 + case searchquery.KindKeyword: 594 + if item.Negated { 595 + negatedKeywords = append(negatedKeywords, item.Value) 596 + } else { 597 + keywords = append(keywords, item.Value) 598 + } 599 + case searchquery.KindQuoted: 600 + if item.Negated { 601 + negatedPhrases = append(negatedPhrases, item.Value) 602 + } else { 603 + phrases = append(phrases, item.Value) 592 604 } 593 - labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 594 - negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 595 - } else { 596 - l.Debug("failed to fetch label definitions for DID resolution", "err", err) 597 605 } 598 606 } 599 607 600 - tf := searchquery.ExtractTextFilters(query) 601 - 602 608 searchOpts := models.PullSearchOptions{ 603 - Keywords: tf.Keywords, 604 - Phrases: tf.Phrases, 605 - RepoAt: f.RepoAt().String(), 606 - State: state, 607 - AuthorDid: authorDid, 608 - Labels: labels, 609 - LabelValues: labelValues, 610 - NegatedKeywords: tf.NegatedKeywords, 611 - NegatedPhrases: tf.NegatedPhrases, 612 - NegatedLabels: negatedLabels, 613 - NegatedLabelValues: negatedLabelValues, 614 - NegatedAuthorDids: negatedAuthorDids, 615 - Page: page, 609 + Keywords: keywords, 610 + Phrases: phrases, 611 + RepoAt: f.RepoAt().String(), 612 + State: state, 613 + AuthorDid: authorDid, 614 + Labels: labels, 615 + NegatedKeywords: negatedKeywords, 616 + NegatedPhrases: negatedPhrases, 617 + NegatedLabels: negatedLabels, 618 + NegatedAuthorDid: negatedAuthorDid, 619 + Page: page, 616 620 } 617 621 618 622 var totalPulls int
+8 -12
appview/repo/archive.go
··· 52 52 rp.pages.Error503(w) 53 53 return 54 54 } 55 - defer resp.Body.Close() 56 55 57 - // force application/gzip here 58 - w.Header().Set("Content-Type", "application/gzip") 59 - 60 - filename := "" 61 - if cd := resp.Header.Get("Content-Disposition"); strings.HasPrefix(cd, "attachment;") { 62 - filename = cd // knot has already set the attachment CD 56 + // pass through headers from upstream response 57 + if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" { 58 + w.Header().Set("Content-Disposition", contentDisposition) 63 59 } 64 - if filename == "" { 65 - filename = fmt.Sprintf("attachment; filename=\"%s-%s.tar.gz\"", f.Name, ref) 60 + if contentType := resp.Header.Get("Content-Type"); contentType != "" { 61 + w.Header().Set("Content-Type", contentType) 66 62 } 67 - w.Header().Set("Content-Disposition", filename) 68 - w.Header().Set("X-Content-Type-Options", "nosniff") 69 - 63 + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { 64 + w.Header().Set("Content-Length", contentLength) 65 + } 70 66 if link := resp.Header.Get("Link"); link != "" { 71 67 if resolvedRef, err := extractImmutableLink(link); err == nil { 72 68 newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"",
+1 -1
appview/repo/blob.go
··· 236 236 ext := strings.ToLower(filepath.Ext(resp.Path)) 237 237 238 238 switch ext { 239 - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": 239 + case ".jpg", ".jpeg", ".png", ".gif", ".webp": 240 240 view.ContentType = models.BlobContentTypeImage 241 241 view.HasRawView = true 242 242 view.HasRenderedView = true
+36 -327
appview/repo/opengraph.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 - "encoding/hex" 7 - "fmt" 8 - "image/color" 9 - "image/png" 10 5 "log" 11 6 "net/http" 12 7 "sort" 13 - "strings" 8 + "time" 14 9 15 10 "github.com/go-enry/go-enry/v2" 16 11 "tangled.org/core/appview/db" 17 - "tangled.org/core/appview/models" 18 12 "tangled.org/core/appview/ogcard" 19 13 "tangled.org/core/orm" 20 14 "tangled.org/core/types" 21 15 ) 22 16 23 - func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 24 - width, height := ogcard.DefaultSize() 25 - mainCard, err := ogcard.NewCard(width, height) 17 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 18 + f, err := rp.repoResolver.Resolve(r) 26 19 if err != nil { 27 - return nil, err 20 + log.Println("failed to get repo and knot", err) 21 + return 28 22 } 29 23 30 - // Split: content area (75%) and language bar + icons (25%) 31 - contentCard, bottomArea := mainCard.Split(false, 75) 32 - 33 - // Add padding to content 34 - contentCard.SetMargin(50) 35 - 36 - // Split content horizontally: main content (80%) and avatar area (20%) 37 - mainContent, avatarArea := contentCard.Split(true, 80) 38 - 39 - // Use main content area for both repo name and description to allow dynamic wrapping. 40 - mainContent.SetMargin(10) 41 - 42 24 var ownerHandle string 43 - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 25 + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) 44 26 if err != nil { 45 - ownerHandle = repo.Did 27 + ownerHandle = f.Did 46 28 } else { 47 29 ownerHandle = "@" + owner.Handle.String() 48 30 } 49 31 50 - bounds := mainContent.Img.Bounds() 51 - startX := bounds.Min.X + mainContent.Margin 52 - startY := bounds.Min.Y + mainContent.Margin 53 - currentX := startX 54 - currentY := startY 55 - lineHeight := 64 // Font size 54 + padding 56 - textColor := color.RGBA{88, 96, 105, 255} 57 - 58 - // Draw owner handle 59 - ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 60 - if err != nil { 61 - return nil, err 62 - } 63 - currentX += ownerWidth 64 - 65 - // Draw separator 66 - sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 67 - if err != nil { 68 - return nil, err 69 - } 70 - currentX += sepWidth 71 - 72 - words := strings.Fields(repo.Name) 73 - spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 74 - if spaceWidth == 0 { 75 - spaceWidth = 15 76 - } 77 - 78 - for _, word := range words { 79 - // estimate bold width by measuring regular width and adding a multiplier 80 - regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 81 - estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text 82 - 83 - if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { 84 - currentX = startX 85 - currentY += lineHeight 86 - } 87 - 88 - _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) 89 - if err != nil { 90 - return nil, err 91 - } 92 - currentX += estimatedBoldWidth + spaceWidth 93 - } 94 - 95 - // update Y position for the description 96 - currentY += lineHeight 97 - 98 - // draw description 99 - if currentY < bounds.Max.Y-mainContent.Margin { 100 - totalHeight := float64(bounds.Dy()) 101 - repoNameHeight := float64(currentY - bounds.Min.Y) 102 - 103 - if totalHeight > 0 && repoNameHeight < totalHeight { 104 - repoNamePercent := (repoNameHeight / totalHeight) * 100 105 - if repoNamePercent < 95 { // Ensure there's space left for description 106 - _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) 107 - descriptionCard.SetMargin(8) 108 - 109 - description := repo.Description 110 - if len(description) > 70 { 111 - description = description[:70] + "…" 112 - } 113 - 114 - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 115 - if err != nil { 116 - log.Printf("failed to draw description: %v", err) 117 - } 118 - } 119 - } 120 - } 121 - 122 - // Draw avatar circle on the right side 123 - avatarBounds := avatarArea.Img.Bounds() 124 - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 125 - if avatarSize > 220 { 126 - avatarSize = 220 127 - } 128 - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 129 - avatarY := avatarBounds.Min.Y + 20 130 - 131 - // Get avatar URL and draw it 132 - avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 133 - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 134 - if err != nil { 135 - log.Printf("failed to draw avatar (non-fatal): %v", err) 136 - } 137 - 138 - // Split bottom area: icons area (65%) and language bar (35%) 139 - iconsArea, languageBarCard := bottomArea.Split(false, 75) 140 - 141 - // Split icons area: left side for stats (80%), right side for dolly (20%) 142 - statsArea, dollyArea := iconsArea.Split(true, 80) 32 + avatarUrl := rp.pages.AvatarUrl(ownerHandle, "256") 143 33 144 - // Draw stats with icons in the stats area 145 - starsText := repo.RepoStats.StarCount 146 - issuesText := repo.RepoStats.IssueCount.Open 147 - pullRequestsText := repo.RepoStats.PullCount.Open 148 - 149 - iconColor := color.RGBA{88, 96, 105, 255} 150 - iconSize := 36 151 - textSize := 36.0 152 - 153 - // Position stats in the middle of the stats area 154 - statsBounds := statsArea.Img.Bounds() 155 - statsX := statsBounds.Min.X + 60 // left padding 156 - statsY := statsBounds.Min.Y 157 - currentX = statsX 158 - labelSize := 22.0 159 - // Draw star icon, count, and label 160 - // Align icon baseline with text baseline 161 - iconBaselineOffset := int(textSize) / 2 162 - err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 163 - if err != nil { 164 - log.Printf("failed to draw star icon: %v", err) 165 - } 166 - starIconX := currentX 167 - currentX += iconSize + 15 168 - 169 - starText := fmt.Sprintf("%d", starsText) 170 - err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 171 - if err != nil { 172 - log.Printf("failed to draw star text: %v", err) 173 - } 174 - starTextWidth := len(starText) * 20 175 - starGroupWidth := iconSize + 15 + starTextWidth 176 - 177 - // Draw "stars" label below and centered under the icon+text group 178 - labelY := statsY + iconSize + 15 179 - labelX := starIconX + starGroupWidth/2 180 - err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 181 - if err != nil { 182 - log.Printf("failed to draw stars label: %v", err) 183 - } 184 - 185 - currentX += starTextWidth + 50 186 - 187 - // Draw issues icon, count, and label 188 - issueStartX := currentX 189 - err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 190 - if err != nil { 191 - log.Printf("failed to draw circle-dot icon: %v", err) 192 - } 193 - currentX += iconSize + 15 194 - 195 - issueText := fmt.Sprintf("%d", issuesText) 196 - err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 197 - if err != nil { 198 - log.Printf("failed to draw issue text: %v", err) 199 - } 200 - issueTextWidth := len(issueText) * 20 201 - issueGroupWidth := iconSize + 15 + issueTextWidth 202 - 203 - // Draw "issues" label below and centered under the icon+text group 204 - labelX = issueStartX + issueGroupWidth/2 205 - err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 206 - if err != nil { 207 - log.Printf("failed to draw issues label: %v", err) 208 - } 209 - 210 - currentX += issueTextWidth + 50 211 - 212 - // Draw pull request icon, count, and label 213 - prStartX := currentX 214 - err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 215 - if err != nil { 216 - log.Printf("failed to draw git-pull-request icon: %v", err) 217 - } 218 - currentX += iconSize + 15 219 - 220 - prText := fmt.Sprintf("%d", pullRequestsText) 221 - err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 222 - if err != nil { 223 - log.Printf("failed to draw PR text: %v", err) 224 - } 225 - prTextWidth := len(prText) * 20 226 - prGroupWidth := iconSize + 15 + prTextWidth 227 - 228 - // Draw "pulls" label below and centered under the icon+text group 229 - labelX = prStartX + prGroupWidth/2 230 - err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 231 - if err != nil { 232 - log.Printf("failed to draw pulls label: %v", err) 233 - } 234 - 235 - dollyBounds := dollyArea.Img.Bounds() 236 - dollySize := 90 237 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 241 - if err != nil { 242 - log.Printf("dolly silhouette not available (this is ok): %v", err) 243 - } 244 - 245 - // Draw language bar at bottom 246 - err = drawLanguagesCard(languageBarCard, languageStats) 247 - if err != nil { 248 - log.Printf("failed to draw language bar: %v", err) 249 - return nil, err 250 - } 251 - 252 - return mainCard, nil 253 - } 254 - 255 - // hexToColor converts a hex color to a go color 256 - func hexToColor(colorStr string) (*color.RGBA, error) { 257 - colorStr = strings.TrimLeft(colorStr, "#") 258 - 259 - b, err := hex.DecodeString(colorStr) 260 - if err != nil { 261 - return nil, err 262 - } 263 - 264 - if len(b) < 3 { 265 - return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 266 - } 267 - 268 - clr := color.RGBA{b[0], b[1], b[2], 255} 269 - 270 - return &clr, nil 271 - } 272 - 273 - func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 274 - bounds := card.Img.Bounds() 275 - cardWidth := bounds.Dx() 276 - 277 - if len(languageStats) == 0 { 278 - // Draw a light gray bar if no languages detected 279 - card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 280 - return nil 281 - } 282 - 283 - // Limit to top 5 languages for the visual bar 284 - displayLanguages := languageStats 285 - if len(displayLanguages) > 5 { 286 - displayLanguages = displayLanguages[:5] 287 - } 288 - 289 - currentX := bounds.Min.X 290 - 291 - for _, lang := range displayLanguages { 292 - var langColor *color.RGBA 293 - var err error 294 - 295 - if lang.Color != "" { 296 - langColor, err = hexToColor(lang.Color) 297 - if err != nil { 298 - // Fallback to a default color 299 - langColor = &color.RGBA{149, 157, 165, 255} 300 - } 301 - } else { 302 - // Default color if no color specified 303 - langColor = &color.RGBA{149, 157, 165, 255} 304 - } 305 - 306 - langWidth := float32(cardWidth) * (lang.Percentage / 100) 307 - card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 308 - currentX += int(langWidth) 309 - } 310 - 311 - // Fill remaining space with the last color (if any gap due to rounding) 312 - if currentX < bounds.Max.X && len(displayLanguages) > 0 { 313 - lastLang := displayLanguages[len(displayLanguages)-1] 314 - var lastColor *color.RGBA 315 - var err error 316 - 317 - if lastLang.Color != "" { 318 - lastColor, err = hexToColor(lastLang.Color) 319 - if err != nil { 320 - lastColor = &color.RGBA{149, 157, 165, 255} 321 - } 322 - } else { 323 - lastColor = &color.RGBA{149, 157, 165, 255} 324 - } 325 - card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 326 - } 327 - 328 - return nil 329 - } 330 - 331 - func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 332 - f, err := rp.repoResolver.Resolve(r) 333 - if err != nil { 334 - log.Println("failed to get repo and knot", err) 335 - return 336 - } 337 - 338 - // Get language stats directly from database 339 34 var languageStats []types.RepoLanguageDetails 340 35 langs, err := db.GetRepoLanguages( 341 36 rp.db, ··· 344 39 ) 345 40 if err != nil { 346 41 log.Printf("failed to get language stats from db: %v", err) 347 - // non-fatal, continue without language stats 348 42 } else if len(langs) > 0 { 349 43 var total int64 350 44 for _, l := range langs { ··· 375 69 }) 376 70 } 377 71 378 - card, err := rp.drawRepoSummaryCard(f, languageStats) 379 - if err != nil { 380 - log.Println("failed to draw repo summary card", err) 381 - http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 382 - return 72 + var ogLanguages []ogcard.LanguageData 73 + for _, lang := range languageStats { 74 + if len(ogLanguages) >= 5 { 75 + break 76 + } 77 + ogLanguages = append(ogLanguages, ogcard.LanguageData{ 78 + Color: lang.Color, 79 + Percentage: lang.Percentage, 80 + }) 81 + } 82 + 83 + updatedAt := f.Created 84 + 85 + payload := ogcard.RepositoryCardPayload{ 86 + Type: "repository", 87 + RepoName: f.Name, 88 + OwnerHandle: ownerHandle, 89 + Stars: f.RepoStats.StarCount, 90 + Pulls: f.RepoStats.PullCount.Open, 91 + Issues: f.RepoStats.IssueCount.Open, 92 + UpdatedAt: updatedAt.Format(time.RFC3339), 93 + AvatarUrl: avatarUrl, 94 + Languages: ogLanguages, 383 95 } 384 96 385 - var imageBuffer bytes.Buffer 386 - err = png.Encode(&imageBuffer, card.Img) 97 + imageBytes, err := rp.ogcardClient.RenderRepositoryCard(r.Context(), payload) 387 98 if err != nil { 388 - log.Println("failed to encode repo summary card", err) 389 - http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 99 + log.Println("failed to render repository card", err) 100 + http.Error(w, "failed to render repository card", http.StatusInternalServerError) 390 101 return 391 102 } 392 103 393 - imageBytes := imageBuffer.Bytes() 394 - 395 104 w.Header().Set("Content-Type", "image/png") 396 - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 105 + w.Header().Set("Cache-Control", "public, max-age=3600") 397 106 w.WriteHeader(http.StatusOK) 398 107 _, err = w.Write(imageBytes) 399 108 if err != nil { 400 - log.Println("failed to write repo summary card", err) 109 + log.Println("failed to write repository card", err) 401 110 return 402 111 } 403 112 }
+4 -7
appview/repo/repo.go
··· 12 12 "strings" 13 13 "time" 14 14 15 - "tangled.org/core/appview/cloudflare" 16 - 17 15 "tangled.org/core/api/tangled" 18 16 "tangled.org/core/appview/config" 19 17 "tangled.org/core/appview/db" 20 18 "tangled.org/core/appview/models" 21 19 "tangled.org/core/appview/notify" 22 20 "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/ogcard" 23 22 "tangled.org/core/appview/pages" 24 23 "tangled.org/core/appview/reporesolver" 25 24 "tangled.org/core/appview/validator" ··· 52 51 logger *slog.Logger 53 52 serviceAuth *serviceauth.ServiceAuth 54 53 validator *validator.Validator 55 - cfClient *cloudflare.Client 54 + ogcardClient *ogcard.Client 56 55 } 57 56 58 57 func New( ··· 67 66 enforcer *rbac.Enforcer, 68 67 logger *slog.Logger, 69 68 validator *validator.Validator, 70 - cfClient *cloudflare.Client, 71 69 ) *Repo { 72 - return &Repo{ 73 - oauth: oauth, 70 + return &Repo{oauth: oauth, 74 71 repoResolver: repoResolver, 75 72 pages: pages, 76 73 idResolver: idResolver, ··· 81 78 enforcer: enforcer, 82 79 logger: logger, 83 80 validator: validator, 84 - cfClient: cfClient, 81 + ogcardClient: ogcard.NewClient(config.Ogcard.Host), 85 82 } 86 83 } 87 84
-4
appview/repo/router.go
··· 87 87 r.Put("/branches/default", rp.SetDefaultBranch) 88 88 r.Put("/secrets", rp.Secrets) 89 89 r.Delete("/secrets", rp.Secrets) 90 - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { 91 - r.Put("/", rp.SaveRepoSiteConfig) 92 - r.Delete("/", rp.DeleteRepoSiteConfig) 93 - }) 94 90 r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { 95 91 r.Get("/", rp.Webhooks) 96 92 r.Post("/", rp.AddWebhook)
-207
appview/repo/settings.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "context" 5 4 "encoding/json" 6 5 "fmt" 7 6 "net/http" 8 - "path" 9 7 "slices" 10 8 "strings" 11 9 "time" 12 10 13 11 "tangled.org/core/api/tangled" 14 - 15 12 "tangled.org/core/appview/db" 16 13 "tangled.org/core/appview/models" 17 14 "tangled.org/core/appview/oauth" 18 15 "tangled.org/core/appview/pages" 19 - "tangled.org/core/appview/sites" 20 16 xrpcclient "tangled.org/core/appview/xrpcclient" 21 17 "tangled.org/core/orm" 22 18 "tangled.org/core/types" ··· 174 170 175 171 case "hooks": 176 172 rp.Webhooks(w, r) 177 - 178 - case "sites": 179 - rp.sitesSettings(w, r) 180 173 } 181 - } 182 - 183 - func (rp *Repo) sitesSettings(w http.ResponseWriter, r *http.Request) { 184 - l := rp.logger.With("handler", "sitesSettings") 185 - 186 - f, err := rp.repoResolver.Resolve(r) 187 - if err != nil { 188 - l.Error("failed to get repo and knot", "err", err) 189 - return 190 - } 191 - user := rp.oauth.GetMultiAccountUser(r) 192 - 193 - scheme := "http" 194 - if !rp.config.Core.Dev { 195 - scheme = "https" 196 - } 197 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 198 - xrpcc := &indigoxrpc.Client{Host: host} 199 - 200 - repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 201 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 202 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 203 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 204 - rp.pages.Error503(w) 205 - return 206 - } 207 - 208 - var result types.RepoBranchesResponse 209 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 210 - l.Error("failed to decode XRPC response", "err", err) 211 - rp.pages.Error503(w) 212 - return 213 - } 214 - 215 - siteConfig, err := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) 216 - if err != nil { 217 - l.Error("failed to get site config", "err", err) 218 - rp.pages.Error503(w) 219 - return 220 - } 221 - 222 - ownerClaim, err := db.GetActiveDomainClaimForDid(rp.db, f.Did) 223 - if err != nil { 224 - l.Error("failed to get owner domain claim", "err", err) 225 - // non-fatal — just show no claim 226 - ownerClaim = nil 227 - } 228 - 229 - deploys, err := db.GetSiteDeploys(rp.db, f.RepoAt().String(), 20) 230 - if err != nil { 231 - l.Error("failed to get site deploys", "err", err) 232 - // non-fatal 233 - deploys = nil 234 - } 235 - 236 - indexSiteTakenBy, err := db.GetIndexRepoAtForDid(rp.db, f.Did, f.RepoAt().String()) 237 - if err != nil { 238 - l.Error("failed to get index site owner", "err", err) 239 - // non-fatal 240 - indexSiteTakenBy = "" 241 - } 242 - 243 - rp.pages.RepoSiteSettings(w, pages.RepoSiteSettingsParams{ 244 - LoggedInUser: user, 245 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 246 - Branches: result.Branches, 247 - SiteConfig: siteConfig, 248 - OwnerClaim: ownerClaim, 249 - Deploys: deploys, 250 - IndexSiteTakenBy: indexSiteTakenBy, 251 - }) 252 - } 253 - 254 - func (rp *Repo) SaveRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 255 - l := rp.logger.With("handler", "SaveRepoSiteConfig") 256 - 257 - noticeId := "repo-sites-error" 258 - 259 - f, err := rp.repoResolver.Resolve(r) 260 - if err != nil { 261 - l.Error("failed to get repo and knot", "err", err) 262 - rp.pages.Notice(w, noticeId, "Failed to load repository.") 263 - return 264 - } 265 - 266 - branch := strings.TrimSpace(r.FormValue("branch")) 267 - if branch == "" { 268 - rp.pages.Notice(w, noticeId, "Branch cannot be empty.") 269 - return 270 - } 271 - 272 - dir := strings.TrimSpace(r.FormValue("dir")) 273 - if dir == "" { 274 - dir = "/" 275 - } 276 - 277 - // Normalise: always starts with /, no trailing slash (except root), no ".." 278 - dir = path.Clean("/" + dir) 279 - if dir != "/" && strings.Contains(dir, "..") { 280 - rp.pages.Notice(w, noticeId, "Invalid directory path.") 281 - return 282 - } 283 - 284 - isIndex := r.FormValue("is_index") == "true" 285 - 286 - if err := db.SetRepoSiteConfig(rp.db, f.RepoAt().String(), branch, dir, isIndex); err != nil { 287 - l.Error("failed to save site config", "err", err) 288 - rp.pages.Notice(w, noticeId, "Failed to save site configuration.") 289 - return 290 - } 291 - 292 - // Trigger an initial deploy asynchronously so the handler returns promptly. 293 - // Skip entirely if there is no active domain claim — the site cannot be served anyway. 294 - ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) 295 - if ownerClaim == nil { 296 - rp.logger.Info("skipping deploy: no active domain claim", "repo", f.DidSlashRepo()) 297 - } else if rp.cfClient.Enabled() { 298 - scheme := "http" 299 - if !rp.config.Core.Dev { 300 - scheme = "https" 301 - } 302 - knotHost := fmt.Sprintf("%s://%s", scheme, f.Knot) 303 - 304 - go func() { 305 - ctx := context.Background() 306 - 307 - deploy := &models.SiteDeploy{ 308 - RepoAt: f.RepoAt().String(), 309 - Branch: branch, 310 - Dir: dir, 311 - Trigger: models.SiteDeployTriggerConfigChange, 312 - } 313 - 314 - deployErr := sites.Deploy(ctx, rp.cfClient, knotHost, f.Did, f.Name, branch, dir) 315 - if deployErr != nil { 316 - l.Error("sites: initial R2 sync failed", "repo", f.DidSlashRepo(), "err", deployErr) 317 - deploy.Status = models.SiteDeployStatusFailure 318 - deploy.Error = deployErr.Error() 319 - } else { 320 - deploy.Status = models.SiteDeployStatusSuccess 321 - } 322 - 323 - if err := db.AddSiteDeploy(rp.db, deploy); err != nil { 324 - l.Error("sites: failed to record deploy", "repo", f.DidSlashRepo(), "err", err) 325 - } 326 - 327 - if deployErr == nil { 328 - if err := sites.PutDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Did, f.Name, isIndex); err != nil { 329 - l.Error("sites: KV write failed", "domain", ownerClaim.Domain, "err", err) 330 - } 331 - rp.logger.Info("site deployed to r2", "repo", f.DidSlashRepo(), "is_index", isIndex) 332 - } 333 - }() 334 - } else { 335 - rp.logger.Warn("cloudflare integration is disabled; site won't be deployed", "repo", f.DidSlashRepo()) 336 - } 337 - 338 - rp.pages.HxRefresh(w) 339 - } 340 - 341 - func (rp *Repo) DeleteRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 342 - l := rp.logger.With("handler", "DeleteRepoSiteConfig") 343 - 344 - noticeId := "repo-sites-error" 345 - 346 - f, err := rp.repoResolver.Resolve(r) 347 - if err != nil { 348 - l.Error("failed to get repo and knot", "err", err) 349 - rp.pages.Notice(w, noticeId, "Failed to load repository.") 350 - return 351 - } 352 - 353 - // Fetch the current config before deleting so we know the isIndex flag for 354 - // the KV key and the domain mapping to clean up. 355 - existingConfig, _ := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) 356 - 357 - if err := db.DeleteRepoSiteConfig(rp.db, f.RepoAt().String()); err != nil { 358 - l.Error("failed to delete site config", "err", err) 359 - rp.pages.Notice(w, noticeId, "Failed to remove site configuration.") 360 - return 361 - } 362 - 363 - // Clean up R2 objects and KV entry asynchronously. 364 - if rp.cfClient.Enabled() && existingConfig != nil { 365 - ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) 366 - 367 - go func() { 368 - ctx := context.Background() 369 - if err := sites.Delete(ctx, rp.cfClient, f.Did, f.Name); err != nil { 370 - l.Error("sites: R2 delete failed", "repo", f.DidSlashRepo(), "err", err) 371 - } 372 - if ownerClaim != nil { 373 - if err := sites.DeleteDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Name); err != nil { 374 - l.Error("sites: KV delete failed", "domain", ownerClaim.Domain, "err", err) 375 - } 376 - } 377 - }() 378 - } 379 - 380 - rp.pages.HxRefresh(w) 381 174 } 382 175 383 176 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
+1 -1
appview/repo/tree.go
··· 128 128 rp.pages.RepoTree(w, pages.RepoTreeParams{ 129 129 LoggedInUser: user, 130 130 BreadCrumbs: breadcrumbs, 131 - Path: treePath, 131 + TreePath: treePath, 132 132 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 133 133 EmailToDid: emailToDidMap, 134 134 LastCommitInfo: lastCommitInfo,
+9 -9
appview/reporesolver/resolver.go
··· 18 18 "tangled.org/core/rbac" 19 19 ) 20 20 21 - var ( 22 - blobPattern = regexp.MustCompile(`blob/[^/]+/(.*)$`) 23 - treePattern = regexp.MustCompile(`tree/[^/]+/(.*)$`) 24 - pathAfterRefRE = regexp.MustCompile(`(?:blob|tree|raw)/[^/]+/(.*)$`) 25 - ) 26 - 27 21 type RepoResolver struct { 28 22 config *config.Config 29 23 enforcer *rbac.Enforcer ··· 146 140 func extractCurrentDir(fullPath string) string { 147 141 fullPath = strings.TrimPrefix(fullPath, "/") 148 142 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 149 144 if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 145 return path.Dir(matches[1]) 151 146 } 152 147 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 153 149 if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 154 150 dir := strings.TrimSuffix(matches[1], "/") 155 151 if dir == "" { ··· 168 164 func extractPathAfterRef(fullPath string) string { 169 165 fullPath = strings.TrimPrefix(fullPath, "/") 170 166 171 - // pathAfterRefRE matches blob/, tree/, or raw/ followed by any ref and then a slash; 172 - // it captures everything after the final slash. 173 - matches := pathAfterRefRE.FindStringSubmatch(fullPath) 167 + // match blob/, tree/, or raw/ followed by any ref and then a slash 168 + // 169 + // captures everything after the final slash 170 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 171 + 172 + re := regexp.MustCompile(pattern) 173 + matches := re.FindStringSubmatch(fullPath) 174 174 175 175 if len(matches) > 1 { 176 176 return matches[1]
-99
appview/searchquery/resolve.go
··· 1 - package searchquery 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "strings" 7 - ) 8 - 9 - // IdentResolver converts a handle/identifier string to a DID string. 10 - type IdentResolver func(ctx context.Context, ident string) (string, error) 11 - 12 - // ResolveAuthor extracts the "author" tag from the query and resolves 13 - // both the positive and negated values to DIDs using the provided resolver. 14 - // Returns empty string / nil on missing tags or resolution failure. 15 - func ResolveAuthor(ctx context.Context, q *Query, resolve IdentResolver, log *slog.Logger) (authorDid string, negatedAuthorDids []string) { 16 - if authorHandle := q.Get("author"); authorHandle != nil { 17 - did, err := resolve(ctx, *authorHandle) 18 - if err != nil { 19 - log.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 20 - } else { 21 - authorDid = did 22 - } 23 - } 24 - 25 - for _, handle := range q.GetAllNegated("author") { 26 - did, err := resolve(ctx, handle) 27 - if err != nil { 28 - log.Debug("failed to resolve negated author handle", "handle", handle, "err", err) 29 - continue 30 - } 31 - negatedAuthorDids = append(negatedAuthorDids, did) 32 - } 33 - 34 - return authorDid, negatedAuthorDids 35 - } 36 - 37 - // TextFilters holds the keyword and phrase filters extracted from a query. 38 - type TextFilters struct { 39 - Keywords []string 40 - NegatedKeywords []string 41 - Phrases []string 42 - NegatedPhrases []string 43 - } 44 - 45 - // ExtractTextFilters extracts keyword and quoted-phrase items from the query, 46 - // separated into positive and negated slices. 47 - func ExtractTextFilters(q *Query) TextFilters { 48 - var tf TextFilters 49 - for _, item := range q.Items() { 50 - switch item.Kind { 51 - case KindKeyword: 52 - if item.Negated { 53 - tf.NegatedKeywords = append(tf.NegatedKeywords, item.Value) 54 - } else { 55 - tf.Keywords = append(tf.Keywords, item.Value) 56 - } 57 - case KindQuoted: 58 - if item.Negated { 59 - tf.NegatedPhrases = append(tf.NegatedPhrases, item.Value) 60 - } else { 61 - tf.Phrases = append(tf.Phrases, item.Value) 62 - } 63 - } 64 - } 65 - return tf 66 - } 67 - 68 - // ResolveDIDLabelValues resolves handle values to DIDs for dynamic label 69 - // tags whose name appears in didLabels. Tags not in didLabels are returned 70 - // unchanged. On resolution failure the original value is kept. 71 - func ResolveDIDLabelValues( 72 - ctx context.Context, 73 - tags []string, 74 - didLabels map[string]bool, 75 - resolve IdentResolver, 76 - log *slog.Logger, 77 - ) []string { 78 - resolved := make([]string, 0, len(tags)) 79 - for _, tag := range tags { 80 - idx := strings.Index(tag, ":") 81 - if idx < 0 { 82 - resolved = append(resolved, tag) 83 - continue 84 - } 85 - name, val := tag[:idx], tag[idx+1:] 86 - if didLabels[name] { 87 - did, err := resolve(ctx, val) 88 - if err != nil { 89 - log.Debug("failed to resolve DID label value", "label", name, "value", val, "err", err) 90 - resolved = append(resolved, tag) 91 - continue 92 - } 93 - resolved = append(resolved, name+":"+did) 94 - } else { 95 - resolved = append(resolved, tag) 96 - } 97 - } 98 - return resolved 99 - }
-33
appview/searchquery/searchquery.go
··· 158 158 return q.Get(key) != nil 159 159 } 160 160 161 - // KnownTags is the set of tag keys with special system-defined handling. 162 - // Any tag:value pair whose key is not in this set is treated as a dynamic 163 - // label filter. 164 - var KnownTags = map[string]bool{ 165 - "state": true, 166 - "author": true, 167 - "label": true, 168 - } 169 - 170 - // GetDynamicTags returns composite "key:value" strings for all non-negated 171 - // tag:value items whose key is not a known system tag. 172 - func (q *Query) GetDynamicTags() []string { 173 - var result []string 174 - for _, item := range q.items { 175 - if item.Kind == KindTagValue && !item.Negated && !KnownTags[item.Key] { 176 - result = append(result, item.Key+":"+item.Value) 177 - } 178 - } 179 - return result 180 - } 181 - 182 - // GetNegatedDynamicTags returns composite "key:value" strings for all negated 183 - // tag:value items whose key is not a known system tag. 184 - func (q *Query) GetNegatedDynamicTags() []string { 185 - var result []string 186 - for _, item := range q.items { 187 - if item.Kind == KindTagValue && item.Negated && !KnownTags[item.Key] { 188 - result = append(result, item.Key+":"+item.Value) 189 - } 190 - } 191 - return result 192 - } 193 - 194 161 func (q *Query) Set(key, value string) { 195 162 raw := key + ":" + value 196 163 found := false
-23
appview/searchquery/searchquery_test.go
··· 250 250 assert.Equal(t, "-label", items[0].Key) 251 251 assert.Equal(t, "bug", items[0].Value) 252 252 } 253 - 254 - func TestDynamicTags(t *testing.T) { 255 - q := Parse("state:open label:bug priority:high severity:critical -priority:low author:alice -severity:minor keyword") 256 - 257 - // Known tags are not included 258 - dynamic := q.GetDynamicTags() 259 - assert.Equal(t, []string{"priority:high", "severity:critical"}, dynamic) 260 - 261 - negated := q.GetNegatedDynamicTags() 262 - assert.Equal(t, []string{"priority:low", "severity:minor"}, negated) 263 - 264 - // Known tags still work as before 265 - assert.Equal(t, []string{"bug"}, q.GetAll("label")) 266 - val := q.Get("state") 267 - assert.NotNil(t, val) 268 - assert.Equal(t, "open", *val) 269 - } 270 - 271 - func TestDynamicTagsEmpty(t *testing.T) { 272 - q := Parse("state:open label:bug author:alice keyword") 273 - assert.Equal(t, 0, len(q.GetDynamicTags())) 274 - assert.Equal(t, 0, len(q.GetNegatedDynamicTags())) 275 - }
-294
appview/settings/danger.go
··· 1 - package settings 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "net/http" 7 - "strings" 8 - "time" 9 - 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - ) 13 - 14 - type pdsSession struct { 15 - Client *xrpc.Client 16 - Did string 17 - Email string 18 - AccessJwt string 19 - } 20 - 21 - func (s *Settings) pdsClient() *xrpc.Client { 22 - return &xrpc.Client{ 23 - Host: s.Config.Pds.Host, 24 - Client: &http.Client{Timeout: 15 * time.Second}, 25 - } 26 - } 27 - 28 - func (s *Settings) verifyPdsPassword(did, password string) (*pdsSession, error) { 29 - client := s.pdsClient() 30 - resp, err := comatproto.ServerCreateSession(context.Background(), client, &comatproto.ServerCreateSession_Input{ 31 - Identifier: did, 32 - Password: password, 33 - }) 34 - if err != nil { 35 - return nil, err 36 - } 37 - 38 - client.Auth = &xrpc.AuthInfo{AccessJwt: resp.AccessJwt} 39 - 40 - var email string 41 - if resp.Email != nil { 42 - email = *resp.Email 43 - } 44 - 45 - return &pdsSession{ 46 - Client: client, 47 - Did: resp.Did, 48 - Email: email, 49 - AccessJwt: resp.AccessJwt, 50 - }, nil 51 - } 52 - 53 - func (s *Settings) revokePdsSession(session *pdsSession) { 54 - if err := comatproto.ServerDeleteSession(context.Background(), session.Client); err != nil { 55 - s.Logger.Warn("failed to revoke session", "err", err) 56 - } 57 - } 58 - 59 - func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { 60 - user := s.OAuth.GetMultiAccountUser(r) 61 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 62 - s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 63 - return 64 - } 65 - 66 - did := s.OAuth.GetDid(r) 67 - password := r.FormValue("current_password") 68 - if password == "" { 69 - s.Pages.Notice(w, "password-error", "Password is required.") 70 - return 71 - } 72 - 73 - session, err := s.verifyPdsPassword(did, password) 74 - if err != nil { 75 - s.Pages.Notice(w, "password-error", "Current password is incorrect.") 76 - return 77 - } 78 - 79 - if session.Email == "" { 80 - s.revokePdsSession(session) 81 - s.Logger.Error("requesting password reset: no email on account", "did", did) 82 - s.Pages.Notice(w, "password-error", "No email associated with your account.") 83 - return 84 - } 85 - 86 - s.revokePdsSession(session) 87 - 88 - err = comatproto.ServerRequestPasswordReset(context.Background(), s.pdsClient(), &comatproto.ServerRequestPasswordReset_Input{ 89 - Email: session.Email, 90 - }) 91 - if err != nil { 92 - s.Logger.Error("requesting password reset", "err", err) 93 - s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 94 - return 95 - } 96 - 97 - s.Pages.DangerPasswordTokenStep(w) 98 - } 99 - 100 - func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { 101 - user := s.OAuth.GetMultiAccountUser(r) 102 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 103 - s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 104 - return 105 - } 106 - 107 - token := strings.TrimSpace(r.FormValue("token")) 108 - newPassword := r.FormValue("new_password") 109 - confirmPassword := r.FormValue("confirm_password") 110 - 111 - if token == "" || newPassword == "" || confirmPassword == "" { 112 - s.Pages.Notice(w, "password-error", "All fields are required.") 113 - return 114 - } 115 - 116 - if newPassword != confirmPassword { 117 - s.Pages.Notice(w, "password-error", "Passwords do not match.") 118 - return 119 - } 120 - 121 - err := comatproto.ServerResetPassword(context.Background(), s.pdsClient(), &comatproto.ServerResetPassword_Input{ 122 - Token: token, 123 - Password: newPassword, 124 - }) 125 - if err != nil { 126 - s.Logger.Error("resetting password", "err", err) 127 - s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") 128 - return 129 - } 130 - 131 - s.Pages.DangerPasswordSuccess(w) 132 - } 133 - 134 - func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { 135 - user := s.OAuth.GetMultiAccountUser(r) 136 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 137 - s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") 138 - return 139 - } 140 - 141 - did := s.OAuth.GetDid(r) 142 - password := r.FormValue("password") 143 - 144 - if password == "" { 145 - s.Pages.Notice(w, "deactivate-error", "Password is required.") 146 - return 147 - } 148 - 149 - session, err := s.verifyPdsPassword(did, password) 150 - if err != nil { 151 - s.Pages.Notice(w, "deactivate-error", "Password is incorrect.") 152 - return 153 - } 154 - 155 - err = comatproto.ServerDeactivateAccount(context.Background(), session.Client, &comatproto.ServerDeactivateAccount_Input{}) 156 - s.revokePdsSession(session) 157 - if err != nil { 158 - s.Logger.Error("deactivating account", "err", err) 159 - s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 160 - return 161 - } 162 - 163 - if err := s.OAuth.DeleteSession(w, r); err != nil { 164 - s.Logger.Error("clearing session after deactivation", "did", did, "err", err) 165 - } 166 - if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 167 - s.Logger.Error("removing account after deactivation", "did", did, "err", err) 168 - } 169 - s.Pages.HxRedirect(w, "/") 170 - } 171 - 172 - func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { 173 - user := s.OAuth.GetMultiAccountUser(r) 174 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 175 - s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 176 - return 177 - } 178 - 179 - did := s.OAuth.GetDid(r) 180 - password := r.FormValue("password") 181 - 182 - if password == "" { 183 - s.Pages.Notice(w, "delete-error", "Password is required.") 184 - return 185 - } 186 - 187 - session, err := s.verifyPdsPassword(did, password) 188 - if err != nil { 189 - s.Pages.Notice(w, "delete-error", "Password is incorrect.") 190 - return 191 - } 192 - 193 - err = comatproto.ServerRequestAccountDelete(context.Background(), session.Client) 194 - s.revokePdsSession(session) 195 - if err != nil { 196 - s.Logger.Error("requesting account deletion", "err", err) 197 - s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 198 - return 199 - } 200 - 201 - s.Pages.DangerDeleteTokenStep(w) 202 - } 203 - 204 - func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { 205 - user := s.OAuth.GetMultiAccountUser(r) 206 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 207 - s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 208 - return 209 - } 210 - 211 - did := s.OAuth.GetDid(r) 212 - password := r.FormValue("password") 213 - token := strings.TrimSpace(r.FormValue("token")) 214 - confirmation := r.FormValue("confirmation") 215 - 216 - if password == "" || token == "" { 217 - s.Pages.Notice(w, "delete-error", "All fields are required.") 218 - return 219 - } 220 - 221 - if confirmation != "delete my account" { 222 - s.Pages.Notice(w, "delete-error", "You must type \"delete my account\" to confirm.") 223 - return 224 - } 225 - 226 - err := comatproto.ServerDeleteAccount(context.Background(), s.pdsClient(), &comatproto.ServerDeleteAccount_Input{ 227 - Did: did, 228 - Password: password, 229 - Token: token, 230 - }) 231 - if err != nil { 232 - s.Logger.Error("deleting account", "err", err) 233 - s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") 234 - return 235 - } 236 - 237 - if err := s.OAuth.DeleteSession(w, r); err != nil { 238 - s.Logger.Error("clearing session after account deletion", "did", did, "err", err) 239 - } 240 - if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 241 - s.Logger.Error("removing account after deletion", "did", did, "err", err) 242 - } 243 - s.Pages.HxRedirect(w, "/") 244 - } 245 - 246 - func (s *Settings) isAccountDeactivated(ctx context.Context, did, pdsHost string) bool { 247 - client := &xrpc.Client{ 248 - Host: pdsHost, 249 - Client: &http.Client{Timeout: 5 * time.Second}, 250 - } 251 - 252 - _, err := comatproto.RepoDescribeRepo(ctx, client, did) 253 - if err == nil { 254 - return false 255 - } 256 - 257 - var xrpcErr *xrpc.Error 258 - var xrpcBody *xrpc.XRPCError 259 - return errors.As(err, &xrpcErr) && 260 - errors.As(xrpcErr.Wrapped, &xrpcBody) && 261 - xrpcBody.ErrStr == "RepoDeactivated" 262 - } 263 - 264 - func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { 265 - user := s.OAuth.GetMultiAccountUser(r) 266 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 267 - s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") 268 - return 269 - } 270 - 271 - did := s.OAuth.GetDid(r) 272 - password := r.FormValue("password") 273 - 274 - if password == "" { 275 - s.Pages.Notice(w, "reactivate-error", "Password is required.") 276 - return 277 - } 278 - 279 - session, err := s.verifyPdsPassword(did, password) 280 - if err != nil { 281 - s.Pages.Notice(w, "reactivate-error", "Password is incorrect.") 282 - return 283 - } 284 - 285 - err = comatproto.ServerActivateAccount(context.Background(), session.Client) 286 - s.revokePdsSession(session) 287 - if err != nil { 288 - s.Logger.Error("reactivating account", "err", err) 289 - s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 290 - return 291 - } 292 - 293 - s.Pages.HxRefresh(w) 294 - }
-124
appview/settings/moderation.go
··· 1 - package settings 2 - 3 - import ( 4 - "regexp" 5 - "strings" 6 - "sync" 7 - "unicode" 8 - ) 9 - 10 - // slurPatterns contains regexes that match slurs and their common obfuscations, 11 - // including unicode lookalikes. Ported from tranquil-pds moderation/mod.rs. 12 - // 13 - // CONTENT WARNING: this file exists solely to block hateful language from 14 - // appearing in subdomains. The patterns below do not reflect the views of 15 - // this project or its contributors. 16 - var ( 17 - slurPatterns []*regexp.Regexp 18 - slurPatternsOnce sync.Once 19 - ) 20 - 21 - func getSlurPatterns() []*regexp.Regexp { 22 - slurPatternsOnce.Do(func() { 23 - raw := []string{ 24 - `(?i)\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][hĤĥȞȟḦḧḢḣḨḩḤḥḪḫH̱ẖĦħⱧⱨꞪɦꞕΗНн][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b`, 25 - `(?i)\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b`, 26 - `(?i)\b[fḞḟƑƒꞘꞙᵮᶂ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa@4][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGg]{1,2}([ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeiÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ]{1,2}([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b`, 27 - `(?i)\b[kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLlyÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]*\b`, 28 - `(?i)\b[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeaÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ]?|n[ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]|[a4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa]?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b`, 29 - `(?i)[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?`, 30 - `(?i)\b[tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa4]+[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn]{1,2}([iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]|[yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b`, 31 - } 32 - for _, r := range raw { 33 - slurPatterns = append(slurPatterns, regexp.MustCompile(r)) 34 - } 35 - }) 36 - return slurPatterns 37 - } 38 - 39 - // stripTrailingDigits removes trailing ASCII digits from s. 40 - func stripTrailingDigits(s string) string { 41 - return strings.TrimRightFunc(s, func(r rune) bool { 42 - return r >= '0' && r <= '9' 43 - }) 44 - } 45 - 46 - // normalizeLeetspeak replaces common leetspeak substitutions with their 47 - // standard ASCII equivalents. 48 - func normalizeLeetspeak(s string) string { 49 - var b strings.Builder 50 - b.Grow(len(s)) 51 - for _, c := range s { 52 - switch c { 53 - case '4', '@': 54 - b.WriteRune('a') 55 - case '3': 56 - b.WriteRune('e') 57 - case '1', '!', '|': 58 - b.WriteRune('i') 59 - case '0': 60 - b.WriteRune('o') 61 - case '5', '$': 62 - b.WriteRune('s') 63 - case '7': 64 - b.WriteRune('t') 65 - case '8': 66 - b.WriteRune('b') 67 - case '9': 68 - b.WriteRune('g') 69 - default: 70 - b.WriteRune(c) 71 - } 72 - } 73 - return b.String() 74 - } 75 - 76 - // foldToASCII strips unicode combining characters and maps each rune to its 77 - // lowercase ASCII equivalent where possible, leaving other runes unchanged. 78 - func foldToASCII(s string) string { 79 - var b strings.Builder 80 - b.Grow(len(s)) 81 - for _, r := range s { 82 - // drop combining/modifier categories 83 - cat := unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Mc, r) || unicode.Is(unicode.Me, r) 84 - if cat { 85 - continue 86 - } 87 - b.WriteRune(unicode.ToLower(r)) 88 - } 89 - return b.String() 90 - } 91 - 92 - // subdomainHasSlur returns true if the given subdomain contains a slur, 93 - // checking across several normalisation passes to defeat common bypass 94 - // attempts: 95 - // 96 - // 1. raw lowercased input 97 - // 2. separators (. - _) stripped 98 - // 3. trailing digits stripped 99 - // 4. separators stripped + trailing digits stripped 100 - // 5. leetspeak normalised variants of all of the above 101 - func subdomainHasSlur(subdomain string) bool { 102 - lower := strings.ToLower(subdomain) 103 - normalized := strings.NewReplacer(".", "", "-", "", "_", "").Replace(lower) 104 - stripped := stripTrailingDigits(lower) 105 - normalizedStripped := stripTrailingDigits(normalized) 106 - 107 - candidates := []string{ 108 - lower, 109 - normalized, 110 - stripped, 111 - normalizedStripped, 112 - normalizeLeetspeak(normalized), 113 - normalizeLeetspeak(normalizedStripped), 114 - } 115 - 116 - for _, pat := range getSlurPatterns() { 117 - for _, c := range candidates { 118 - if pat.MatchString(c) { 119 - return true 120 - } 121 - } 122 - } 123 - return false 124 - }
+40 -332
appview/settings/settings.go
··· 1 1 package settings 2 2 3 3 import ( 4 - "context" 5 4 "database/sql" 6 5 "errors" 7 6 "fmt" 8 - "html" 9 7 "log" 10 - "log/slog" 11 8 "net/http" 12 9 "net/url" 13 - "slices" 14 10 "strings" 15 11 "time" 16 12 17 13 "github.com/go-chi/chi/v5" 18 14 "tangled.org/core/api/tangled" 19 - "tangled.org/core/appview/cloudflare" 20 15 "tangled.org/core/appview/config" 21 16 "tangled.org/core/appview/db" 22 17 "tangled.org/core/appview/email" ··· 24 19 "tangled.org/core/appview/models" 25 20 "tangled.org/core/appview/oauth" 26 21 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/sites" 28 22 "tangled.org/core/tid" 29 23 30 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - atpclient "github.com/bluesky-social/indigo/atproto/client" 32 25 "github.com/bluesky-social/indigo/atproto/syntax" 33 26 lexutil "github.com/bluesky-social/indigo/lex/util" 34 27 "github.com/gliderlabs/ssh" ··· 36 29 ) 37 30 38 31 type Settings struct { 39 - Db *db.DB 40 - OAuth *oauth.OAuth 41 - Pages *pages.Pages 42 - Config *config.Config 43 - CfClient *cloudflare.Client 44 - Logger *slog.Logger 32 + Db *db.DB 33 + OAuth *oauth.OAuth 34 + Pages *pages.Pages 35 + Config *config.Config 45 36 } 46 37 47 38 func (s *Settings) Router() http.Handler { ··· 73 64 r.Put("/", s.updateNotificationPreferences) 74 65 }) 75 66 76 - r.Route("/sites", func(r chi.Router) { 77 - r.Get("/", s.sitesSettings) 78 - r.Put("/", s.claimSitesDomain) 79 - r.Delete("/", s.releaseSitesDomain) 80 - }) 81 - 82 - r.Post("/password/request", s.requestPasswordReset) 83 - r.Post("/password/reset", s.resetPassword) 84 - r.Post("/deactivate", s.deactivateAccount) 85 - r.Post("/reactivate", s.reactivateAccount) 86 - r.Post("/delete/request", s.requestAccountDelete) 87 - r.Post("/delete/confirm", s.deleteAccount) 88 - 89 - r.Get("/handle", s.elevateForHandle) 90 - r.Post("/handle", s.updateHandle) 91 - 92 67 return r 93 68 } 94 69 95 - func (s *Settings) sitesSettings(w http.ResponseWriter, r *http.Request) { 96 - user := s.OAuth.GetMultiAccountUser(r) 97 - did := s.OAuth.GetDid(r) 98 - 99 - claim, err := db.GetActiveDomainClaimForDid(s.Db, did) 100 - if err != nil { 101 - s.Logger.Error("failed to get domain claim", "err", err) 102 - claim = nil 103 - } 104 - 105 - // determine whether the active account has a tngl.sh handle, in which 106 - // case their sites domain is automatically their handle domain. 107 - pdsDomain := strings.TrimPrefix(s.Config.Pds.Host, "https://") 108 - pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 109 - isTnglHandle := false 110 - for _, acc := range user.Accounts { 111 - if acc.Did == did && strings.HasSuffix(acc.Handle, "."+pdsDomain) { 112 - isTnglHandle = true 113 - break 114 - } 115 - } 116 - 117 - s.Pages.UserSiteSettings(w, pages.UserSiteSettingsParams{ 118 - LoggedInUser: user, 119 - Claim: claim, 120 - SitesDomain: s.Config.Sites.Domain, 121 - IsTnglHandle: isTnglHandle, 122 - }) 123 - } 124 - 125 - func (s *Settings) claimSitesDomain(w http.ResponseWriter, r *http.Request) { 126 - did := s.OAuth.GetDid(r) 127 - 128 - subdomain := strings.TrimSpace(r.FormValue("subdomain")) 129 - if subdomain == "" { 130 - s.Pages.Notice(w, "settings-sites-error", "Subdomain cannot be empty.") 131 - return 132 - } 133 - 134 - if len(subdomain) < 4 { 135 - s.Pages.Notice(w, "settings-sites-error", "Subdomain must be at least 4 characters long.") 136 - return 137 - } 138 - 139 - if subdomainHasSlur(subdomain) { 140 - s.Pages.Notice(w, "settings-sites-error", "That subdomain is not allowed.") 141 - return 142 - } 143 - 144 - if !isValidSubdomain(subdomain) { 145 - s.Pages.Notice(w, "settings-sites-error", "Invalid subdomain. Use only lowercase letters, digits, and hyphens. Cannot start or end with a hyphen.") 146 - return 147 - } 148 - 149 - sitesDomain := s.Config.Sites.Domain 150 - 151 - if subdomain == sitesDomain { 152 - s.Pages.Notice(w, "settings-sites-error", fmt.Sprintf("You cannot claim the root domain %q.", sitesDomain)) 153 - return 154 - } 155 - 156 - fullDomain := subdomain + "." + sitesDomain 157 - 158 - if err := db.ClaimDomain(s.Db, did, fullDomain); err != nil { 159 - switch { 160 - case errors.Is(err, db.ErrDomainTaken): 161 - s.Pages.Notice(w, "settings-sites-error", "That domain is already claimed by another user.") 162 - case errors.Is(err, db.ErrDomainCooldown): 163 - s.Pages.Notice(w, "settings-sites-error", "That domain was recently released and is in a 30-day cooldown period. Please try again later.") 164 - case errors.Is(err, db.ErrAlreadyClaimed): 165 - s.Pages.Notice(w, "settings-sites-error", "You already have a domain claimed. Release it before claiming a new one.") 166 - default: 167 - s.Logger.Error("claiming domain", "err", err) 168 - s.Pages.Notice(w, "settings-sites-error", "Unable to claim domain at this moment. Try again later.") 169 - } 170 - return 171 - } 172 - 173 - s.Pages.HxRefresh(w) 174 - } 175 - 176 - func (s *Settings) releaseSitesDomain(w http.ResponseWriter, r *http.Request) { 177 - did := s.OAuth.GetDid(r) 178 - domain := strings.TrimSpace(r.FormValue("domain")) 179 - 180 - if domain == "" { 181 - s.Pages.Notice(w, "settings-sites-error", "Domain cannot be empty.") 182 - return 183 - } 184 - 185 - pdsDomain := strings.TrimPrefix(s.Config.Pds.Host, "https://") 186 - pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 187 - user := s.OAuth.GetMultiAccountUser(r) 188 - for _, acc := range user.Accounts { 189 - if acc.Did == did && strings.HasSuffix(acc.Handle, "."+pdsDomain) { 190 - if strings.HasSuffix(domain, "."+pdsDomain) { 191 - s.Pages.Notice(w, "settings-sites-error", "Your tngl.sh domain is tied to your handle and cannot be released here.") 192 - return 193 - } 194 - } 195 - } 196 - 197 - if err := db.ReleaseDomain(s.Db, did, domain); err != nil { 198 - s.Logger.Error("releasing domain", "err", err) 199 - s.Pages.Notice(w, "settings-sites-error", "Unable to release domain. Make sure it belongs to your account.") 200 - return 201 - } 202 - 203 - // Clean up all site data for this DID asynchronously. 204 - if s.CfClient.Enabled() { 205 - siteConfigs, err := db.GetRepoSiteConfigsForDid(s.Db, did) 206 - if err != nil { 207 - s.Logger.Error("releaseSitesDomain: fetching site configs for cleanup", "err", err) 208 - } 209 - 210 - if err := db.DeleteRepoSiteConfigsForDid(s.Db, did); err != nil { 211 - s.Logger.Error("releaseSitesDomain: deleting site configs from db", "err", err) 212 - } 213 - 214 - go func() { 215 - ctx := context.Background() 216 - 217 - // Delete each repo's R2 objects. 218 - for _, sc := range siteConfigs { 219 - if err := sites.Delete(ctx, s.CfClient, did, sc.RepoName); err != nil { 220 - s.Logger.Error("releaseSitesDomain: R2 delete failed", "did", did, "repo", sc.RepoName, "err", err) 221 - } 222 - } 223 - 224 - // Delete the single KV entry for the domain. 225 - if err := sites.DeleteAllDomainMappings(ctx, s.CfClient, domain); err != nil { 226 - s.Logger.Error("releaseSitesDomain: KV delete failed", "domain", domain, "err", err) 227 - } 228 - }() 229 - } 230 - 231 - s.Pages.HxLocation(w, "/settings/sites") 232 - } 233 - 234 - // isValidSubdomain checks that a subdomain label uses only lowercase letters, 235 - // digits, and hyphens, and does not start or end with a hyphen. 236 - func isValidSubdomain(s string) bool { 237 - if len(s) == 0 || len(s) > 63 { 238 - return false 239 - } 240 - if s[0] == '-' || s[len(s)-1] == '-' { 241 - return false 242 - } 243 - for _, c := range s { 244 - if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { 245 - return false 246 - } 247 - } 248 - return true 249 - } 250 - 251 70 func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 252 71 user := s.OAuth.GetMultiAccountUser(r) 253 72 ··· 256 75 log.Printf("failed to get users punchcard preferences: %s", err) 257 76 } 258 77 259 - isDeactivated := s.Config.Pds.IsTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 260 - 261 78 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 262 79 LoggedInUser: user, 263 80 PunchcardPreference: punchcardPreferences, 264 - IsTnglSh: s.Config.Pds.IsTnglShUser(user.Pds()), 265 - IsDeactivated: isDeactivated, 266 - PdsDomain: s.pdsDomain(), 267 - HandleOpen: r.URL.Query().Get("handle") == "1", 268 81 }) 269 82 } 270 83 ··· 274 87 275 88 prefs, err := db.GetNotificationPreference(s.Db, did) 276 89 if err != nil { 277 - s.Logger.Error("failed to get notification preferences", "err", err) 90 + log.Printf("failed to get notification preferences: %s", err) 278 91 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 279 92 return 280 93 } ··· 304 117 305 118 err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 306 119 if err != nil { 307 - s.Logger.Error("failed to update notification preferences", "err", err) 120 + log.Printf("failed to update notification preferences: %s", err) 308 121 s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 309 122 return 310 123 } ··· 316 129 user := s.OAuth.GetMultiAccountUser(r) 317 130 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Active.Did) 318 131 if err != nil { 319 - s.Logger.Error("keys settings", "err", err) 132 + log.Println(err) 320 133 } 321 134 322 135 s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ ··· 329 142 user := s.OAuth.GetMultiAccountUser(r) 330 143 emails, err := db.GetAllEmails(s.Db, user.Active.Did) 331 144 if err != nil { 332 - s.Logger.Error("emails settings", "err", err) 145 + log.Println(err) 333 146 } 334 147 335 148 s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ ··· 360 173 361 174 err := email.SendEmail(emailToSend) 362 175 if err != nil { 363 - s.Logger.Error("sending email", "err", err) 176 + log.Printf("sending email: %s", err) 364 177 s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 365 178 return err 366 179 } ··· 372 185 switch r.Method { 373 186 case http.MethodGet: 374 187 s.Pages.Notice(w, "settings-emails", "Unimplemented.") 375 - s.Logger.Warn("emails: unimplemented method") 188 + log.Println("unimplemented") 376 189 return 377 190 case http.MethodPut: 378 191 did := s.OAuth.GetDid(r) ··· 387 200 // check if email already exists in database 388 201 existingEmail, err := db.GetEmail(s.Db, did, emAddr) 389 202 if err != nil && !errors.Is(err, sql.ErrNoRows) { 390 - s.Logger.Error("checking for existing email", "err", err) 203 + log.Printf("checking for existing email: %s", err) 391 204 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 392 205 return 393 206 } ··· 407 220 // Begin transaction 408 221 tx, err := s.Db.Begin() 409 222 if err != nil { 410 - s.Logger.Error("failed to start transaction", "err", err) 223 + log.Printf("failed to start transaction: %s", err) 411 224 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 412 225 return 413 226 } ··· 419 232 Verified: false, 420 233 VerificationCode: code, 421 234 }); err != nil { 422 - s.Logger.Error("adding email", "err", err) 235 + log.Printf("adding email: %s", err) 423 236 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 424 237 return 425 238 } ··· 430 243 431 244 // Commit transaction 432 245 if err := tx.Commit(); err != nil { 433 - s.Logger.Error("failed to commit add-email transaction", "err", err) 246 + log.Printf("failed to commit transaction: %s", err) 434 247 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 435 248 return 436 249 } ··· 445 258 // Begin transaction 446 259 tx, err := s.Db.Begin() 447 260 if err != nil { 448 - s.Logger.Error("failed to start transaction", "err", err) 261 + log.Printf("failed to start transaction: %s", err) 449 262 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 450 263 return 451 264 } 452 265 defer tx.Rollback() 453 266 454 267 if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 455 - s.Logger.Error("deleting email", "err", err) 268 + log.Printf("deleting email: %s", err) 456 269 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 457 270 return 458 271 } 459 272 460 273 // Commit transaction 461 274 if err := tx.Commit(); err != nil { 462 - s.Logger.Error("failed to commit delete-email transaction", "err", err) 275 + log.Printf("failed to commit transaction: %s", err) 463 276 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 464 277 return 465 278 } ··· 489 302 490 303 valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code) 491 304 if err != nil { 492 - s.Logger.Error("checking email verification", "err", err) 305 + log.Printf("checking email verification: %s", err) 493 306 s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 494 307 return 495 308 } ··· 501 314 502 315 // Mark email as verified in the database 503 316 if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil { 504 - s.Logger.Error("marking email as verified", "err", err) 317 + log.Printf("marking email as verified: %s", err) 505 318 s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 506 319 return 507 320 } ··· 530 343 if errors.Is(err, sql.ErrNoRows) { 531 344 s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 532 345 } else { 533 - s.Logger.Error("checking for existing email", "err", err) 346 + log.Printf("checking for existing email: %s", err) 534 347 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 535 348 } 536 349 return ··· 557 370 // Begin transaction 558 371 tx, err := s.Db.Begin() 559 372 if err != nil { 560 - s.Logger.Error("failed to start transaction", "err", err) 373 + log.Printf("failed to start transaction: %s", err) 561 374 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 562 375 return 563 376 } ··· 565 378 566 379 // Update the verification code and last sent time 567 380 if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 568 - s.Logger.Error("updating email verification code", "err", err) 381 + log.Printf("updating email verification: %s", err) 569 382 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 570 383 return 571 384 } ··· 577 390 578 391 // Commit transaction 579 392 if err := tx.Commit(); err != nil { 580 - s.Logger.Error("failed to commit resend-verification transaction", "err", err) 393 + log.Printf("failed to commit transaction: %s", err) 581 394 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 582 395 return 583 396 } ··· 596 409 } 597 410 598 411 if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil { 599 - s.Logger.Error("setting primary email", "err", err) 412 + log.Printf("setting primary email: %s", err) 600 413 s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 601 414 return 602 415 } ··· 608 421 switch r.Method { 609 422 case http.MethodGet: 610 423 s.Pages.Notice(w, "settings-keys", "Unimplemented.") 611 - s.Logger.Warn("keys: unimplemented method") 424 + log.Println("unimplemented") 612 425 return 613 426 case http.MethodPut: 614 427 did := s.OAuth.GetDid(r) ··· 623 436 624 437 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 625 438 if err != nil { 626 - s.Logger.Error("parsing public key", "err", err) 627 - s.Pages.NoticeHTML(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 439 + log.Printf("parsing public key: %s", err) 440 + s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 628 441 return 629 442 } 630 443 ··· 632 445 633 446 tx, err := s.Db.Begin() 634 447 if err != nil { 635 - s.Logger.Error("failed to start transaction for adding public key", "err", err) 448 + log.Printf("failed to start tx; adding public key: %s", err) 636 449 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 637 450 return 638 451 } 639 452 defer tx.Rollback() 640 453 641 454 if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 642 - s.Logger.Error("adding public key", "err", err) 455 + log.Printf("adding public key: %s", err) 643 456 s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 644 457 return 645 458 } ··· 658 471 }) 659 472 // invalid record 660 473 if err != nil { 661 - s.Logger.Error("failed to create atproto record", "err", err) 474 + log.Printf("failed to create record: %s", err) 662 475 s.Pages.Notice(w, "settings-keys", "Failed to create record.") 663 476 return 664 477 } 665 478 666 - s.Logger.Info("created atproto record", "uri", resp.Uri) 479 + log.Println("created atproto record: ", resp.Uri) 667 480 668 481 err = tx.Commit() 669 482 if err != nil { 670 - s.Logger.Error("failed to commit add-key transaction", "err", err) 483 + log.Printf("failed to commit tx; adding public key: %s", err) 671 484 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 672 485 return 673 486 } ··· 683 496 rkey := q.Get("rkey") 684 497 key := q.Get("key") 685 498 686 - s.Logger.Debug("deleting key", "name", name, "rkey", rkey, "key", key) 499 + log.Println(name) 500 + log.Println(rkey) 501 + log.Println(key) 687 502 688 503 client, err := s.OAuth.AuthorizedClient(r) 689 504 if err != nil { 690 - s.Logger.Error("failed to authorize client", "err", err) 505 + log.Printf("failed to authorize client: %s", err) 691 506 s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 692 507 return 693 508 } 694 509 695 510 if err := db.DeletePublicKey(s.Db, did, name, key); err != nil { 696 - s.Logger.Error("removing public key", "err", err) 511 + log.Printf("removing public key: %s", err) 697 512 s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 698 513 return 699 514 } ··· 708 523 709 524 // invalid record 710 525 if err != nil { 711 - s.Logger.Error("failed to delete record", "err", err) 712 - s.Pages.Notice(w, "settings-keys", "Failed to remove key.") 526 + log.Printf("failed to delete record from PDS: %s", err) 527 + s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 713 528 return 714 529 } 715 530 } 716 - s.Logger.Info("deleted key successfully", "name", name) 531 + log.Println("deleted successfully") 717 532 718 533 s.Pages.HxLocation(w, "/settings/keys") 719 534 return 720 535 } 721 536 } 722 - 723 - func (s *Settings) pdsDomain() string { 724 - parsed, err := url.Parse(s.Config.Pds.Host) 725 - if err != nil { 726 - return s.Config.Pds.Host 727 - } 728 - return parsed.Hostname() 729 - } 730 - 731 - func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { 732 - user := s.OAuth.GetMultiAccountUser(r) 733 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 734 - http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 735 - return 736 - } 737 - 738 - sess, err := s.OAuth.ResumeSession(r) 739 - if err == nil && slices.Contains(sess.Data.Scopes, "identity:handle") { 740 - http.Redirect(w, r, "/settings/profile?handle=1", http.StatusSeeOther) 741 - return 742 - } 743 - 744 - redirectURL, err := s.OAuth.StartElevatedAuthFlow( 745 - r.Context(), w, r, 746 - user.Did(), 747 - []string{"identity:handle"}, 748 - "/settings/profile?handle=1", 749 - ) 750 - if err != nil { 751 - log.Printf("failed to start elevated auth flow: %s", err) 752 - http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 753 - return 754 - } 755 - 756 - http.Redirect(w, r, redirectURL, http.StatusFound) 757 - } 758 - 759 - func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { 760 - user := s.OAuth.GetMultiAccountUser(r) 761 - if !s.Config.Pds.IsTnglShUser(user.Pds()) { 762 - s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") 763 - return 764 - } 765 - 766 - handleType := r.FormValue("type") 767 - handleInput := strings.TrimSpace(r.FormValue("handle")) 768 - 769 - if handleInput == "" { 770 - s.Pages.Notice(w, "handle-error", "Handle cannot be empty.") 771 - return 772 - } 773 - 774 - var newHandle string 775 - switch handleType { 776 - case "subdomain": 777 - if !isValidSubdomain(handleInput) { 778 - s.Pages.Notice(w, "handle-error", "Invalid handle. Use only lowercase letters, digits, and hyphens.") 779 - return 780 - } 781 - newHandle = handleInput + "." + s.pdsDomain() 782 - case "custom": 783 - newHandle = handleInput 784 - default: 785 - s.Pages.Notice(w, "handle-error", "Invalid handle type.") 786 - return 787 - } 788 - 789 - client, err := s.OAuth.AuthorizedClient(r) 790 - if err != nil { 791 - log.Printf("failed to get authorized client: %s", err) 792 - s.Pages.Notice(w, "handle-error", "Failed to authorize. Try logging in again.") 793 - return 794 - } 795 - 796 - err = comatproto.IdentityUpdateHandle(r.Context(), client, &comatproto.IdentityUpdateHandle_Input{ 797 - Handle: newHandle, 798 - }) 799 - if err != nil { 800 - if strings.Contains(err.Error(), "ScopeMissing") || strings.Contains(err.Error(), "insufficient_scope") { 801 - redirectURL, elevErr := s.OAuth.StartElevatedAuthFlow( 802 - r.Context(), w, r, 803 - user.Did(), 804 - []string{"identity:handle"}, 805 - "/settings/profile?handle=1", 806 - ) 807 - if elevErr != nil { 808 - log.Printf("failed to start elevated auth flow: %s", elevErr) 809 - s.Pages.Notice(w, "handle-error", "Failed to start re-authorization. Try again later.") 810 - return 811 - } 812 - 813 - s.Pages.HxRedirect(w, redirectURL) 814 - return 815 - } 816 - 817 - log.Printf("failed to update handle: %s", err) 818 - msg := err.Error() 819 - var apiErr *atpclient.APIError 820 - if errors.As(err, &apiErr) && apiErr.Message != "" { 821 - msg = apiErr.Message 822 - } 823 - s.Pages.Notice(w, "handle-error", fmt.Sprintf("Failed to update handle: %s", msg)) 824 - return 825 - } 826 - 827 - s.Pages.NoticeHTML(w, "handle-success", fmt.Sprintf("Handle updated to <strong>%s</strong>.", html.EscapeString(newHandle))) 828 - }
+28 -49
appview/signup/signup.go
··· 14 14 15 15 "github.com/go-chi/chi/v5" 16 16 "github.com/posthog/posthog-go" 17 - "tangled.org/core/appview/cloudflare" 18 17 "tangled.org/core/appview/config" 19 18 "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/dns" 20 20 "tangled.org/core/appview/email" 21 21 "tangled.org/core/appview/models" 22 22 "tangled.org/core/appview/pages" ··· 27 27 type Signup struct { 28 28 config *config.Config 29 29 db *db.DB 30 - cf *cloudflare.Client 30 + cf *dns.Cloudflare 31 31 posthog posthog.Client 32 32 idResolver *idresolver.Resolver 33 33 pages *pages.Pages ··· 36 36 } 37 37 38 38 func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 39 - var cf *cloudflare.Client 40 - if cfg.Cloudflare.ApiToken != "" { 39 + var cf *dns.Cloudflare 40 + if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 41 41 var err error 42 - cf, err = cloudflare.New(cfg) 42 + cf, err = dns.NewCloudflare(cfg) 43 43 if err != nil { 44 44 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 45 45 } ··· 118 118 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 119 119 switch r.Method { 120 120 case http.MethodGet: 121 - emailId := r.URL.Query().Get("id") 122 121 s.pages.Signup(w, pages.SignupParams{ 123 - CloudflareSiteKey: s.config.Cloudflare.Turnstile.SiteKey, 124 - EmailId: emailId, 122 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 125 123 }) 126 124 case http.MethodPost: 127 125 if s.cf == nil { ··· 236 234 237 235 // executeSignupTransaction performs the signup process transactionally with rollback 238 236 func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 239 - // var recordID string 237 + var recordID string 240 238 var did string 241 239 var emailAdded bool 242 240 ··· 246 244 s.l.Info("rolling back signup transaction", "username", username, "did", did) 247 245 248 246 // Rollback DNS record 249 - // if recordID != "" { 250 - // if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 251 - // s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 252 - // } else { 253 - // s.l.Info("successfully rolled back DNS record", "recordID", recordID) 254 - // } 255 - // } 247 + if recordID != "" { 248 + if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 249 + s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 250 + } else { 251 + s.l.Info("successfully rolled back DNS record", "recordID", recordID) 252 + } 253 + } 256 254 257 255 // Rollback PDS account 258 256 if did != "" { ··· 282 280 return err 283 281 } 284 282 285 - // XXX: we have a wildcard *.tngl.sh record now 286 283 // step 2: create DNS record with actual DID 287 - // recordID, err = s.cf.CreateDNSRecord(ctx, cloudflare.DNSRecord{ 288 - // Type: "TXT", 289 - // Name: "_atproto." + username, 290 - // Content: fmt.Sprintf(`"did=%s"`, did), 291 - // TTL: 6400, 292 - // Proxied: false, 293 - // }) 294 - // if err != nil { 295 - // s.l.Error("failed to create DNS record", "error", err) 296 - // s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 297 - // return err 298 - // } 284 + recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 + Type: "TXT", 286 + Name: "_atproto." + username, 287 + Content: fmt.Sprintf(`"did=%s"`, did), 288 + TTL: 6400, 289 + Proxied: false, 290 + }) 291 + if err != nil { 292 + s.l.Error("failed to create DNS record", "error", err) 293 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 + return err 295 + } 299 296 300 297 // step 3: add email to database 301 298 err = db.AddEmail(s.db, models.Email{ ··· 311 308 } 312 309 emailAdded = true 313 310 314 - // step 4: auto-claim <username>.<pds-domain> for this user. 315 - // All signups through this flow receive a <username>.tngl.sh handle 316 - // (or whatever the configured PDS host is), so we claim the matching 317 - // sites subdomain on their behalf. This is the only way to obtain a 318 - // *.tngl.sh sites domain; it cannot be claimed manually via settings. 319 - pdsDomain := strings.TrimPrefix(s.config.Pds.Host, "https://") 320 - pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 321 - autoClaimDomain := username + "." + pdsDomain 322 - if err := db.ClaimDomain(s.db, did, autoClaimDomain); err != nil { 323 - s.l.Warn("failed to auto-claim sites domain at signup", 324 - "domain", autoClaimDomain, 325 - "did", did, 326 - "error", err, 327 - ) 328 - } else { 329 - s.l.Info("auto-claimed sites domain at signup", "domain", autoClaimDomain, "did", did) 330 - } 331 - 332 311 // if we get here, we've successfully created the account and added the email 333 312 success = true 334 313 ··· 358 337 return errors.New("captcha token is empty") 359 338 } 360 339 361 - if s.config.Cloudflare.Turnstile.SecretKey == "" { 340 + if s.config.Cloudflare.TurnstileSecretKey == "" { 362 341 return errors.New("turnstile secret key not configured") 363 342 } 364 343 365 344 data := url.Values{} 366 - data.Set("secret", s.config.Cloudflare.Turnstile.SecretKey) 345 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 367 346 data.Set("response", cfToken) 368 347 369 348 // include the client IP if we have it
-236
appview/sites/sites.go
··· 1 - package sites 2 - 3 - import ( 4 - "archive/tar" 5 - "bytes" 6 - "compress/gzip" 7 - "context" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "io/fs" 12 - "os" 13 - "path/filepath" 14 - "strings" 15 - 16 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 - "tangled.org/core/api/tangled" 18 - "tangled.org/core/appview/cloudflare" 19 - ) 20 - 21 - // DomainMapping is the value stored in Workers KV, keyed by the bare domain. 22 - // Repos maps repo name → is_index; at most one repo may have is_index = true. 23 - type DomainMapping struct { 24 - Did string `json:"did"` 25 - Repos map[string]bool `json:"repos"` 26 - } 27 - 28 - // getOrNewMapping fetches the existing KV entry for domain, or returns a 29 - // fresh empty mapping for the given did if none exists yet. 30 - func getOrNewMapping(ctx context.Context, cf *cloudflare.Client, domain, did string) (DomainMapping, error) { 31 - raw, err := cf.KVGet(ctx, domain) 32 - if err != nil { 33 - return DomainMapping{}, fmt.Errorf("reading domain mapping for %q: %w", domain, err) 34 - } 35 - if raw == nil { 36 - return DomainMapping{Did: did, Repos: make(map[string]bool)}, nil 37 - } 38 - var m DomainMapping 39 - if err := json.Unmarshal(raw, &m); err != nil { 40 - return DomainMapping{}, fmt.Errorf("unmarshalling domain mapping for %q: %w", domain, err) 41 - } 42 - if m.Repos == nil { 43 - m.Repos = make(map[string]bool) 44 - } 45 - return m, nil 46 - } 47 - 48 - // PutDomainMapping adds or updates a single repo entry within the per-domain 49 - // KV record. If isIndex is true, any previously indexed repo is demoted first. 50 - func PutDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, did, repo string, isIndex bool) error { 51 - m, err := getOrNewMapping(ctx, cf, domain, did) 52 - if err != nil { 53 - return err 54 - } 55 - 56 - m.Did = did 57 - m.Repos[repo] = isIndex 58 - 59 - val, err := json.Marshal(m) 60 - if err != nil { 61 - return fmt.Errorf("marshalling domain mapping: %w", err) 62 - } 63 - if err := cf.KVPut(ctx, domain, val); err != nil { 64 - return fmt.Errorf("putting domain mapping for %q: %w", domain, err) 65 - } 66 - return nil 67 - } 68 - 69 - // DeleteDomainMapping removes a single repo from the per-domain KV record. 70 - // If it was the last repo, the key is deleted entirely. 71 - func DeleteDomainMapping(ctx context.Context, cf *cloudflare.Client, domain, repo string) error { 72 - m, err := getOrNewMapping(ctx, cf, domain, "") 73 - if err != nil { 74 - return err 75 - } 76 - 77 - delete(m.Repos, repo) 78 - 79 - if len(m.Repos) == 0 { 80 - if err := cf.KVDelete(ctx, domain); err != nil { 81 - return fmt.Errorf("deleting domain mapping for %q: %w", domain, err) 82 - } 83 - return nil 84 - } 85 - 86 - val, err := json.Marshal(m) 87 - if err != nil { 88 - return fmt.Errorf("marshalling domain mapping: %w", err) 89 - } 90 - if err := cf.KVPut(ctx, domain, val); err != nil { 91 - return fmt.Errorf("putting domain mapping for %q: %w", domain, err) 92 - } 93 - return nil 94 - } 95 - 96 - // DeleteAllDomainMappings removes the KV entry for a domain entirely. 97 - // Used when a user releases their domain claim. 98 - func DeleteAllDomainMappings(ctx context.Context, cf *cloudflare.Client, domain string) error { 99 - if err := cf.KVDelete(ctx, domain); err != nil { 100 - return fmt.Errorf("deleting all domain mappings for %q: %w", domain, err) 101 - } 102 - return nil 103 - } 104 - 105 - // prefix returns the R2 key prefix for a given repo: "{did}/{repo}/". 106 - // All site objects live under this prefix. 107 - func prefix(repoDid, repoName string) string { 108 - return repoDid + "/" + repoName + "/" 109 - } 110 - 111 - // Deploy fetches the repo archive at the given branch from knotHost, extracts 112 - // deployDir from it, and syncs the resulting files to R2 via cf.SyncFiles. 113 - // It is the authoritative entry-point for deploying a git site. 114 - func Deploy( 115 - ctx context.Context, 116 - cf *cloudflare.Client, 117 - knotHost, repoDid, repoName, branch, deployDir string, 118 - ) error { 119 - tmpDir, err := os.MkdirTemp("", "tangled-sites-*") 120 - if err != nil { 121 - return fmt.Errorf("creating temp dir: %w", err) 122 - } 123 - defer os.RemoveAll(tmpDir) 124 - 125 - if err := extractArchive(ctx, knotHost, repoDid, repoName, branch, tmpDir); err != nil { 126 - return fmt.Errorf("extracting archive: %w", err) 127 - } 128 - 129 - // deployDir is absolute within the repo (e.g. "/" or "/docs"). 130 - // Map it to a path inside tmpDir. 131 - deployRoot := filepath.Join(tmpDir, filepath.FromSlash(deployDir)) 132 - 133 - files := make(map[string][]byte) 134 - err = filepath.WalkDir(deployRoot, func(p string, d fs.DirEntry, err error) error { 135 - if err != nil { 136 - return err 137 - } 138 - if d.IsDir() { 139 - return nil 140 - } 141 - content, err := os.ReadFile(p) 142 - if err != nil { 143 - return err 144 - } 145 - rel, err := filepath.Rel(deployRoot, p) 146 - if err != nil { 147 - return err 148 - } 149 - files[filepath.ToSlash(rel)] = content 150 - return nil 151 - }) 152 - if err != nil { 153 - return fmt.Errorf("walking deploy dir: %w", err) 154 - } 155 - 156 - if err := cf.SyncFiles(ctx, prefix(repoDid, repoName), files); err != nil { 157 - return fmt.Errorf("syncing files to R2: %w", err) 158 - } 159 - 160 - return nil 161 - } 162 - 163 - // Delete removes all R2 objects for a repo site. 164 - func Delete(ctx context.Context, cf *cloudflare.Client, repoDid, repoName string) error { 165 - if err := cf.DeleteFiles(ctx, prefix(repoDid, repoName)); err != nil { 166 - return fmt.Errorf("deleting site files from R2: %w", err) 167 - } 168 - return nil 169 - } 170 - 171 - // extractArchive fetches the tar.gz archive for the given repo+branch from 172 - // the knot via XRPC and extracts it into destDir. 173 - func extractArchive(ctx context.Context, knotHost, repoDid, repoName, branch, destDir string) error { 174 - xrpcc := &indigoxrpc.Client{Host: knotHost} 175 - data, err := tangled.RepoArchive(ctx, xrpcc, "tar.gz", "", branch, repoDid+"/"+repoName) 176 - if err != nil { 177 - return fmt.Errorf("fetching archive: %w", err) 178 - } 179 - 180 - gz, err := gzip.NewReader(bytes.NewReader(data)) 181 - if err != nil { 182 - return fmt.Errorf("opening gzip stream: %w", err) 183 - } 184 - defer gz.Close() 185 - 186 - tr := tar.NewReader(gz) 187 - for { 188 - hdr, err := tr.Next() 189 - if err == io.EOF { 190 - break 191 - } 192 - if err != nil { 193 - return fmt.Errorf("reading tar: %w", err) 194 - } 195 - 196 - // The knot always adds a leading prefix dir (e.g. "myrepo-main/"); strip it. 197 - name := hdr.Name 198 - i := strings.Index(name, "/") 199 - if i < 0 { 200 - continue 201 - } 202 - name = name[i+1:] 203 - if name == "" { 204 - continue 205 - } 206 - 207 - target := filepath.Join(destDir, filepath.FromSlash(name)) 208 - 209 - // Guard against zip-slip. 210 - if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) { 211 - continue 212 - } 213 - 214 - switch hdr.Typeflag { 215 - case tar.TypeDir: 216 - if err := os.MkdirAll(target, 0o755); err != nil { 217 - return err 218 - } 219 - case tar.TypeReg: 220 - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 221 - return err 222 - } 223 - f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode()) 224 - if err != nil { 225 - return err 226 - } 227 - if _, err := io.Copy(f, tr); err != nil { 228 - f.Close() 229 - return err 230 - } 231 - f.Close() 232 - } 233 - } 234 - 235 - return nil 236 - }
+1 -1
appview/state/accounts.go
··· 28 28 } 29 29 30 30 l.Info("switched account", "did", did) 31 - s.pages.HxRefresh(w) 31 + s.pages.HxRedirect(w, "/") 32 32 } 33 33 34 34 func (s *State) RemoveAccount(w http.ResponseWriter, r *http.Request) {
+16 -48
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "maps" 6 7 "net/http" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/identity" ··· 10 11 "tangled.org/core/appview/models" 11 12 ) 12 13 13 - // allowedResponseHeaders is the set of headers we will forward from the knot 14 - // back to the client. everything else is stripped. 15 - var allowedResponseHeaders = map[string]bool{ 16 - "Content-Encoding": true, 17 - "Transfer-Encoding": true, 18 - "Cache-Control": true, 19 - "Expires": true, 20 - "Pragma": true, 21 - } 22 - 23 - func copyAllowedHeaders(dst, src http.Header) { 24 - for k, vv := range src { 25 - if allowedResponseHeaders[http.CanonicalHeaderKey(k)] { 26 - for _, v := range vv { 27 - dst.Add(k, v) 28 - } 29 - } 30 - } 31 - } 32 - 33 - func setGitHeaders(w http.ResponseWriter, contentType string) { 34 - w.Header().Set("Content-Type", contentType) 35 - w.Header().Set("Content-Disposition", "attachment") 36 - w.Header().Set("X-Content-Type-Options", "nosniff") 37 - } 38 - 39 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 40 15 user := r.Context().Value("resolvedId").(identity.Identity) 41 16 repo := r.Context().Value("repo").(*models.Repo) ··· 45 20 scheme = "http" 46 21 } 47 22 48 - // check for the 'service' url param 49 - service := r.URL.Query().Get("service") 50 - var contentType string 51 - switch service { 52 - case "git-receive-pack": 53 - contentType = "application/x-git-receive-pack-advertisement" 54 - default: 55 - // git-upload-pack is the default service for git-clone / git-fetch. 56 - contentType = "application/x-git-upload-pack-advertisement" 57 - } 58 - 59 23 targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 60 - s.proxyRequest(w, r, targetURL, contentType) 24 + s.proxyRequest(w, r, targetURL) 25 + 61 26 } 62 27 63 28 func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { ··· 74 39 } 75 40 76 41 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 77 - s.proxyRequest(w, r, targetURL, "application/x-git-upload-archive-result") 42 + s.proxyRequest(w, r, targetURL) 78 43 } 79 44 80 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { ··· 91 56 } 92 57 93 58 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 94 - s.proxyRequest(w, r, targetURL, "application/x-git-upload-pack-result") 59 + s.proxyRequest(w, r, targetURL) 95 60 } 96 61 97 62 func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { ··· 108 73 } 109 74 110 75 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 111 - s.proxyRequest(w, r, targetURL, "application/x-git-receive-pack-result") 76 + s.proxyRequest(w, r, targetURL) 112 77 } 113 78 114 - func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string, contentType string) { 79 + func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 115 80 client := &http.Client{} 116 81 82 + // Create new request 117 83 proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 118 84 if err != nil { 119 85 http.Error(w, err.Error(), http.StatusInternalServerError) 120 86 return 121 87 } 122 88 123 - proxyReq.Header = r.Header.Clone() 89 + // Copy original headers 90 + proxyReq.Header = r.Header 124 91 125 92 repoOwnerHandle := chi.URLParam(r, "user") 126 - proxyReq.Header.Set("x-tangled-repo-owner-handle", repoOwnerHandle) 93 + proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 127 94 95 + // Execute request 128 96 resp, err := client.Do(proxyReq) 129 97 if err != nil { 130 98 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 132 100 } 133 101 defer resp.Body.Close() 134 102 135 - // selectively copy only allowed headers 136 - copyAllowedHeaders(w.Header(), resp.Header) 137 - 138 - setGitHeaders(w, contentType) 103 + // Copy response headers 104 + maps.Copy(w.Header(), resp.Header) 139 105 106 + // Set response status code 140 107 w.WriteHeader(resp.StatusCode) 141 108 109 + // Copy response body 142 110 if _, err := io.Copy(w, resp.Body); err != nil { 143 111 http.Error(w, err.Error(), http.StatusInternalServerError) 144 112 return
+7 -74
appview/state/knotstream.go
··· 8 8 "slices" 9 9 "time" 10 10 11 - "tangled.org/core/appview/cloudflare" 12 11 "tangled.org/core/appview/notify" 13 12 14 13 "tangled.org/core/api/tangled" ··· 16 15 "tangled.org/core/appview/config" 17 16 "tangled.org/core/appview/db" 18 17 "tangled.org/core/appview/models" 19 - "tangled.org/core/appview/sites" 20 18 ec "tangled.org/core/eventconsumer" 21 19 "tangled.org/core/eventconsumer/cursor" 22 20 "tangled.org/core/log" ··· 29 27 "github.com/posthog/posthog-go" 30 28 ) 31 29 32 - func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, notifier notify.Notifier, cfClient *cloudflare.Client) (*ec.Consumer, error) { 30 + func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, notifier notify.Notifier) (*ec.Consumer, error) { 33 31 logger := log.FromContext(ctx) 34 32 logger = log.SubLogger(logger, "knotstream") 35 33 ··· 52 50 53 51 cfg := ec.ConsumerConfig{ 54 52 Sources: srcs, 55 - ProcessFunc: knotIngester(d, enforcer, posthog, notifier, c.Core.Dev, c, cfClient), 53 + ProcessFunc: knotIngester(d, enforcer, posthog, notifier, c.Core.Dev), 56 54 RetryInterval: c.Knotstream.RetryInterval, 57 55 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 58 56 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 66 64 return ec.NewConsumer(cfg), nil 67 65 } 68 66 69 - func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, notifier notify.Notifier, dev bool, c *config.Config, cfClient *cloudflare.Client) ec.ProcessFunc { 67 + func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, notifier notify.Notifier, dev bool) ec.ProcessFunc { 70 68 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 71 69 switch msg.Nsid { 72 70 case tangled.GitRefUpdateNSID: 73 - return ingestRefUpdate(ctx, d, enforcer, posthog, notifier, dev, c, cfClient, source, msg) 71 + return ingestRefUpdate(d, enforcer, posthog, notifier, dev, source, msg, ctx) 74 72 case tangled.PipelineNSID: 75 73 return ingestPipeline(d, source, msg) 76 74 } ··· 79 77 } 80 78 } 81 79 82 - func ingestRefUpdate(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, pc posthog.Client, notifier notify.Notifier, dev bool, c *config.Config, cfClient *cloudflare.Client, source ec.Source, msg ec.Message) error { 80 + func ingestRefUpdate(d *db.DB, enforcer *rbac.Enforcer, pc posthog.Client, notifier notify.Notifier, dev bool, source ec.Source, msg ec.Message, ctx context.Context) error { 83 81 logger := log.FromContext(ctx) 84 82 85 83 var record tangled.GitRefUpdate ··· 115 113 errWebhook = fmt.Errorf("failed to lookup repo for webhooks: %w", err) 116 114 } else if len(repos) == 1 { 117 115 notifier.Push(ctx, &repos[0], record.Ref, record.OldSha, record.NewSha, record.CommitterDid) 116 + } else if len(repos) == 0 { 117 + errWebhook = fmt.Errorf("no repo found for webhooks: %s/%s", record.RepoDid, record.RepoName) 118 118 } 119 119 120 120 errPunchcard := populatePunchcard(d, record) ··· 128 128 }) 129 129 } 130 130 131 - // Trigger a sites redeploy if this push is to the configured sites branch. 132 - if cfClient.Enabled() { 133 - go triggerSitesDeployIfNeeded(ctx, d, cfClient, c, record, source) 134 - } 135 - 136 131 return errors.Join(errWebhook, errPunchcard, errLanguages, errPosthog) 137 - } 138 - 139 - // triggerSitesDeployIfNeeded checks whether the pushed ref matches the sites 140 - // branch configured for this repo and, if so, syncs the site to R2 141 - func triggerSitesDeployIfNeeded(ctx context.Context, d *db.DB, cfClient *cloudflare.Client, c *config.Config, record tangled.GitRefUpdate, source ec.Source) { 142 - logger := log.FromContext(ctx) 143 - 144 - ref := plumbing.ReferenceName(record.Ref) 145 - if !ref.IsBranch() { 146 - return 147 - } 148 - pushedBranch := ref.Short() 149 - 150 - repos, err := db.GetRepos( 151 - d, 152 - 0, 153 - orm.FilterEq("did", record.RepoDid), 154 - orm.FilterEq("name", record.RepoName), 155 - ) 156 - if err != nil || len(repos) != 1 { 157 - return 158 - } 159 - repo := repos[0] 160 - 161 - siteConfig, err := db.GetRepoSiteConfig(d, repo.RepoAt().String()) 162 - if err != nil || siteConfig == nil { 163 - return 164 - } 165 - if siteConfig.Branch != pushedBranch { 166 - return 167 - } 168 - 169 - scheme := "https" 170 - if c.Core.Dev { 171 - scheme = "http" 172 - } 173 - knotHost := fmt.Sprintf("%s://%s", scheme, source.Key()) 174 - 175 - deploy := &models.SiteDeploy{ 176 - RepoAt: repo.RepoAt().String(), 177 - Branch: siteConfig.Branch, 178 - Dir: siteConfig.Dir, 179 - CommitSHA: record.NewSha, 180 - Trigger: models.SiteDeployTriggerPush, 181 - } 182 - 183 - deployErr := sites.Deploy(ctx, cfClient, knotHost, record.RepoDid, record.RepoName, siteConfig.Branch, siteConfig.Dir) 184 - if deployErr != nil { 185 - logger.Error("sites: R2 sync failed on push", "repo", record.RepoDid+"/"+record.RepoName, "err", deployErr) 186 - deploy.Status = models.SiteDeployStatusFailure 187 - deploy.Error = deployErr.Error() 188 - } else { 189 - deploy.Status = models.SiteDeployStatusSuccess 190 - } 191 - 192 - if err := db.AddSiteDeploy(d, deploy); err != nil { 193 - logger.Error("sites: failed to record deploy", "repo", record.RepoDid+"/"+record.RepoName, "err", err) 194 - } 195 - 196 - if deployErr == nil { 197 - logger.Info("site deployed to r2", "repo", record.RepoDid+"/"+record.RepoName) 198 - } 199 132 } 200 133 201 134 func populatePunchcard(d *db.DB, record tangled.GitRefUpdate) error {
-33
appview/state/login.go
··· 1 1 package state 2 2 3 3 import ( 4 - "errors" 5 4 "fmt" 6 5 "net/http" 7 6 "strings" 8 - "time" 9 7 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 8 "tangled.org/core/appview/oauth" 13 9 "tangled.org/core/appview/pages" 14 10 ) ··· 66 62 fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 67 63 ) 68 64 return 69 - } 70 - 71 - ident, err := s.idResolver.ResolveIdent(r.Context(), handle) 72 - if err != nil { 73 - l.Warn("handle resolution failed", "handle", handle, "err", err) 74 - s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) 75 - return 76 - } 77 - 78 - pdsEndpoint := ident.PDSEndpoint() 79 - if pdsEndpoint == "" { 80 - s.pages.Notice(w, "login-msg", fmt.Sprintf("No PDS found for \"%s\".", handle)) 81 - return 82 - } 83 - 84 - pdsClient := &xrpc.Client{Host: pdsEndpoint, Client: &http.Client{Timeout: 5 * time.Second}} 85 - _, err = comatproto.RepoDescribeRepo(r.Context(), pdsClient, ident.DID.String()) 86 - if err != nil { 87 - var xrpcErr *xrpc.Error 88 - var xrpcBody *xrpc.XRPCError 89 - isDeactivated := errors.As(err, &xrpcErr) && 90 - errors.As(xrpcErr.Wrapped, &xrpcBody) && 91 - xrpcBody.ErrStr == "RepoDeactivated" 92 - 93 - if !isDeactivated { 94 - l.Warn("describeRepo failed", "handle", handle, "did", ident.DID, "pds", pdsEndpoint, "err", err) 95 - s.pages.Notice(w, "login-msg", fmt.Sprintf("Account \"%s\" is no longer available.", handle)) 96 - return 97 - } 98 65 } 99 66 100 67 if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil {
+1 -5
appview/state/profile.go
··· 89 89 followStatus = db.GetFollowStatus(s.db, loggedInUser.Active.Did, did) 90 90 } 91 91 92 - var loggedInDid string 93 - if loggedInUser != nil { 94 - loggedInDid = loggedInUser.Did() 95 - } 96 - showPunchcard := s.shouldShowPunchcard(did, loggedInDid) 92 + showPunchcard := s.shouldShowPunchcard(did, loggedInUser.Did()) 97 93 98 94 var punchcard *models.Punchcard 99 95 if showPunchcard {
+4 -9
appview/state/router.go
··· 34 34 35 35 router.Get("/pwa-manifest.json", s.WebAppManifest) 36 36 router.Get("/robots.txt", s.RobotsTxt) 37 - router.Get("/.well-known/security.txt", s.SecurityTxt) 38 37 39 38 userRouter := s.UserRouter(&middleware) 40 39 standardRouter := s.StandardRouter(&middleware) ··· 121 120 r.Handle("/static/*", s.pages.Static()) 122 121 123 122 r.Get("/", s.HomeOrTimeline) 124 - r.Get("/home", s.Home) 125 123 r.Get("/timeline", s.Timeline) 126 124 r.Get("/upgradeBanner", s.UpgradeBanner) 127 125 ··· 211 209 212 210 func (s *State) SettingsRouter() http.Handler { 213 211 settings := &settings.Settings{ 214 - Db: s.db, 215 - OAuth: s.oauth, 216 - Pages: s.pages, 217 - Config: s.config, 218 - CfClient: s.cfClient, 219 - Logger: log.SubLogger(s.logger, "settings"), 212 + Db: s.db, 213 + OAuth: s.oauth, 214 + Pages: s.pages, 215 + Config: s.config, 220 216 } 221 217 222 218 return settings.Router() ··· 319 315 s.enforcer, 320 316 log.SubLogger(s.logger, "repo"), 321 317 s.validator, 322 - s.cfClient, 323 318 ) 324 319 return repo.Router(mw) 325 320 }
+86 -99
appview/state/state.go
··· 12 12 13 13 "tangled.org/core/api/tangled" 14 14 "tangled.org/core/appview" 15 - "tangled.org/core/appview/bsky" 16 - "tangled.org/core/appview/cloudflare" 17 15 "tangled.org/core/appview/config" 18 16 "tangled.org/core/appview/db" 19 17 "tangled.org/core/appview/indexer" ··· 27 25 "tangled.org/core/appview/reporesolver" 28 26 "tangled.org/core/appview/validator" 29 27 xrpcclient "tangled.org/core/appview/xrpcclient" 30 - "tangled.org/core/consts" 31 28 "tangled.org/core/eventconsumer" 32 29 "tangled.org/core/idresolver" 33 30 "tangled.org/core/jetstream" ··· 41 38 atpclient "github.com/bluesky-social/indigo/atproto/client" 42 39 "github.com/bluesky-social/indigo/atproto/syntax" 43 40 lexutil "github.com/bluesky-social/indigo/lex/util" 44 - "github.com/bluesky-social/indigo/xrpc" 45 41 securejoin "github.com/cyphar/filepath-securejoin" 46 42 "github.com/go-chi/chi/v5" 47 43 "github.com/posthog/posthog-go" ··· 64 60 spindlestream *eventconsumer.Consumer 65 61 logger *slog.Logger 66 62 validator *validator.Validator 67 - cfClient *cloudflare.Client 68 63 } 69 64 70 65 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 118 113 tangled.PublicKeyNSID, 119 114 tangled.RepoArtifactNSID, 120 115 tangled.ActorProfileNSID, 121 - tangled.KnotMemberNSID, 122 116 tangled.SpindleMemberNSID, 123 117 tangled.SpindleNSID, 124 118 tangled.StringNSID, ··· 174 168 notifier := notify.NewMergedNotifier(notifiers) 175 169 notifier = notify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 176 170 177 - var cfClient *cloudflare.Client 178 - if config.Cloudflare.ApiToken != "" { 179 - cfClient, err = cloudflare.New(config) 180 - if err != nil { 181 - logger.Warn("failed to create cloudflare client, sites upload will be disabled", "err", err) 182 - cfClient = nil 183 - } 184 - } 185 - 186 - knotstream, err := Knotstream(ctx, config, d, enforcer, posthog, notifier, cfClient) 171 + knotstream, err := Knotstream(ctx, config, d, enforcer, posthog, notifier) 187 172 if err != nil { 188 173 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err) 189 174 } ··· 196 181 spindlestream.Start(ctx) 197 182 198 183 state := &State{ 199 - db: d, 200 - notifier: notifier, 201 - indexer: indexer, 202 - oauth: oauth, 203 - enforcer: enforcer, 204 - pages: pages, 205 - idResolver: res, 206 - mentionsResolver: mentionsResolver, 207 - posthog: posthog, 208 - jc: jc, 209 - config: config, 210 - repoResolver: repoResolver, 211 - knotstream: knotstream, 212 - spindlestream: spindlestream, 213 - logger: logger, 214 - validator: validator, 215 - cfClient: cfClient, 184 + d, 185 + notifier, 186 + indexer, 187 + oauth, 188 + enforcer, 189 + pages, 190 + res, 191 + mentionsResolver, 192 + posthog, 193 + jc, 194 + config, 195 + repoResolver, 196 + knotstream, 197 + spindlestream, 198 + logger, 199 + validator, 216 200 } 217 201 218 - // fetch initial bluesky posts if configured 219 - go fetchBskyPosts(ctx, res, config, d, logger) 220 - 221 202 return state, nil 222 203 } 223 204 224 205 func (s *State) Close() error { 225 206 // other close up logic goes here 226 207 return s.db.Close() 227 - } 228 - 229 - func (s *State) SecurityTxt(w http.ResponseWriter, r *http.Request) { 230 - w.Header().Set("Content-Type", "text/plain") 231 - w.Header().Set("Cache-Control", "public, max-age=86400") // one day 232 - 233 - securityTxt := `Contact: mailto:security@tangled.org 234 - Preferred-Languages: en 235 - Canonical: https://tangled.org/.well-known/security.txt 236 - Expires: 2030-01-01T21:59:00.000Z 237 - ` 238 - w.Write([]byte(securityTxt)) 239 208 } 240 209 241 210 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { ··· 276 245 }) 277 246 } 278 247 248 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 249 + if s.oauth.GetMultiAccountUser(r) != nil { 250 + s.Timeline(w, r) 251 + return 252 + } 253 + s.Home(w, r) 254 + } 255 + 256 + func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 257 + user := s.oauth.GetMultiAccountUser(r) 258 + 259 + // TODO: set this flag based on the UI 260 + filtered := false 261 + 262 + var userDid string 263 + if user != nil && user.Active != nil { 264 + userDid = user.Active.Did 265 + } 266 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 267 + if err != nil { 268 + s.logger.Error("failed to make timeline", "err", err) 269 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 270 + } 271 + 272 + repos, err := db.GetTopStarredReposLastWeek(s.db) 273 + if err != nil { 274 + s.logger.Error("failed to get top starred repos", "err", err) 275 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 276 + return 277 + } 278 + 279 + gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 280 + if err != nil { 281 + // non-fatal 282 + } 283 + 284 + s.pages.Timeline(w, pages.TimelineParams{ 285 + LoggedInUser: user, 286 + Timeline: timeline, 287 + Repos: repos, 288 + GfiLabel: gfiLabel, 289 + }) 290 + } 291 + 279 292 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 280 293 user := s.oauth.GetMultiAccountUser(r) 281 294 if user == nil { ··· 310 323 s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 311 324 Registrations: regs, 312 325 Spindles: spindles, 326 + }) 327 + } 328 + 329 + func (s *State) Home(w http.ResponseWriter, r *http.Request) { 330 + // TODO: set this flag based on the UI 331 + filtered := false 332 + 333 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 334 + if err != nil { 335 + s.logger.Error("failed to make timeline", "err", err) 336 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 337 + return 338 + } 339 + 340 + repos, err := db.GetTopStarredReposLastWeek(s.db) 341 + if err != nil { 342 + s.logger.Error("failed to get top starred repos", "err", err) 343 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 344 + return 345 + } 346 + 347 + s.pages.Home(w, pages.TimelineParams{ 348 + LoggedInUser: nil, 349 + Timeline: timeline, 350 + Repos: repos, 313 351 }) 314 352 } 315 353 ··· 632 670 633 671 return nil 634 672 } 635 - 636 - func fetchBskyPosts(ctx context.Context, res *idresolver.Resolver, config *config.Config, d *db.DB, logger *slog.Logger) { 637 - resolved, err := res.ResolveIdent(context.Background(), consts.TangledDid) 638 - if err != nil { 639 - logger.Error("failed to resolve tangled.org DID", "err", err) 640 - return 641 - } 642 - 643 - pdsEndpoint := resolved.PDSEndpoint() 644 - if pdsEndpoint == "" { 645 - logger.Error("no PDS endpoint found for tangled.sh DID") 646 - return 647 - } 648 - 649 - session, err := oauth.CreateAppPasswordSession(res, config.Core.AppPassword, consts.TangledDid, logger) 650 - if err != nil { 651 - logger.Error("failed to create appassword session... skipping fetch", "err", err) 652 - return 653 - } 654 - 655 - client := xrpc.Client{ 656 - Auth: &xrpc.AuthInfo{ 657 - AccessJwt: session.AccessJwt, 658 - Did: session.Did, 659 - }, 660 - Host: session.PdsEndpoint, 661 - } 662 - 663 - l := log.SubLogger(logger, "bluesky") 664 - 665 - ticker := time.NewTicker(config.Bluesky.UpdateInterval) 666 - defer ticker.Stop() 667 - 668 - for { 669 - posts, _, err := bsky.FetchPosts(ctx, &client, 20, "") 670 - if err != nil { 671 - l.Error("failed to fetch bluesky posts", "err", err) 672 - } else if err := db.InsertBlueskyPosts(d, posts); err != nil { 673 - l.Error("failed to insert bluesky posts", "err", err) 674 - } else { 675 - l.Info("inserted bluesky posts", "count", len(posts)) 676 - } 677 - 678 - select { 679 - case <-ticker.C: 680 - case <-ctx.Done(): 681 - l.Info("stopping bluesky updater") 682 - return 683 - } 684 - } 685 - }
-77
appview/state/timeline.go
··· 1 - package state 2 - 3 - import ( 4 - "net/http" 5 - 6 - "tangled.org/core/appview/db" 7 - "tangled.org/core/appview/pages" 8 - "tangled.org/core/orm" 9 - ) 10 - 11 - func (s *State) Home(w http.ResponseWriter, r *http.Request) { 12 - // TODO: set this flag based on the UI 13 - filtered := false 14 - 15 - user := s.oauth.GetMultiAccountUser(r) 16 - 17 - timeline, err := db.MakeTimeline(s.db, 50, "", filtered) 18 - if err != nil { 19 - s.logger.Error("failed to make timeline", "err", err) 20 - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 21 - return 22 - } 23 - 24 - blueskyPosts, err := db.GetBlueskyPosts(s.db, 8) 25 - if err != nil { 26 - s.logger.Error("failed to get bluesky posts", "err", err) 27 - } 28 - 29 - s.pages.Home(w, pages.TimelineParams{ 30 - LoggedInUser: user, 31 - Timeline: timeline, 32 - BlueskyPosts: blueskyPosts, 33 - }) 34 - } 35 - func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 36 - if s.oauth.GetMultiAccountUser(r) != nil { 37 - s.Timeline(w, r) 38 - return 39 - } 40 - s.Home(w, r) 41 - } 42 - 43 - func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 44 - user := s.oauth.GetMultiAccountUser(r) 45 - 46 - // TODO: set this flag based on the UI 47 - filtered := false 48 - 49 - var userDid string 50 - if user != nil && user.Active != nil { 51 - userDid = user.Active.Did 52 - } 53 - timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 54 - if err != nil { 55 - s.logger.Error("failed to make timeline", "err", err) 56 - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 57 - } 58 - 59 - repos, err := db.GetTopStarredReposLastWeek(s.db) 60 - if err != nil { 61 - s.logger.Error("failed to get top starred repos", "err", err) 62 - s.pages.Notice(w, "topstarredrepos", "Unable to load.") 63 - return 64 - } 65 - 66 - gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 67 - if err != nil { 68 - // non-fatal 69 - } 70 - 71 - s.pages.Timeline(w, pages.TimelineParams{ 72 - LoggedInUser: user, 73 - Timeline: timeline, 74 - Repos: repos, 75 - GfiLabel: gfiLabel, 76 - }) 77 - }
-16
appview/strings/strings.go
··· 407 407 return 408 408 } 409 409 410 - client, err := s.OAuth.AuthorizedClient(r) 411 - if err != nil { 412 - fail("Failed to authorize client.", err) 413 - return 414 - } 415 - 416 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 417 - Collection: tangled.StringNSID, 418 - Repo: user.Active.Did, 419 - Rkey: rkey, 420 - }) 421 - if err != nil { 422 - fail("Failed to delete string record from PDS.", err) 423 - return 424 - } 425 - 426 410 if err := db.DeleteString( 427 411 s.Db, 428 412 orm.FilterEq("did", user.Active.Did),
+20 -17
avatar/src/index.js
··· 150 150 151 151 const size = searchParams.get("size"); 152 152 const resizeToTiny = size === "tiny"; 153 + const format = searchParams.get("format") || "webp"; 154 + const validFormats = ["webp", "jpeg", "png"]; 155 + const outputFormat = validFormats.includes(format) ? format : "webp"; 156 + 157 + const contentTypes = { 158 + webp: "image/webp", 159 + jpeg: "image/jpeg", 160 + png: "image/png", 161 + }; 153 162 154 163 const cache = caches.default; 155 164 let cacheKey = request.url; ··· 242 251 243 252 // Fetch and optionally resize the avatar 244 253 let avatarResponse; 245 - if (resizeToTiny) { 246 - avatarResponse = await fetch(avatarUrl, { 247 - // cf: { 248 - // image: { 249 - // width: 32, 250 - // height: 32, 251 - // fit: "cover", 252 - // format: "webp", 253 - // }, 254 - // }, 255 - }); 256 - } else { 257 - avatarResponse = await fetch(avatarUrl); 258 - } 254 + const cfOptions = outputFormat !== "webp" || resizeToTiny ? { 255 + cf: { 256 + image: { 257 + format: outputFormat, 258 + ...(resizeToTiny ? { width: 32, height: 32, fit: "cover" } : {}), 259 + }, 260 + }, 261 + }: {}; 262 + 263 + avatarResponse = await fetch(avatarUrl, cfOptions); 259 264 260 265 if (!avatarResponse.ok) { 261 266 return new Response(`failed to fetch avatar for ${actor}.`, { ··· 264 269 } 265 270 266 271 const avatarData = await avatarResponse.arrayBuffer(); 267 - const contentType = 268 - avatarResponse.headers.get("content-type") || "image/jpeg"; 269 272 270 273 response = new Response(avatarData, { 271 274 headers: { 272 - "Content-Type": contentType, 275 + "Content-Type": contentTypes[outputFormat], 273 276 "Cache-Control": "public, max-age=43200", 274 277 }, 275 278 });
-174
blog/blog.go
··· 1 - package blog 2 - 3 - import ( 4 - "bytes" 5 - "cmp" 6 - "html/template" 7 - "io" 8 - "io/fs" 9 - "os" 10 - "slices" 11 - "strings" 12 - "time" 13 - 14 - "github.com/adrg/frontmatter" 15 - "github.com/gorilla/feeds" 16 - 17 - "tangled.org/core/appview/pages" 18 - "tangled.org/core/appview/pages/markup" 19 - textension "tangled.org/core/appview/pages/markup/extension" 20 - ) 21 - 22 - type Author struct { 23 - Name string `yaml:"name"` 24 - Email string `yaml:"email"` 25 - Handle string `yaml:"handle"` 26 - } 27 - 28 - type PostMeta struct { 29 - Slug string `yaml:"slug"` 30 - Title string `yaml:"title"` 31 - Subtitle string `yaml:"subtitle"` 32 - Date string `yaml:"date"` 33 - Authors []Author `yaml:"authors"` 34 - Image string `yaml:"image"` 35 - Draft bool `yaml:"draft"` 36 - } 37 - 38 - type Post struct { 39 - Meta PostMeta 40 - Body template.HTML 41 - } 42 - 43 - func (p Post) ParsedDate() time.Time { 44 - t, _ := time.Parse("2006-01-02", p.Meta.Date) 45 - return t 46 - } 47 - 48 - type indexParams struct { 49 - LoggedInUser any 50 - Posts []Post 51 - Featured []Post 52 - } 53 - 54 - type postParams struct { 55 - LoggedInUser any 56 - Post Post 57 - } 58 - 59 - // Posts parses and returns all non-draft posts sorted newest-first. 60 - func Posts(postsDir string) ([]Post, error) { 61 - return parsePosts(postsDir, false) 62 - } 63 - 64 - // AllPosts parses and returns all posts including drafts, sorted newest-first. 65 - func AllPosts(postsDir string) ([]Post, error) { 66 - return parsePosts(postsDir, true) 67 - } 68 - 69 - func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) { 70 - fsys := os.DirFS(postsDir) 71 - 72 - entries, err := fs.ReadDir(fsys, ".") 73 - if err != nil { 74 - return nil, err 75 - } 76 - 77 - rctx := &markup.RenderContext{ 78 - RendererType: markup.RendererTypeDefault, 79 - Sanitizer: markup.NewSanitizer(), 80 - } 81 - var posts []Post 82 - for _, entry := range entries { 83 - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { 84 - continue 85 - } 86 - 87 - data, err := fs.ReadFile(fsys, entry.Name()) 88 - if err != nil { 89 - return nil, err 90 - } 91 - 92 - var meta PostMeta 93 - rest, err := frontmatter.Parse(bytes.NewReader(data), &meta) 94 - if err != nil { 95 - return nil, err 96 - } 97 - 98 - if meta.Draft && !includeDrafts { 99 - continue 100 - } 101 - 102 - htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes)) 103 - sanitized := rctx.SanitizeDefault(htmlStr) 104 - 105 - posts = append(posts, Post{ 106 - Meta: meta, 107 - Body: template.HTML(sanitized), 108 - }) 109 - } 110 - 111 - slices.SortFunc(posts, func(a, b Post) int { 112 - return cmp.Compare(b.Meta.Date, a.Meta.Date) 113 - }) 114 - 115 - return posts, nil 116 - } 117 - 118 - func AtomFeed(posts []Post, baseURL string) (string, error) { 119 - feed := &feeds.Feed{ 120 - Title: "the tangled blog", 121 - Link: &feeds.Link{Href: baseURL}, 122 - Author: &feeds.Author{Name: "Tangled"}, 123 - Created: time.Now(), 124 - } 125 - 126 - for _, p := range posts { 127 - postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug 128 - 129 - var authorName string 130 - for i, a := range p.Meta.Authors { 131 - if i > 0 { 132 - authorName += " & " 133 - } 134 - authorName += a.Name 135 - } 136 - 137 - feed.Items = append(feed.Items, &feeds.Item{ 138 - Title: p.Meta.Title, 139 - Link: &feeds.Link{Href: postURL}, 140 - Description: p.Meta.Subtitle, 141 - Author: &feeds.Author{Name: authorName}, 142 - Created: p.ParsedDate(), 143 - }) 144 - } 145 - 146 - return feed.ToAtom() 147 - } 148 - 149 - // RenderIndex renders the blog index page to w. 150 - func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error { 151 - tpl, err := p.ParseWith(os.DirFS(templatesDir), "index.html") 152 - if err != nil { 153 - return err 154 - } 155 - var featured []Post 156 - for _, post := range posts { 157 - if post.Meta.Image != "" { 158 - featured = append(featured, post) 159 - if len(featured) == 3 { 160 - break 161 - } 162 - } 163 - } 164 - return tpl.ExecuteTemplate(w, "layouts/base", indexParams{Posts: posts, Featured: featured}) 165 - } 166 - 167 - // RenderPost renders a single blog post page to w. 168 - func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error { 169 - tpl, err := p.ParseWith(os.DirFS(templatesDir), "post.html") 170 - if err != nil { 171 - return err 172 - } 173 - return tpl.ExecuteTemplate(w, "layouts/base", postParams{Post: post}) 174 - }
-12
blog/config.yaml
··· 1 - preBuild: 2 - - tailwindcss -i ../input.css -o static/tw.css 3 - title: the tangled blog 4 - # note the trailing slash! 5 - url: "https://blog.tangled.org/" 6 - description: "" 7 - author: 8 - name: "Anirudh Oppiliappan" 9 - email: "anirudh@tangled.sh" 10 - defaultTemplate: text.html 11 - extraTemplateDirs: 12 - - ../appview/pages/templates
-160
blog/posts/6-months.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: 6-months 5 - title: 6 months of Tangled 6 - subtitle: a quick recap, and notes on the future 7 - date: 2025-10-21 8 - image: https://assets.tangled.network/blog/6-months.png 9 - authors: 10 - - name: Anirudh 11 - email: anirudh@tangled.org 12 - handle: anirudh.fi 13 - - name: Akshay 14 - email: akshay@tangled.org 15 - handle: oppi.li 16 - draft: false 17 - --- 18 - 19 - Hello Tanglers! It's been over 6 months since we first announced 20 - Tangled, so we figured we'd do a quick retrospective of what we built so 21 - far and what's next. 22 - 23 - If you're new here, here's a quick overview: Tangled is a git hosting 24 - and collaboration platform built on top of the [AT 25 - Protocol](https://atproto.com). You can read a bit more about our 26 - architecture [here](/intro). 27 - 28 - ## new logo and mascot: dolly! 29 - 30 - Tangled finally has a logo! Designed by Akshay himself, Dolly is in 31 - reference to the first ever *cloned* mammal. For a full set of brand assets and guidelines, see our new [branding page](https://tangled.org/brand). 32 - 33 - ![logo with text](https://assets.tangled.network/blog/logo_with_text.jpeg) 34 - 35 - With that, let's recap the major platform improvements so far! 36 - 37 - ## pull requests: doubling down on jujutsu 38 - 39 - One of the first major features we built was our [pull requests 40 - system](/pulls), which follows a unique round-based submission & review 41 - approach. This was really fun to innovate on -- it remains one of 42 - Tangled's core differentiators, and one we plan to keep improving. 43 - 44 - In the same vein, we're the first ever code forge to support [stacking 45 - pull requests](/stacking) using Jujutsu! We're big fans of the tool and 46 - we use it everyday as we hack on 47 - [tangled.org/core](https://tangled.org/@tangled.org/core). 48 - 49 - Ultimately, we think PR-based collaboration should evolve beyond the 50 - traditional model, and we're excited to keep experimenting with new 51 - ideas that make code review and contribution easier! 52 - 53 - ## spindle 54 - 55 - CI was our most requested feature, and we spent a *lot* of time debating 56 - how to approach it. We considered integrating with existing platforms, 57 - but none were good fits. So we gave in to NIH and [built spindle 58 - ourselves](/ci)! This allowed us to go in on Nix using Nixery to build 59 - CI images on the fly and cache them. 60 - 61 - Spindle is still early but designed to be extensible and is AT-native. 62 - The current Docker/Nixery-based engine is limiting -- we plan to switch 63 - to micro VMs down the line to run full-fledged NixOS (and other base 64 - images). Meanwhile, if you've got ideas for other spindle backends 65 - (Kubernetes?!), we'd love to [hear from you](https://chat.tangled.org). 66 - 67 - ## XRPC APIs 68 - 69 - We introduced a complete migration of the knotserver to an 70 - [XRPC](https://atproto.com/specs/xrpc) API. Alongside this, we also 71 - decoupled the knot from the appview by getting rid of the registration 72 - secret, which was centralizing. Knots (and spindles) simply declare 73 - their owner, and any appview can verify ownership. Once we stabilize the 74 - [lexicon definitions](lexicons) for these XRPC calls, building clients 75 - for knots, or alternate implementations should become much simpler. 76 - 77 - [lexicons]: https://tangled.sh/@tangled.sh/core/tree/master/lexicons 78 - 79 - ## issues rework 80 - 81 - Issues got a major rework (and facelift) too! They are now threaded: 82 - top-level comments with replies. This makes Q/A style discussions much 83 - easier to follow! 84 - 85 - ![issue thread](https://assets.tangled.network/blog/issue-threading.webp) 86 - 87 - ## hosted PDS 88 - 89 - A complaint we often recieved was the need for a Bluesky account to use 90 - Tangled; and besides, we realised that the overlap between Bluesky users 91 - and possible Tangled users only goes so far -- we aim to be a generic 92 - code forge after all, AT just happens to be an implementation 93 - detail. 94 - 95 - To address this, we spun up the tngl.sh PDS hosted right here in 96 - Finland. The only way to get an account on this PDS is by [signing 97 - up](https://tangled.sh/signup). There's a lot we can do to improve this 98 - experience as a generic PDS host, but we're still working out details 99 - around that. 100 - 101 - ## labels 102 - 103 - You can easily categorize issues and pulls via labels! There is plenty 104 - of customization available: 105 - 106 - - labels can be basic, or they can have a key and value set, for example: 107 - `wontfix` or `priority/high` 108 - - labels can be constrained to a set of values: `priority: [high medium low]` 109 - - there can be multiple labels of a given type: `reviewed-by: @oppi.li`, 110 - `reviewed-by: @anirudh.fi` 111 - 112 - The options are endless! You can access them via your repo's settings page. 113 - 114 - <div class="flex justify-center items-center gap-2"> 115 - <figure class="w-full m-0 flex flex-col items-center"> 116 - <a href="https://assets.tangled.network/blog/labels_vignette.webp"> 117 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/labels_vignette.webp" alt="A set of labels applied to an issue."> 118 - </a> 119 - <figcaption class="text-center">A set of labels applied to an issue.</figcaption> 120 - </figure> 121 - 122 - <figure class="w-1/3 m-0 flex flex-col items-center"> 123 - <a href="https://assets.tangled.network/blog/new_label_modal.png"> 124 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/new_label_modal.png" alt="Create custom key-value type labels."> 125 - </a> 126 - <figcaption class="text-center">Create custom key-value type labels.</figcaption> 127 - </figure> 128 - </div> 129 - 130 - 131 - ## notifications 132 - 133 - In-app notifications now exist! You get notifications for a variety of events now: 134 - 135 - * new issues/pulls on your repos (also for collaborators) 136 - * comments on your issues/pulls (also for collaborators) 137 - * close/reopen (or merge) of issues/pulls 138 - * new stars 139 - * new follows 140 - 141 - All of this can be fine-tuned in [/settings/notifications](https://tangled.org/settings/notifications). 142 - 143 - ![notifications](https://assets.tangled.network/blog/notifications.png) 144 - 145 - 146 - ## the future 147 - 148 - We're working on a *lot* of exciting new things and possibly some big 149 - announcements to come. Be on the lookout for: 150 - 151 - * email notifications 152 - * preliminary support for issue and PR search 153 - * total "atprotation" [^1] -- the last two holdouts here are repo and pull records 154 - * total federation -- i.e. supporting third-party appviews by making it 155 - reproducible 156 - * achieve complete independence from Bluesky PBC by hosting our own relay 157 - 158 - That's all for now; we'll see you in the atmosphere! Meanwhile, if you'd like to contribute to projects on Tangled, make sure to check out the [good first issues page](https://tangled.org/goodfirstissues) to get started! 159 - 160 - [^1]: atprotation implies a two-way sync between the PDS and appview. Currently, pull requests and repositories are not ingested -- so writing/updating either records on your PDS will not show up on the appview.
-214
blog/posts/ci.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: ci 5 - title: introducing spindle 6 - subtitle: tangled's new CI runner is now generally available 7 - date: 2025-08-06 8 - authors: 9 - - name: Anirudh 10 - email: anirudh@tangled.sh 11 - handle: anirudh.fi 12 - - name: Akshay 13 - email: akshay@tangled.sh 14 - handle: oppi.li 15 - --- 16 - 17 - Since launching Tangled, continuous integration has 18 - consistently topped our feature request list. Today, CI is 19 - no longer a wishlist item, but a fully-featured reality. 20 - 21 - Meet **spindle**: Tangled's new CI runner built atop Nix and 22 - AT Protocol. In typical Tangled fashion we've been 23 - dogfooding spindle for a while now; this very blog post 24 - you're reading was [built and published using 25 - spindle](https://tangled.sh/@tangled.sh/site/pipelines/452/workflow/deploy.yaml). 26 - 27 - Tangled is a new social-enabled Git collaboration platform, 28 - [read our intro](/intro) for more about the project. 29 - 30 - ![spindle architecture](https://assets.tangled.network/blog/spindle-arch.png) 31 - 32 - ## how spindle works 33 - 34 - Spindle is designed around simplicity and the decentralized 35 - nature of the AT Protocol. In ingests "pipeline" records and 36 - emits job status updates. 37 - 38 - When you push code or open a pull request, the knot hosting 39 - your repository emits a pipeline event 40 - (`sh.tangled.pipeline`). Running as a dedicated service, 41 - spindle subscribes to these events via websocket connections 42 - to your knot. 43 - 44 - Once triggered, spindle reads your pipeline manifest, spins 45 - up the necessary execution environment (covered below), and 46 - runs your defined workflow steps. Throughout execution, it 47 - streams real-time logs and status updates 48 - (`sh.tangled.pipeline.status`) back through websockets, 49 - which the Tangled appview subscribes to for live updates. 50 - 51 - Over at the appview, these updates are ingested and stored, 52 - and logs are streamed live. 53 - 54 - ## spindle pipelines 55 - 56 - The pipeline manifest is defined in YAML, and should be 57 - relatively familiar to those that have used other CI 58 - solutions. Here's a minimal example: 59 - 60 - ```yaml 61 - # test.yaml 62 - 63 - when: 64 - - event: ["push", "pull_request"] 65 - branch: ["master"] 66 - 67 - dependencies: 68 - nixpkgs: 69 - - go 70 - 71 - steps: 72 - - name: run all tests 73 - environment: 74 - CGO_ENABLED: 1 75 - command: | 76 - go test -v ./... 77 - ``` 78 - 79 - You can read the [full manifest spec 80 - here](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/pipeline.md), 81 - but the `dependencies` block is the real interesting bit. 82 - Dependencies for your workflow, like Go, Node.js, Python 83 - etc. can be pulled in from nixpkgs. 84 - [Nixpkgs](https://github.com/nixos/nixpkgs/) -- for the 85 - uninitiated -- is a vast collection of packages for the Nix 86 - package manager. Fortunately, you needn't know nor care 87 - about Nix to use it! Just head to https://search.nixos.org 88 - to find your package of choice (I'll bet 1€ that it's 89 - there[^1]), toss it in the list and run your build. The 90 - Nix-savvy of you lot will be happy to know that you can use 91 - custom registries too. 92 - 93 - [^1]: I mean, if it isn't there, it's nowhere. 94 - 95 - Workflow manifests are intentionally simple. We do not want 96 - to include a "marketplace" of workflows or complex job 97 - orchestration. The bulk of the work should be offloaded to a 98 - build system, and CI should be used simply for finishing 99 - touches. That being said, this is still the first revision 100 - for CI, there is a lot more on the roadmap! 101 - 102 - Let's take a look at how spindle executes workflow steps. 103 - 104 - ## workflow execution 105 - 106 - At present, the spindle "engine" supports just the Docker 107 - backend[^2]. Podman is known to work with the Docker socket 108 - feature enabled. Each step is run in a separate container, 109 - with the `/tangled/workspace` and `/nix` volumes persisted 110 - across steps. 111 - 112 - [^2]: Support for additional backends like Firecracker are 113 - planned. Contributions welcome! 114 - 115 - The container image is built using 116 - [Nixery](https://nixery.dev). Nixery is a nifty little tool 117 - that takes a path-separated set of Nix packages and returns 118 - an OCI image with each package in a separate layer. Try this 119 - in your terminal if you've got Docker installed: 120 - 121 - ``` 122 - docker run nixery.dev/bash/hello-go hello-go 123 - ``` 124 - 125 - This should output `Hello, world!`. This is running the 126 - [hello-go](https://search.nixos.org/packages?channel=25.05&show=hello-go) 127 - package from nixpkgs. 128 - 129 - Nixery is super handy since we can construct these images 130 - for CI environments on the fly, with all dependencies baked 131 - in, and the best part: caching for commonly used packages is 132 - free thanks to Docker (pre-existing layers get reused). We 133 - run a Nixery instance of our own at 134 - https://nixery.tangled.sh but you may override that if you 135 - choose to. 136 - 137 - ## debugging CI 138 - 139 - We understand that debugging CI can be the worst. There are 140 - two parts to this problem: 141 - 142 - - CI services often bring their own workflow definition 143 - formats and it can sometimes be difficult to know why the 144 - workflow won't run or why the workflow definition is 145 - incorrect 146 - - The CI job itself fails, but this has more to do with the 147 - build system of choice 148 - 149 - To mend the first problem: we are making use of git 150 - [push-options](https://git-scm.com/docs/git-push#Documentation/git-push.txt--ooption). 151 - When you push to a repository with an option like so: 152 - 153 - ``` 154 - git push origin master -o verbose-ci 155 - ``` 156 - 157 - The server runs a basic set of analysis rules on your 158 - workflow file, and reports any errors: 159 - 160 - ``` 161 - λ git push origin main -o verbose-ci 162 - . 163 - . 164 - . 165 - . 166 - remote: error: failed to parse workflow(s): 167 - remote: - at .tangled/workflows/fmt.yml: yaml: line 14: did not find expected key 168 - remote: 169 - remote: warning(s) on pipeline: 170 - remote: - at build.yml: workflow skipped: did not match trigger push 171 - ``` 172 - 173 - The analysis performed at the moment is quite basic (expect 174 - it to get better over time), but it is already quite useful 175 - to help debug workflows that don't trigger! 176 - 177 - ## pipeline secrets 178 - 179 - Secrets are a bit tricky since atproto has no notion of 180 - private data. Secrets are instead written directly from the 181 - appview to the spindle instance using [service 182 - auth](https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth). 183 - In essence, the appview makes a signed request using the 184 - logged-in user's DID key; spindle verifies this signature by 185 - fetching the public key from the DID document. 186 - 187 - ![pipeline secrets](https://assets.tangled.network/blog/pipeline-secrets.png) 188 - 189 - The secrets themselves are stored in a secret manager. By 190 - default, this is the same sqlite database that spindle uses. 191 - This is *fine* for self-hosters. The hosted, flagship 192 - instance at https://spindle.tangled.sh however uses 193 - [OpenBao](https://openbao.org), an OSS fork of HashiCorp 194 - Vault. 195 - 196 - ## get started now 197 - 198 - You can run your own spindle instance pretty easily: the 199 - [spindle self-hosting 200 - guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md) 201 - should have you covered. Once done, head to your 202 - repository's settings tab and set it up! Doesn't work? Feel 203 - free to pop into [Discord](https://chat.tangled.sh) to get 204 - help -- we have a nice little crew that's always around to 205 - help. 206 - 207 - All Tangled users have access to our hosted spindle 208 - instance, free of charge[^3]. You don't have any more 209 - excuses to not migrate to Tangled now -- [get 210 - started](https://tangled.sh/login) with your AT Protocol 211 - account today. 212 - 213 - [^3]: We can't promise we won't charge for it at some point 214 - but there will always be a free tier.
-241
blog/posts/docs.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: docs 5 - title: we rolled our own documentation site 6 - subtitle: you don't need mintlify 7 - date: 2026-01-12 8 - authors: 9 - - name: Akshay 10 - email: akshay@tangled.org 11 - handle: oppi.li 12 - draft: false 13 - --- 14 - 15 - We recently organized our documentation and put it up on 16 - https://docs.tangled.org, using just pandoc. For several 17 - reasons, using pandoc to roll your own static sites is more 18 - than sufficient for small projects. 19 - 20 - ![docs.tangled.org](https://assets.tangled.network/blog/docs_homepage.png) 21 - 22 - ## requirements 23 - 24 - - Lives in [our 25 - monorepo](https://tangled.org/tangled.org/core). 26 - - No JS: a collection of pages containing just text 27 - should not require JS to view! 28 - - Searchability: in practice, documentation engines that 29 - come bundled with a search-engine have always been lack 30 - lustre. I tend to Ctrl+F or use an actual search engine in 31 - most scenarios. 32 - - Low complexity: building, testing, deploying should be 33 - easy. 34 - - Easy to style 35 - 36 - ## evaluating the ecosystem 37 - 38 - I took the time to evaluate several documentation engine 39 - solutions: 40 - 41 - - [Mintlify](https://www.mintlify.com/): It is quite obvious 42 - from their homepage that mintlify is performing an AI 43 - pivot for the sake of doing so. 44 - - [Docusaurus](https://docusaurus.io/): The generated 45 - documentation site is quite nice, but the value of pages 46 - being served as a full-blown React SPA is questionable. 47 - - [MkDocs](https://www.mkdocs.org/): Works great with JS 48 - disabled, however the table of contents needs to be 49 - maintained via `mkdocs.yml`, which can be quite tedious. 50 - - [MdBook](https://rust-lang.github.io/mdBook/index.html): 51 - As above, you need a `SUMMARY.md` file to control the 52 - table-of-contents. 53 - 54 - MkDocs and MdBook are still on my radar however, in case we 55 - need a bigger feature set. 56 - 57 - ## using pandoc 58 - 59 - [pandoc](https://pandoc.org/) is a wonderfully customizable 60 - markup converter. It provides a "chunkedhtml" output format, 61 - which is perfect for generating documentation sites. Without 62 - any customization, 63 - [this](https://pandoc.org/demo/example33/) is the generated 64 - output, for this [markdown file 65 - input](https://pandoc.org/demo/MANUAL.txt). 66 - 67 - - You get an autogenerated TOC based on the document layout 68 - - Each section is turned into a page of its own 69 - 70 - Massaging pandoc to work for us was quite straightforward: 71 - 72 - - I first combined all our individual markdown files into 73 - [one big 74 - `DOCS.md`](https://tangled.org/tangled.org/core/blob/master/docs/DOCS.md) 75 - file. 76 - - Modified the [default 77 - template](https://github.com/jgm/pandoc-templates/blob/master/default.chunkedhtml) 78 - to put the TOC on every page, to form a "sidebar", see 79 - [`docs/template.html`](https://tangled.org/tangled.org/core/blob/master/docs/template.html) 80 - - Inserted tailwind `prose` classes where necessary, such 81 - that markdown content is rendered the same way between 82 - `tangled.org` and `docs.tangled.org` 83 - 84 - Generating the docs is done with one pandoc command: 85 - 86 - ```bash 87 - pandoc docs/DOCS.md \ 88 - -o out/ \ 89 - -t chunkedhtml \ 90 - --variable toc \ 91 - --toc-depth=2 \ 92 - --css=docs/stylesheet.css \ 93 - --chunk-template="%i.html" \ 94 - --highlight-style=docs/highlight.theme \ 95 - --template=docs/template.html 96 - ``` 97 - 98 - ## avoiding javascript 99 - 100 - The "sidebar" style table-of-contents needs to be collapsed 101 - on mobile displays. Most of the engines I evaluated seem to 102 - require JS to collapse and expand the sidebar, with MkDocs 103 - being the outlier, it uses a checkbox with the 104 - [`:checked`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:checked) 105 - pseudo-class trick to avoid JS. 106 - 107 - The other ways to do this are: 108 - 109 - - Use `<details` and `<summary>`: this is definitely a 110 - "hack", clicking outside the sidebar does not collapse it. 111 - Using Ctrl+F or "Find in page" still works through the 112 - details tag though. 113 - - Use the new `popover` API: this seems like the perfect fit 114 - for a "sidebar" component. 115 - 116 - The bar at the top includes a button to trigger the popover: 117 - 118 - ```html 119 - <button popovertarget="toc-popover">Table of Contents</button> 120 - ``` 121 - 122 - And a `fixed` position div includes the TOC itself: 123 - 124 - ```html 125 - <div id="toc-popover" popover class="fixed top-0"> 126 - <ul> 127 - Quick Start 128 - <li>...</li> 129 - <li>...</li> 130 - <li>...</li> 131 - </ul> 132 - </div> 133 - ``` 134 - 135 - The TOC is scrollable independently and can be collapsed by 136 - clicking anywhere on the screen outside the sidebar. 137 - Searching for content in the page via "Find in page" does 138 - not show any results that are present in the popover 139 - however. The collapsible TOC is only available on smaller 140 - viewports, the TOC is not hidden on larger viewports. 141 - 142 - ## search 143 - 144 - There is no native search on the site for now. Taking 145 - inspiration from [https://htmx.org](https://htmx.org)'s search bar, our search 146 - bar also simply redirects to Google: 147 - 148 - ```html 149 - <form action="https://google.com/search"> 150 - <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 151 - ... 152 - </form> 153 - ``` 154 - 155 - I mentioned earlier that Ctrl+F has typically worked better 156 - for me than, say, the search engine provided by Docusaurus. 157 - To that end, the same docs have been exported to a ["single 158 - page" format](https://docs.tangled.org/single-page.html), by 159 - just removing the `chunkedhtml` related options: 160 - 161 - ```diff 162 - pandoc docs/DOCS.md \ 163 - -o out/ \ 164 - - -t chunkedhtml \ 165 - --variable toc \ 166 - --toc-depth=2 \ 167 - --css=docs/stylesheet.css \ 168 - - --chunk-template="%i.html" \ 169 - --highlight-style=docs/highlight.theme \ 170 - --template=docs/template.html 171 - ``` 172 - 173 - With all the content on a single page, it is trivial to 174 - search through the entire site with the browser. If the docs 175 - do outgrow this, I will consider other options! 176 - 177 - ## building and deploying 178 - 179 - We use [nix](https://nixos.org) and 180 - [colmena](https://colmena.cli.rs/) to build and deploy all 181 - Tangled services. A nix derivation to [build the 182 - documentation](https://tangled.org/tangled.org/core/blob/master/nix/pkgs/docs.nix) 183 - site is written very easily with the `runCommandLocal` 184 - helper: 185 - 186 - ```nix 187 - runCommandLocal "docs" {} '' 188 - . 189 - . 190 - . 191 - ${pandoc}/bin/pandoc ${src}/docs/DOCS.md ... 192 - . 193 - . 194 - . 195 - '' 196 - ``` 197 - 198 - The NixOS machine is configured to serve the site [via 199 - nginx](https://tangled.org/tangled.org/infra/blob/master/hosts/nixery/services/nginx.nix#L7): 200 - 201 - ```nix 202 - services.nginx = { 203 - enable = true; 204 - virtualHosts = { 205 - "docs.tangled.org" = { 206 - root = "${tangled-pkgs.docs}"; 207 - locations."/" = { 208 - tryFiles = "$uri $uri/ =404"; 209 - index = "index.html"; 210 - }; 211 - }; 212 - }; 213 - }; 214 - ``` 215 - 216 - And deployed using `colmena`: 217 - 218 - ```bash 219 - nix run nixpkgs#colmena -- apply 220 - ``` 221 - 222 - To update the site, I first run: 223 - 224 - ```bash 225 - nix flake update tangled 226 - ``` 227 - 228 - Which bumps the `tangled` flake input, and thus 229 - `tangled-pkgs.docs`. The above `colmena` invocation applies 230 - the changes to the machine serving the site. 231 - 232 - ## notes 233 - 234 - Going homegrown has made it a lot easier to style the 235 - documentation site to match the main site. Unfortunately 236 - there are still a few discrepancies between pandoc's 237 - markdown rendering and 238 - [goldmark's](https://pkg.go.dev/github.com/yuin/goldmark/) 239 - markdown rendering (which is what we use in Tangled). We may 240 - yet roll our own SSG, 241 - [TigerStyle](https://tigerbeetle.com/blog/2025-02-27-why-we-designed-tigerbeetles-docs-from-scratch/)!
-64
blog/posts/intro.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: intro 5 - title: introducing tangled 6 - subtitle: a git collaboration platform, built on atproto 7 - date: 2025-03-02 8 - authors: 9 - - name: Anirudh 10 - email: anirudh@tangled.sh 11 - handle: anirudh.fi 12 - --- 13 - 14 - 15 - [Tangled](https://tangled.sh) is a new social-enabled Git collaboration 16 - platform, built on top of the [AT Protocol](https://atproto.com). We 17 - envision a place where developers have complete ownership of their code, 18 - open source communities can freely self-govern and most importantly, 19 - coding can be social and fun again. 20 - 21 - There are several models for decentralized code collaboration platforms, 22 - ranging from ActivityPub's (Forgejo) federated model, to Radicle's 23 - entirely P2P model. Our approach attempts to be the best of both worlds 24 - by adopting atproto -- a protocol for building decentralized social 25 - applications with a central identity. 26 - 27 - ![tangled architecture](https://assets.tangled.network/blog/arch.svg) 28 - 29 - Our approach to this is the idea of "knots". Knots are lightweight, 30 - headless servers that enable users to host Git repositories with ease. 31 - Knots are designed for either single or multi-tenant use which is 32 - perfect for self-hosting on a Raspberry Pi at home, or larger 33 - "community" servers. By default, Tangled provides managed knots where 34 - you can host your repositories for free. 35 - 36 - The [App View][appview] at [tangled.sh](https://tangled.sh) acts as a 37 - consolidated "view" into the whole network, allowing users to access, 38 - clone and contribute to repositories hosted across different knots -- 39 - completely seamlessly. 40 - 41 - Tangled is still in its infancy, and we're building out several of its 42 - core features as we [dogfood it ourselves][dogfood]. We developed these 43 - three tenets to guide our decisions: 44 - 45 - 1. Ownership of data 46 - 2. Low barrier to entry 47 - 3. No compromise on user-experience 48 - 49 - Collaborating on code isn't easy, and the tools and workflows we use 50 - should feel natural and stay out of the way. Tangled's architecture 51 - enables common workflows to work as you'd expect, all while remaining 52 - decentralized. 53 - 54 - We believe that atproto has greatly simplfied one of the hardest parts 55 - of social media: having your friends on it. Today, we're rolling out 56 - invite-only access to Tangled -- join us on IRC at `#tangled` on 57 - [libera.chat](https://libera.chat) and we'll get you set up. 58 - 59 - **Update**: Tangled is open to public, simply login at 60 - [tangled.sh/login](https://tangled.sh/login)! Have fun! 61 - 62 - [pds]: https://atproto.com/guides/glossary#pds-personal-data-server 63 - [appview]: https://docs.bsky.app/docs/advanced-guides/federation-architecture#app-views 64 - [dogfood]: https://tangled.sh/@tangled.sh/core
-195
blog/posts/pulls.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: pulls 5 - title: the lifecycle of a pull request 6 - subtitle: we shipped a bunch of PR features recently; here's how we built it 7 - date: 2025-04-16 8 - image: https://assets.tangled.network/blog/hidden-ref.png 9 - authors: 10 - - name: Anirudh 11 - email: anirudh@tangled.sh 12 - handle: anirudh.fi 13 - - name: Akshay 14 - email: akshay@tangled.sh 15 - handle: oppi.li 16 - draft: false 17 - --- 18 - 19 - We've spent the last couple of weeks building out a pull 20 - request system for Tangled, and today we want to lift the 21 - hood and show you how it works. 22 - 23 - If you're new to Tangled, [read our intro](/intro) for the 24 - full story! 25 - 26 - You have three options to contribute to a repository: 27 - 28 - - Paste a patch on the web UI 29 - - Compare two local branches (you'll see this only if you're a 30 - collaborator on the repo) 31 - - Compare across forks 32 - 33 - Whatever you choose, at the core of every PR is the patch. 34 - First, you write some code. Then, you run `git diff` to 35 - produce a patch and make everyone's lives easier, or push to 36 - a branch, and we generate it ourselves by comparing against 37 - the target. 38 - 39 - ## patch generation 40 - 41 - When you create a PR from a branch, we create a "patch" by 42 - calculating the difference between your branch and the 43 - target branch. Consider this scenario: 44 - 45 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 46 - <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/merge-base.png"> 47 - <figcaption class="text-center"><code>A</code> is the merge-base for 48 - <code>feature</code> and <code>main</code>.</figcaption> 49 - </figure> 50 - 51 - Your `feature` branch has advanced 2 commits since you first 52 - branched out, but in the meanwhile, `main` has also advanced 53 - 2 commits. Doing a trivial `git diff feature main` will 54 - produce a confusing patch: 55 - 56 - - the patch will apply the changes from `X` and `Y` 57 - - the patch will **revert** the changes from `B` and `C` 58 - 59 - We obviously do not want the second part! To only show the 60 - changes added by `feature`, we have to identify the 61 - "merge-base": the nearest common ancestor of `feature` and 62 - `main`. 63 - 64 - 65 - In this case, `A` is the nearest common ancestor, and 66 - subsequently, the patch calculated will contain just `X` and 67 - `Y`. 68 - 69 - ### ref comparisons across forks 70 - 71 - The plumbing described above is easy to do across two 72 - branches, but what about forks? And what if they live on 73 - different servers altogether (as they can in Tangled!)? 74 - 75 - Here's the concept: since we already have all the necessary 76 - components to compare two local refs, why not simply 77 - "localize" the remote ref? 78 - 79 - In simpler terms, we instruct Git to fetch the target branch 80 - from the original repository and store it in your fork under 81 - a special name. This approach allows us to compare your 82 - changes against the most current version of the branch 83 - you're trying to contribute to, all while remaining within 84 - your fork. 85 - 86 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 87 - <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/hidden-ref.png"> 88 - <figcaption class="text-center">Hidden tracking ref.</figcaption> 89 - </figure> 90 - 91 - We call this a "hidden tracking ref." When you create a pull 92 - request from a fork, we establish a refspec that tracks the 93 - remote branch, which we then use to generate a diff. A 94 - refspec is essentially a rule that tells Git how to map 95 - references between a remote and your local repository during 96 - fetch or push operations. 97 - 98 - For example, if your fork has a feature branch called 99 - `feature-1`, and you want to make a pull request to the 100 - `main` branch of the original repository, we fetch the 101 - remote `main` into a local hidden ref using a refspec like 102 - this: 103 - 104 - ``` 105 - +refs/heads/main:refs/hidden/feature-1/main 106 - ``` 107 - 108 - Since we already have a remote (`origin`, by default) to the 109 - original repository (remember, we cloned it earlier), we can 110 - use `fetch` with this refspec to bring the remote `main` 111 - branch into our local hidden ref. Each pull request gets its 112 - own hidden ref, hence the `refs/hidden/:localRef/:remoteRef` 113 - format. We keep this ref updated whenever you push new 114 - commits to your feature branch, ensuring that comparisons -- 115 - and any potential merge conflicts -- are always based on the 116 - latest state of the target branch. 117 - 118 - And just like earlier, we produce the patch by diffing your 119 - feature branch with the hidden tracking ref. Also, the entire pull 120 - request is stored as [an atproto record][atproto-record] and updated 121 - each time the patch changes. 122 - 123 - [atproto-record]: https://pdsls.dev/at://did:plc:qfpnj4og54vl56wngdriaxug/sh.tangled.repo.pull/3lmwniim2i722 124 - 125 - Neat, now that we have a patch; we can move on the hard 126 - part: code review. 127 - 128 - 129 - ## your patch does the rounds 130 - 131 - Tangled uses a "round-based" review format. Your initial 132 - submission starts "round 0". Once your submission receives 133 - scrutiny, you can address reviews and resubmit your patch. 134 - This resubmission starts "round 1". You keep whittling on 135 - your patch till it is good enough, and eventually merged (or 136 - closed if you are unlucky). 137 - 138 - <figure class="max-w-[700px] m-auto flex flex-col items-center justify-center"> 139 - <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/patch-pr-main.png"> 140 - <figcaption class="text-center">A new pull request with a couple 141 - rounds of reviews.</figcaption> 142 - </figure> 143 - 144 - Rounds are a far superior to standard branch-based 145 - approaches: 146 - 147 - - Submissions are immutable: how many times have your 148 - reviews gone out-of-date because the author pushed commits 149 - _during_ your review? 150 - - Reviews are attached to submissions: at a glance, it is 151 - easy to tell which comment applies to which "version" of 152 - the pull-request 153 - - The author can choose when to resubmit! They can commit as 154 - much as they want to their branch, but a new round begins 155 - when they choose to hit "resubmit" 156 - - It is possible to "interdiff" and observe changes made 157 - across submissions (this is coming very soon to Tangled!) 158 - 159 - This [post by Mitchell 160 - Hashimoto](https://mitchellh.com/writing/github-changesets) 161 - goes into further detail on what can be achieved with 162 - round-based reviews. 163 - 164 - ## future plans 165 - 166 - To close off this post, we wanted to share some of our 167 - future plans for pull requests: 168 - 169 - * `format-patch` support: both for pasting in the UI and 170 - internally. This allows us to show commits in the PR page, 171 - and offer different merge strategies to choose from 172 - (squash, rebase, ...). 173 - **Update 2025-08-12**: We have format-patch support! 174 - 175 - * Gerrit-style `refs/for/main`: we're still hashing out the 176 - details but being able to push commits to a ref to 177 - "auto-create" a PR would be super handy! 178 - 179 - * Change ID support: This will allow us to group changes 180 - together and track them across multiple commits, and to 181 - provide "history" for each change. This works great with [Jujutsu][jj]. 182 - **Update 2025-08-12**: This has now landed: https://blog.tangled.org/stacking 183 - 184 - Join us on [Discord](https://chat.tangled.sh) or 185 - `#tangled` on libera.chat (the two are bridged, so we will 186 - never miss a message!). We are always available to help 187 - setup knots, listen to feedback on features, or even 188 - shepherd contributions! 189 - 190 - **Update 2025-08-12**: We move fast, and we now have jujutsu support, and an 191 - early in-house CI: https://blog.tangled.org/ci. You no longer need a Bluesky 192 - account to sign-up; head to https://tangled.sh/signup and sign up with your 193 - email! 194 - 195 - [jj]: https://jj-vcs.github.io/jj/latest/
-74
blog/posts/seed.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: seed 5 - title: announcing our €3,8M seed round 6 - subtitle: and more on what's next 7 - date: 2026-03-02 8 - image: https://assets.tangled.network/blog/seed.png 9 - authors: 10 - - name: Anirudh 11 - email: anirudh@tangled.org 12 - handle: anirudh.fi 13 - --- 14 - 15 - ![seed](https://assets.tangled.network/seed.png) 16 - 17 - Today, we're announcing our €3,8M ($4.5M) financing round led by 18 - [byFounders](https://byfounders.vc), with participation from [Bain 19 - Capital Crypto](https://baincapitalcrypto.com/), 20 - [Antler](https://antler.co), Thomas Dohmke (former GitHub CEO), Avery 21 - Pennarun (CEO of Tailscale), among other incredible angels. 22 - 23 - For the past year, we've been building Tangled from the ground up -- 24 - starting from first principles and asking ourselves what code 25 - collaboration should really look like. We made deliberate, 26 - [future-facing technology](https://anirudh.fi/future) choices. We chose 27 - to build on top of the AT Protocol as it helped us realize a federated, 28 - open network where users can own their code and social data. We shipped 29 - stacked PRs to enable more efficient contribution and review workflows. 30 - What started off as a side project, grew to over 7k+ users, who've 31 - created over 5k+ repositories. 32 - 33 - Our vision for Tangled has always been big: we want to build the best 34 - code forge ever, and become foundational infrastructure for the next 35 - generation of open source. Whatever that looks like: hundreds of devs 36 - building artisanal libraries, or one dev and a hundred agents building a 37 - micro-SaaS. 38 - 39 - And finding the right investors to help us acheive this vision wasn't 40 - something we took lightly. We spent months getting to know potential 41 - partners -- among which, byFounders stood out immediately. Like us, 42 - they're community-driven at their core, and their commitment to 43 - transparency runs deep -- you can see the very term sheet we signed on 44 - their website! With these shared fundamental values, we knew byFounders 45 - were the right people to have in our corner and we're incredibly excited 46 - to work with them. 47 - 48 - ## what's next 49 - 50 - We're heads down building. For 2026, expect to see: 51 - 52 - * a fully revamped CI (spindle v2!) built on micro VMs to allow for 53 - faster builds and more choice of build environments. Oh, and a proper 54 - Nix CI -- we know you want it. 55 - * protocol-level improvements across the board that'll unlock nifty 56 - things like repo migrations across knots, organizations, and more! 57 - * a customizable "mission control" dashboard for your active PRs, 58 - issues, and anything else you might want to track. 59 - * a migration tool to help you move off GitHub 60 - * all things search: code search, repo search, etc. 61 - * platform and infrastructure performance improvements & more global 62 - presence 63 - * Tangled CLI! 64 - 65 - If all this sounds exciting to you: we're growing our team! Shoot us 66 - [an email](mailto:team@tangled.org) telling us a bit about yourself and 67 - any past work that might be relevant, and what part of the roadmap 68 - interests you most. We can hire from (almost) anywhere. 69 - 70 - New to Tangled? [Get started here](https://docs.tangled.org/). Oh, and 71 - come hang on [Discord](https://chat.tangled.org)! 72 - 73 - A sincere thank you to everyone that helped us get here -- we're giddy 74 - about what's to come.
-351
blog/posts/stacking.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: stacking 5 - title: jujutsu on tangled 6 - subtitle: tangled now supports jujutsu change-ids! 7 - date: 2025-06-02 8 - image: https://assets.tangled.network/blog/interdiff_difference.jpeg 9 - authors: 10 - - name: Akshay 11 - email: akshay@tangled.sh 12 - handle: oppi.li 13 - draft: false 14 - --- 15 - 16 - Jujutsu is built around structuring your work into 17 - meaningful commits. Naturally, during code-review, you'd 18 - expect reviewers to be able to comment on individual 19 - commits, and also see the evolution of a commit over time, 20 - as reviews are addressed. We set out to natively support 21 - this model of code-review on Tangled. 22 - 23 - Tangled is a new social-enabled Git collaboration platform, 24 - [read our intro](/intro) for more about the project. 25 - 26 - For starters, I would like to contrast the two schools of 27 - code-review, the "diff-soup" model and the interdiff model. 28 - 29 - ## the diff-soup model 30 - 31 - When you create a PR on traditional code forges (GitHub 32 - specifically), the UX implicitly encourages you to address 33 - your code review by *adding commits* on top of your original 34 - set of changes: 35 - 36 - - GitHub's "Apply Suggestion" button directly commits the 37 - suggestion into your PR 38 - - GitHub only shows you the diff of all files at once by 39 - default 40 - - It is difficult to know what changed across force pushes 41 - 42 - Consider a hypothetical PR that adds 3 commits: 43 - 44 - ``` 45 - [c] implement new feature across the board (HEAD) 46 - | 47 - [b] introduce new feature 48 - | 49 - [a] some small refactor 50 - ``` 51 - 52 - And when only newly added commits are easy to review, this 53 - is what ends up happening: 54 - 55 - ``` 56 - [f] formatting & linting (HEAD) 57 - | 58 - [e] update name of new feature 59 - | 60 - [d] fix bug in refactor 61 - | 62 - [c] implement new feature across the board 63 - | 64 - [b] introduce new feature 65 - | 66 - [a] some small refactor 67 - ``` 68 - 69 - It is impossible to tell what addresses what at a glance, 70 - there is an implicit relation between each change: 71 - 72 - ``` 73 - [f] formatting & linting 74 - | 75 - [e] update name of new feature -------------. 76 - | | 77 - [d] fix bug in refactor -----------. | 78 - | | | 79 - [c] implement new feature across the board | 80 - | | | 81 - [b] introduce new feature <-----------------' 82 - | | 83 - [a] some small refactor <----------' 84 - ``` 85 - 86 - This has the downside of clobbering the output of `git 87 - blame` (if there is a bug in the new feature, you will first 88 - land on `e`, and upon digging further, you will land on 89 - `b`). This becomes incredibly tricky to navigate if reviews 90 - go on through multiple cycles. 91 - 92 - 93 - ## the interdiff model 94 - 95 - With jujutsu however, you have the tools at hand to 96 - fearlessly edit, split, squash and rework old commits (you 97 - can absolutely achieve this with git and interactive 98 - rebasing, but it is certainly not trivial). 99 - 100 - Let's try that again: 101 - 102 - ``` 103 - [c] implement new feature across the board (HEAD) 104 - | 105 - [b] introduce new feature 106 - | 107 - [a] some small refactor 108 - ``` 109 - 110 - To fix the bug in the refactor: 111 - 112 - ``` 113 - $ jj edit a 114 - Working copy (@) now at: [a] some small refactor 115 - 116 - $ # hack hack hack 117 - 118 - $ jj log -r a:: 119 - Rebased 2 descendant commits onto updated working copy 120 - [c] implement new feature across the board (HEAD) 121 - | 122 - [b] introduce new feature 123 - | 124 - [a] some small refactor 125 - ``` 126 - 127 - Jujutsu automatically rebases the descendants without having 128 - to lift a finger. Brilliant! You can repeat the same 129 - exercise for all review comments, and effectively, your 130 - PR will have evolved like so: 131 - 132 - ``` 133 - a -> b -> c initial attempt 134 - | | | 135 - v v v 136 - a' -> b' -> c' after first cycle of reviews 137 - ``` 138 - 139 - ## the catch 140 - 141 - If you use `git rebase`, you will know that it modifies 142 - history and therefore changes the commit SHA. How then, 143 - should one tell the difference between the "old" and "new" 144 - state of affairs? 145 - 146 - Tools like `git-range-diff` make use of a variety of 147 - text-based heuristics to roughly match `a` to `a'` and `b` 148 - to `b'` etc. 149 - 150 - Jujutsu however, works around this by assigning stable 151 - "change id"s to each change (which internally point to a git 152 - commit, if you use the git backing). If you edit a commit, 153 - its SHA changes, but its change-id remains the same. 154 - 155 - And this is the essence of our new stacked PRs feature! 156 - 157 - ## interdiff code review on tangled 158 - 159 - To really explain how this works, let's start with a [new 160 - codebase](https://tangled.sh/@oppi.li/stacking-demo/): 161 - 162 - ``` 163 - $ jj git init --colocate 164 - 165 - # -- initialize codebase -- 166 - 167 - $ jj log 168 - @ n set: introduce Set type main HEAD 1h 169 - ``` 170 - 171 - I have kicked things off by creating a new go module that 172 - adds a `HashSet` data structure. My first changeset 173 - introduces some basic set operations: 174 - 175 - ``` 176 - $ jj log 177 - @ so set: introduce set difference HEAD 178 - ├ sq set: introduce set intersection 179 - ├ mk set: introduce set union 180 - ├ my set: introduce basic set operations 181 - ~ 182 - 183 - $ jj git push -c @ 184 - Changes to push to origin: 185 - Add bookmark push-soqmukrvport to fc06362295bd 186 - ``` 187 - 188 - When submitting a pull request, select "Submit as stacked PRs": 189 - 190 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 191 - <a href="https://assets.tangled.network/blog/submit_stacked.jpeg"> 192 - <img class="my-1 h-auto max-w-full" src="https://assets.tangled.network/blog/submit_stacked.jpeg"> 193 - </a> 194 - <figcaption class="text-center">Submitting Stacked PRs</figcaption> 195 - </figure> 196 - 197 - This submits each change as an individual pull request: 198 - 199 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 200 - <a href="https://assets.tangled.network/blog/top_of_stack.jpeg"> 201 - <img class="my-1 h-auto max-w-full" src="https://assets.tangled.network/blog/top_of_stack.jpeg"> 202 - </a> 203 - <figcaption class="text-center">The "stack" is similar to Gerrit's relation chain</figcaption> 204 - </figure> 205 - 206 - After a while, I receive a couple of review comments, not on 207 - my entire submission, but rather, on each *individual 208 - change*. Additionally, the reviewer is happy with my first 209 - change, and has gone ahead and merged that: 210 - 211 - <div class="flex justify-center items-start gap-2"> 212 - <figure class="w-1/3 m-0 flex flex-col items-center"> 213 - <a href="https://assets.tangled.network/blog/basic_merged.jpeg"> 214 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/basic_merged.jpeg" alt="The first change has been merged"> 215 - </a> 216 - <figcaption class="text-center">The first change has been merged</figcaption> 217 - </figure> 218 - 219 - <figure class="w-1/3 m-0 flex flex-col items-center"> 220 - <a href="https://assets.tangled.network/blog/review_union.jpeg"> 221 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/review_union.jpeg" alt="A review on the set union implementation"> 222 - </a> 223 - <figcaption class="text-center">A review on the set union implementation</figcaption> 224 - </figure> 225 - 226 - <figure class="w-1/3 m-0 flex flex-col items-center"> 227 - <a href="https://assets.tangled.network/blog/review_difference.jpeg"> 228 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/review_difference.jpeg" alt="A review on the set difference implementation"> 229 - </a> 230 - <figcaption class="text-center">A review on the set difference implementation</figcaption> 231 - </figure> 232 - </div> 233 - 234 - Let us address the first review: 235 - 236 - > can you use the new `maps.Copy` api here? 237 - 238 - ``` 239 - $ jj log 240 - @ so set: introduce set difference push-soqmukrvport 241 - ├ sq set: introduce set intersection 242 - ├ mk set: introduce set union 243 - ├ my set: introduce basic set operations 244 - ~ 245 - 246 - # let's edit the implementation of `Union` 247 - $ jj edit mk 248 - 249 - # hack, hack, hack 250 - 251 - $ jj log 252 - Rebased 2 descendant commits onto updated working copy 253 - ├ so set: introduce set difference push-soqmukrvport* 254 - ├ sq set: introduce set intersection 255 - @ mk set: introduce set union 256 - ├ my set: introduce basic set operations 257 - ~ 258 - ``` 259 - 260 - Next, let us address the bug: 261 - 262 - > there is a logic bug here, the condition should be negated. 263 - 264 - ``` 265 - # let's edit the implementation of `Difference` 266 - $ jj edit so 267 - 268 - # hack, hack, hack 269 - ``` 270 - 271 - We are done addressing reviews: 272 - ``` 273 - $ jj git push 274 - Changes to push to origin: 275 - Move sideways bookmark push-soqmukrvport from fc06362295bd to dfe2750f6d40 276 - ``` 277 - 278 - Upon resubmitting the PR for review, Tangled is able to 279 - accurately trace the commit across rewrites, using jujutsu 280 - change-ids, and map it to the corresponding PR: 281 - 282 - <div class="flex justify-center items-start gap-2"> 283 - <figure class="w-1/2 m-0 flex flex-col items-center"> 284 - <a href="https://assets.tangled.network/blog/round_2_union.jpeg"> 285 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/round_2_union.jpeg" alt="PR #2 advances to the next round"> 286 - </a> 287 - <figcaption class="text-center">PR #2 advances to the next round</figcaption> 288 - </figure> 289 - 290 - <figure class="w-1/2 m-0 flex flex-col items-center"> 291 - <a href="https://assets.tangled.network/blog/round_2_difference.jpeg"> 292 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/round_2_difference.jpeg" alt="PR #4 advances to the next round"> 293 - </a> 294 - <figcaption class="text-center">PR #4 advances to the next round</figcaption> 295 - </figure> 296 - </div> 297 - 298 - Of note here are a few things: 299 - 300 - - The initial submission is still visible under `round #0` 301 - - By resubmitting, the round has simply advanced to `round 302 - #1` 303 - - There is a helpful "interdiff" button to look at the 304 - difference between the two submissions 305 - 306 - The individual diffs are still available, but most 307 - importantly, the reviewer can view the *evolution* of a 308 - change by hitting the interdiff button: 309 - 310 - <div class="flex justify-center items-start gap-2"> 311 - <figure class="w-1/2 m-0 flex flex-col items-center"> 312 - <a href="https://assets.tangled.network/blog/diff_1_difference.jpeg"> 313 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/diff_1_difference.jpeg" alt="Diff from round #0"> 314 - </a> 315 - <figcaption class="text-center">Diff from round #0</figcaption> 316 - </figure> 317 - 318 - <figure class="w-1/2 m-0 flex flex-col items-center"> 319 - <a href="https://assets.tangled.network/blog/diff_2_difference.jpeg"> 320 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/diff_2_difference.jpeg" alt="Diff from round #1"> 321 - </a> 322 - <figcaption class="text-center">Diff from round #1</figcaption> 323 - </figure> 324 - </div> 325 - 326 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 327 - <a href="https://assets.tangled.network/blog/interdiff_difference.jpeg"> 328 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/interdiff_difference.jpeg" alt="Interdiff between round #0 and #1"> 329 - </a> 330 - <figcaption class="text-center">Interdiff between round #1 and #0</figcaption> 331 - </figure> 332 - 333 - Indeed, the logic bug has been addressed! 334 - 335 - ## start stacking today 336 - 337 - If you are a jujutsu user, you can enable this flag on more 338 - recent versions of jujutsu: 339 - 340 - ``` 341 - λ jj --version 342 - jj 0.29.0-8c7ca30074767257d75e3842581b61e764d022cf 343 - 344 - # -- in your config.toml file -- 345 - [git] 346 - write-change-id-header = true 347 - ``` 348 - 349 - This feature writes `change-id` headers directly into the 350 - git commit object, and is visible to code forges upon push, 351 - and allows you to stack your PRs on Tangled.
-17
blog/templates/fragments/footer.html
··· 1 - {{ define "blog/fragments/footer" }} 2 - <footer class="mt-12 w-full px-6 py-4 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700"> 3 - <div class="max-w-[90ch] mx-auto flex flex-wrap justify-center items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"> 4 - <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 5 - <a href="https://tangled.org" class="no-underline hover:no-underline flex items-center"> 6 - {{ template "fragments/dolly/logo" (dict "Classes" "size-5 text-gray-500 dark:text-gray-400") }} 7 - </a> 8 - <span>&copy; 2026 Tangled Labs Oy.</span> 9 - </div> 10 - <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">docs</a> 11 - <a href="https://tangled.org/tangled.org/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">source</a> 12 - <a href="https://chat.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">discord</a> 13 - <a href="https://bsky.app/profile/tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">bluesky</a> 14 - <a href="/feed.xml" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">feed</a> 15 - </div> 16 - </footer> 17 - {{ end }}
-76
blog/templates/index.html
··· 1 - {{ define "title" }}the tangled blog{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta name="description" content="The Tangled blog." /> 5 - <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 6 - {{ end }} 7 - 8 - <!-- overrides the default slate bg --> 9 - {{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} 10 - 11 - {{ define "topbarLayout" }} 12 - <header class="max-w-screen-xl mx-auto w-full" style="z-index: 20;"> 13 - {{ template "layouts/fragments/topbar" . }} 14 - </header> 15 - {{ end }} 16 - 17 - {{ define "content" }} 18 - <div class="max-w-screen-lg mx-auto w-full px-4 py-10"> 19 - 20 - <header class="mb-10 text-center"> 21 - <h1 class="text-3xl font-bold dark:text-white mb-2">the tangled blog</h1> 22 - <p class="text-gray-500 dark:text-gray-400">all the ropes and scaffolding</p> 23 - </header> 24 - 25 - {{ if .Featured }} 26 - <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-14"> 27 - {{ range .Featured }} 28 - <a href="/{{ .Meta.Slug }}" class="no-underline hover:no-underline group flex flex-col bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 overflow-hidden hover:bg-gray-100/25 hover:dark:bg-gray-700/25 transition-colors"> 29 - <div class="aspect-[16/9] overflow-hidden bg-gray-100 dark:bg-gray-700"> 30 - <img src="{{ .Meta.Image }}" alt="{{ .Meta.Title }}" class="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300" /> 31 - </div> 32 - <div class="flex flex-col flex-1 px-5 py-4"> 33 - <div class="text-xs text-gray-400 dark:text-gray-500 mb-2"> 34 - {{ $date := .ParsedDate }}{{ $date.Format "Jan 2, 2006" }} 35 - {{ if .Meta.Draft }}<span class="text-red-500">[draft]</span>{{ end }} 36 - </div> 37 - <h2 class="font-bold text-gray-900 dark:text-white text-base leading-snug mb-1 group-hover:underline">{{ .Meta.Title }}</h2> 38 - <p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 flex-1">{{ .Meta.Subtitle }}</p> 39 - <div class="flex items-center mt-4"> 40 - <div class="inline-flex items-center -space-x-2"> 41 - {{ range .Meta.Authors }} 42 - <img src="{{ tinyAvatar .Handle }}" class="size-6 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 43 - {{ end }} 44 - </div> 45 - </div> 46 - </div> 47 - </a> 48 - {{ end }} 49 - </div> 50 - {{ end }} 51 - 52 - <div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 53 - {{ range .Posts }} 54 - <a href="/{{ .Meta.Slug }}" class="no-underline hover:no-underline group flex items-center justify-between gap-4 px-6 py-3 hover:bg-gray-100/25 hover:dark:bg-gray-700/25 transition-colors"> 55 - <div class="flex items-center gap-3 min-w-0"> 56 - <div class="inline-flex items-center -space-x-2 shrink-0"> 57 - {{ range .Meta.Authors }} 58 - <img src="{{ tinyAvatar .Handle }}" class="size-5 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 59 - {{ end }} 60 - </div> 61 - <span class="font-medium text-gray-900 dark:text-white group-hover:underline truncate"> 62 - {{ .Meta.Title }} 63 - {{ if .Meta.Draft }}<span class="text-red-500 text-xs font-normal ml-1">[draft]</span>{{ end }} 64 - </span> 65 - </div> 66 - <div class="text-sm text-gray-400 dark:text-gray-500 shrink-0"> 67 - {{ $date := .ParsedDate }}{{ $date.Format "Jan 02, 2006" }} 68 - </div> 69 - </a> 70 - {{ end }} 71 - </div> 72 - 73 - </div> 74 - {{ end }} 75 - 76 - {{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }}
-68
blog/templates/post.html
··· 1 - {{ define "title" }}{{ .Post.Meta.Title }} — tangled blog{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta name="description" content="{{ .Post.Meta.Subtitle }}"/> 5 - <meta property="og:title" content="{{ .Post.Meta.Title }}" /> 6 - <meta property="og:description" content="{{ .Post.Meta.Subtitle }}" /> 7 - <meta property="og:url" content="https://blog.tangled.org/{{ .Post.Meta.Slug }}" /> 8 - {{ if .Post.Meta.Image }} 9 - <meta property="og:image" content="https://blog.tangled.org{{ .Post.Meta.Image }}" /> 10 - <meta property="og:image:width" content="1200" /> 11 - <meta property="og:image:height" content="630" /> 12 - <meta name="twitter:card" content="summary_large_image" /> 13 - <meta name="twitter:image" content="https://blog.tangled.org{{ .Post.Meta.Image }}" /> 14 - {{ end }} 15 - <meta name="twitter:title" content="{{ .Post.Meta.Title }}" /> 16 - <meta name="twitter:description" content="{{ .Post.Meta.Subtitle }}" /> 17 - <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 18 - {{ end }} 19 - 20 - {{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} 21 - 22 - {{ define "topbarLayout" }} 23 - <header class="max-w-screen-xl mx-auto w-full" style="z-index: 20;"> 24 - {{ template "layouts/fragments/topbar" . }} 25 - </header> 26 - {{ end }} 27 - 28 - {{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }} 29 - 30 - {{ define "content" }} 31 - <div class="max-w-[90ch] mx-auto w-full px-4 py-8"> 32 - <div class="prose dark:prose-invert w-full max-w-none"> 33 - 34 - <header class="not-prose mb-4"> 35 - {{ $authors := .Post.Meta.Authors }} 36 - <p class="mb-1 text-sm text-gray-600 dark:text-gray-400"> 37 - {{ $date := .Post.ParsedDate }} 38 - {{ $date.Format "02 Jan, 2006" }} 39 - </p> 40 - 41 - <h1 class="mb-0 text-2xl font-bold dark:text-white"> 42 - {{ .Post.Meta.Title }} 43 - {{ if .Post.Meta.Draft }}<span class="text-red-500 text-base font-normal">[draft]</span>{{ end }} 44 - </h1> 45 - <p class="italic mt-1 mb-3 text-lg text-gray-600 dark:text-gray-400">{{ .Post.Meta.Subtitle }}</p> 46 - 47 - <div class="flex items-center gap-3 not-prose"> 48 - <div class="inline-flex items-center -space-x-2"> 49 - {{ range $authors }} 50 - <img src="{{ tinyAvatar .Handle }}" class="size-7 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Handle }}" title="{{ .Handle }}" /> 51 - {{ end }} 52 - </div> 53 - <div class="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300"> 54 - {{ range $i, $a := $authors }} 55 - {{ if gt $i 0 }}<span class="text-gray-400">&amp;</span>{{ end }} 56 - <a href="https://tangled.org/@{{ $a.Handle }}" class="hover:underline">{{ $a.Handle }}</a> 57 - {{ end }} 58 - </div> 59 - </div> 60 - </header> 61 - 62 - <article> 63 - {{ .Post.Body }} 64 - </article> 65 - 66 - </div> 67 - </div> 68 - {{ end }}
-91
blog/templates/text.html
··· 1 - {{ define "fragments/logotypeSmall" }} 2 - <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 4 - <span class="font-bold text-xl not-italic">tangled</span> 5 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> 6 - </span> 7 - {{ end }} 8 - 9 - <!doctype html> 10 - <html lang="en"> 11 - <head> 12 - <meta charset="UTF-8" /> 13 - <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 14 - <meta name="description" content="{{ index .Meta "subtitle" }}"/> 15 - <meta property="og:site_name" content="Tangled" /> 16 - <meta property="og:type" content="website" /> 17 - <meta property="og:title" content="{{ index .Meta "title" }}" /> 18 - <meta property="og:description" content="{{ index .Meta "subtitle" }}" /> 19 - <meta property="og:url" content="https://blog.tangled.org/{{ index .Meta "slug" }}" /> 20 - <meta property="og:image" content="https://blog.tangled.org{{ index .Meta "image" }}" /> 21 - <meta property="og:image:width" content="1200" /> 22 - <meta property="og:image:height" content="630" /> 23 - <meta name="twitter:card" content="summary_large_image" /> 24 - <meta name="twitter:title" content="{{ index .Meta "title" }}" /> 25 - <meta name="twitter:description" content="{{ index .Meta "subtitle" }}" /> 26 - <meta name="twitter:image" content="https://blog.tangled.org{{ index .Meta "image" }}" /> 27 - <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 28 - <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 29 - <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 30 - <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 31 - <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 32 - <link rel="stylesheet" href="/static/tw.css" type="text/css" /> 33 - <title>{{ index .Meta "title" }}</title> 34 - </head> 35 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white min-h-screen flex flex-col gap-4"> 36 - 37 - <header class="w-full drop-shadow-sm bg-white dark:bg-gray-800"> 38 - <nav class="mx-auto px-6 py-2"> 39 - <div class="flex justify-between items-center"> 40 - <a href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 41 - {{ template "fragments/logotypeSmall" }} 42 - </a> 43 - </div> 44 - </nav> 45 - </header> 46 - 47 - <div class="flex-grow flex flex-col max-w-[75ch] mx-auto w-full px-1"> 48 - <main class="prose dark:prose-invert w-full max-w-none"> 49 - 50 - <header class="not-prose"> 51 - <p class="px-6 mb-0 text-sm text-gray-600 dark:text-gray-400"> 52 - {{ $dateStr := index .Meta "date" }} 53 - {{ $date := parsedate $dateStr }} 54 - {{ $date.Format "02 Jan, 2006" }} 55 - 56 - <span class="mx-2 select-none">&middot;</span> 57 - 58 - by 59 - {{ $authors := index .Meta "authors" }} 60 - {{ if eq (len $authors) 2 }} 61 - <a href="https://bsky.app/profile/{{ (index $authors 0).handle }}" class="no-underline">{{ (index $authors 0).name }}</a> 62 - &amp; 63 - <a href="https://bsky.app/profile/{{ (index $authors 1).handle }}" class="no-underline">{{ (index $authors 1).name }}</a> 64 - {{ else }} 65 - {{ range $authors }} 66 - <a href="https://bsky.app/profile/{{ .handle }}" class="no-underline">{{ .name }}</a> 67 - {{ end }} 68 - {{ end }} 69 - </p> 70 - 71 - {{ if index .Meta "draft" }} 72 - <h1 class="px-6 mb-0 text-2xl font-bold">{{ index .Meta "title" }} <span class="text-red-500">[draft]</span></h1> 73 - {{ else }} 74 - <h1 class="px-6 mb-0 text-2xl font-bold">{{ index .Meta "title" }}</h1> 75 - {{ end }} 76 - <p class="italic px-6 mt-1 mb-4 text-lg text-gray-600 dark:text-gray-400">{{ index .Meta "subtitle" }}</p> 77 - </header> 78 - 79 - <article class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 80 - {{ .Body }} 81 - </article> 82 - 83 - </main> 84 - </div> 85 - 86 - <footer class="mt-12"> 87 - {{ template "layouts/fragments/footer" . }} 88 - </footer> 89 - 90 - </body> 91 - </html>
-3
camo/src/mimetypes.json
··· 1 1 [ 2 - "image/avif", 3 2 "image/bmp", 4 3 "image/cgm", 5 4 "image/g3fax", 6 5 "image/gif", 7 - "image/heif", 8 6 "image/ief", 9 7 "image/jp2", 10 - "image/jxl", 11 8 "image/jpeg", 12 9 "image/jpg", 13 10 "image/pict",
-193
cmd/blog/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "io" 7 - "log/slog" 8 - "net/http" 9 - "os" 10 - "path/filepath" 11 - 12 - "tangled.org/core/appview/config" 13 - "tangled.org/core/appview/pages" 14 - "tangled.org/core/blog" 15 - "tangled.org/core/idresolver" 16 - tlog "tangled.org/core/log" 17 - ) 18 - 19 - const ( 20 - postsDir = "blog/posts" 21 - templatesDir = "blog/templates" 22 - ) 23 - 24 - func main() { 25 - if len(os.Args) < 2 { 26 - fmt.Fprintln(os.Stderr, "usage: blog <build|serve> [flags]") 27 - os.Exit(1) 28 - } 29 - 30 - ctx := context.Background() 31 - logger := tlog.New("blog") 32 - 33 - switch os.Args[1] { 34 - case "build": 35 - if err := runBuild(ctx, logger); err != nil { 36 - logger.Error("build failed", "err", err) 37 - os.Exit(1) 38 - } 39 - case "serve": 40 - addr := "0.0.0.0:3001" 41 - if len(os.Args) >= 3 { 42 - addr = os.Args[2] 43 - } 44 - if err := runServe(ctx, logger, addr); err != nil { 45 - logger.Error("serve failed", "err", err) 46 - os.Exit(1) 47 - } 48 - default: 49 - fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) 50 - os.Exit(1) 51 - } 52 - } 53 - 54 - func makePages(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*pages.Pages, error) { 55 - resolver := idresolver.DefaultResolver(cfg.Plc.PLCURL) 56 - return pages.NewPages(cfg, resolver, nil, logger), nil 57 - } 58 - 59 - func runBuild(ctx context.Context, logger *slog.Logger) error { 60 - cfg, err := config.LoadConfig(ctx) 61 - if err != nil { 62 - cfg = &config.Config{} 63 - } 64 - 65 - p, err := makePages(ctx, cfg, logger) 66 - if err != nil { 67 - return fmt.Errorf("creating pages: %w", err) 68 - } 69 - 70 - posts, err := blog.Posts(postsDir) 71 - if err != nil { 72 - return fmt.Errorf("parsing posts: %w", err) 73 - } 74 - 75 - outDir := "build" 76 - if err := os.MkdirAll(outDir, 0755); err != nil { 77 - return err 78 - } 79 - 80 - // index 81 - if err := renderToFile(outDir, "index.html", func(w io.Writer) error { 82 - return blog.RenderIndex(p, templatesDir, posts, w) 83 - }); err != nil { 84 - return fmt.Errorf("rendering index: %w", err) 85 - } 86 - 87 - // posts — each at build/<slug>/index.html directly (no /blog/ prefix) 88 - for _, post := range posts { 89 - post := post 90 - postDir := filepath.Join(outDir, post.Meta.Slug) 91 - if err := os.MkdirAll(postDir, 0755); err != nil { 92 - return err 93 - } 94 - if err := renderToFile(postDir, "index.html", func(w io.Writer) error { 95 - return blog.RenderPost(p, templatesDir, post, w) 96 - }); err != nil { 97 - return fmt.Errorf("rendering post %s: %w", post.Meta.Slug, err) 98 - } 99 - } 100 - 101 - // atom feed — at build/feed.xml 102 - baseURL := "https://blog.tangled.org" 103 - atom, err := blog.AtomFeed(posts, baseURL) 104 - if err != nil { 105 - return fmt.Errorf("generating atom feed: %w", err) 106 - } 107 - if err := os.WriteFile(filepath.Join(outDir, "feed.xml"), []byte(atom), 0644); err != nil { 108 - return fmt.Errorf("writing feed: %w", err) 109 - } 110 - 111 - logger.Info("build complete", "dir", outDir) 112 - return nil 113 - } 114 - 115 - func runServe(ctx context.Context, logger *slog.Logger, addr string) error { 116 - cfg, err := config.LoadConfig(ctx) 117 - if err != nil { 118 - cfg = &config.Config{} 119 - } 120 - 121 - p, err := makePages(ctx, cfg, logger) 122 - if err != nil { 123 - return fmt.Errorf("creating pages: %w", err) 124 - } 125 - 126 - mux := http.NewServeMux() 127 - 128 - // index 129 - mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 130 - if r.URL.Path != "/" { 131 - http.NotFound(w, r) 132 - return 133 - } 134 - posts, err := blog.AllPosts(postsDir) 135 - if err != nil { 136 - http.Error(w, err.Error(), http.StatusInternalServerError) 137 - return 138 - } 139 - if err := blog.RenderIndex(p, templatesDir, posts, w); err != nil { 140 - logger.Error("render index", "err", err) 141 - } 142 - }) 143 - 144 - // individual posts directly at /<slug> 145 - mux.HandleFunc("GET /{slug}", func(w http.ResponseWriter, r *http.Request) { 146 - slug := r.PathValue("slug") 147 - posts, err := blog.AllPosts(postsDir) 148 - if err != nil { 149 - http.Error(w, err.Error(), http.StatusInternalServerError) 150 - return 151 - } 152 - for _, post := range posts { 153 - if post.Meta.Slug == slug { 154 - if err := blog.RenderPost(p, templatesDir, post, w); err != nil { 155 - logger.Error("render post", "err", err) 156 - } 157 - return 158 - } 159 - } 160 - http.NotFound(w, r) 161 - }) 162 - 163 - // atom feed at /feed.xml 164 - mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { 165 - posts, err := blog.Posts(postsDir) 166 - if err != nil { 167 - http.Error(w, err.Error(), http.StatusInternalServerError) 168 - return 169 - } 170 - atom, err := blog.AtomFeed(posts, "https://blog.tangled.org") 171 - if err != nil { 172 - http.Error(w, err.Error(), http.StatusInternalServerError) 173 - return 174 - } 175 - w.Header().Set("Content-Type", "application/atom+xml") 176 - fmt.Fprint(w, atom) 177 - }) 178 - 179 - // appview static files (tw.css, fonts, icons, logos) 180 - mux.Handle("GET /static/", p.Static()) 181 - 182 - logger.Info("serving", "addr", addr) 183 - return http.ListenAndServe(addr, mux) 184 - } 185 - 186 - func renderToFile(dir, name string, fn func(io.Writer) error) error { 187 - f, err := os.Create(filepath.Join(dir, name)) 188 - if err != nil { 189 - return err 190 - } 191 - defer f.Close() 192 - return fn(f) 193 - }
-277
cmd/claimer/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "flag" 8 - "fmt" 9 - "log/slog" 10 - "net/http" 11 - "net/url" 12 - "os" 13 - "strings" 14 - 15 - appviewdb "tangled.org/core/appview/db" 16 - tlog "tangled.org/core/log" 17 - 18 - _ "github.com/mattn/go-sqlite3" 19 - ) 20 - 21 - var ( 22 - dbPath = flag.String("db", "appview.db", "path to the appview SQLite database") 23 - pdsHost = flag.String("pds", "https://tngl.sh", "PDS host URL") 24 - dryRun = flag.Bool("dry-run", false, "print what would be done without writing to the database") 25 - verbose = flag.Bool("v", false, "log pagination progress") 26 - ) 27 - 28 - type repo struct { 29 - DID string `json:"did"` 30 - Head string `json:"head"` 31 - } 32 - 33 - type listReposResponse struct { 34 - Cursor string `json:"cursor"` 35 - Repos []repo `json:"repos"` 36 - } 37 - 38 - type describeRepoResponse struct { 39 - Handle string `json:"handle"` 40 - DID string `json:"did"` 41 - } 42 - 43 - func main() { 44 - flag.Usage = func() { 45 - fmt.Fprintf(os.Stderr, "Usage: claimer [flags]\n\n") 46 - fmt.Fprintf(os.Stderr, "Backfills domain_claims for all existing users on the tngl.sh PDS.\n") 47 - fmt.Fprintf(os.Stderr, "DIDs are fetched via com.atproto.sync.listRepos, handles are resolved\n") 48 - fmt.Fprintf(os.Stderr, "via com.atproto.repo.describeRepo (no DNS verification required).\n\n") 49 - fmt.Fprintf(os.Stderr, "Flags:\n") 50 - flag.PrintDefaults() 51 - } 52 - flag.Parse() 53 - 54 - ctx := context.Background() 55 - logger := tlog.New("claimer") 56 - ctx = tlog.IntoContext(ctx, logger) 57 - 58 - pdsDomain, err := domainFromURL(*pdsHost) 59 - if err != nil { 60 - logger.Error("invalid pds host", "host", *pdsHost, "error", err) 61 - os.Exit(1) 62 - } 63 - 64 - logger.Info("starting claimer", 65 - "pds", *pdsHost, 66 - "pds_domain", pdsDomain, 67 - "db", *dbPath, 68 - "dry_run", *dryRun, 69 - ) 70 - 71 - database, err := appviewdb.Make(ctx, *dbPath) 72 - if err != nil { 73 - logger.Error("failed to open database", "error", err) 74 - os.Exit(1) 75 - } 76 - defer database.Close() 77 - 78 - dids, err := fetchAllDIDs(ctx, *pdsHost, logger) 79 - if err != nil { 80 - logger.Error("failed to fetch repos from PDS", "error", err) 81 - os.Exit(1) 82 - } 83 - 84 - logger.Info("fetched repos", "count", len(dids)) 85 - 86 - suffix := "." + pdsDomain 87 - 88 - var ( 89 - created int 90 - skipped int 91 - errCount int 92 - ) 93 - 94 - for _, did := range dids { 95 - handle, err := describeRepo(ctx, *pdsHost, did) 96 - if err != nil { 97 - logger.Error("failed to describe repo", "did", did, "error", err) 98 - errCount++ 99 - continue 100 - } 101 - 102 - if !strings.HasSuffix(handle, suffix) { 103 - logger.Info("skipping account: handle does not match pds domain", 104 - "did", did, "handle", handle, "suffix", suffix) 105 - skipped++ 106 - continue 107 - } 108 - 109 - // The handle itself is the claim domain: <username>.<pds-domain> 110 - claimDomain := handle 111 - 112 - if *dryRun { 113 - logger.Info("[dry-run] would claim domain", 114 - "did", did, 115 - "handle", handle, 116 - "domain", claimDomain, 117 - ) 118 - created++ 119 - continue 120 - } 121 - 122 - if err := appviewdb.ClaimDomain(database, did, claimDomain); err != nil { 123 - switch { 124 - case errors.Is(err, appviewdb.ErrDomainTaken): 125 - logger.Warn("skipping: domain already claimed by another user", 126 - "did", did, "domain", claimDomain) 127 - skipped++ 128 - case errors.Is(err, appviewdb.ErrAlreadyClaimed): 129 - logger.Warn("skipping: DID already has an active claim", 130 - "did", did, "domain", claimDomain) 131 - skipped++ 132 - case errors.Is(err, appviewdb.ErrDomainCooldown): 133 - logger.Warn("skipping: domain is in 30-day cooldown", 134 - "did", did, "domain", claimDomain) 135 - skipped++ 136 - default: 137 - logger.Error("failed to claim domain", 138 - "did", did, "domain", claimDomain, "error", err) 139 - errCount++ 140 - } 141 - continue 142 - } 143 - 144 - logger.Info("claimed domain", "did", did, "domain", claimDomain) 145 - created++ 146 - } 147 - 148 - logger.Info("claimer finished", 149 - slog.Group("results", 150 - "created", created, 151 - "skipped", skipped, 152 - "errors", errCount, 153 - ), 154 - ) 155 - 156 - if errCount > 0 { 157 - os.Exit(1) 158 - } 159 - } 160 - 161 - // fetchAllDIDs pages through com.atproto.sync.listRepos (public, no auth) 162 - // and returns every DID hosted on the PDS. 163 - func fetchAllDIDs(ctx context.Context, pdsHost string, logger *slog.Logger) ([]string, error) { 164 - var all []string 165 - cursor := "" 166 - 167 - for { 168 - batch, nextCursor, err := listRepos(ctx, pdsHost, cursor) 169 - if err != nil { 170 - return nil, err 171 - } 172 - 173 - for _, r := range batch { 174 - all = append(all, r.DID) 175 - } 176 - 177 - if *verbose { 178 - logger.Info("fetched page", "count", len(batch), "total", len(all), "next_cursor", nextCursor) 179 - } 180 - 181 - if nextCursor == "" || nextCursor == cursor { 182 - break 183 - } 184 - cursor = nextCursor 185 - } 186 - 187 - return all, nil 188 - } 189 - 190 - // listRepos fetches one page of com.atproto.sync.listRepos. 191 - func listRepos(ctx context.Context, pdsHost, cursor string) ([]repo, string, error) { 192 - params := url.Values{} 193 - params.Set("limit", "100") 194 - if cursor != "" { 195 - params.Set("cursor", cursor) 196 - } 197 - 198 - endpoint := pdsHost + "/xrpc/com.atproto.sync.listRepos?" + params.Encode() 199 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 200 - if err != nil { 201 - return nil, "", fmt.Errorf("building listRepos request: %w", err) 202 - } 203 - 204 - resp, err := http.DefaultClient.Do(req) 205 - if err != nil { 206 - return nil, "", fmt.Errorf("executing listRepos request: %w", err) 207 - } 208 - defer resp.Body.Close() 209 - 210 - if resp.StatusCode != http.StatusOK { 211 - var errBody struct { 212 - Error string `json:"error"` 213 - Message string `json:"message"` 214 - } 215 - json.NewDecoder(resp.Body).Decode(&errBody) 216 - return nil, "", fmt.Errorf("PDS returned %d: %s — %s", resp.StatusCode, errBody.Error, errBody.Message) 217 - } 218 - 219 - var result listReposResponse 220 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 221 - return nil, "", fmt.Errorf("decoding listRepos response: %w", err) 222 - } 223 - 224 - return result.Repos, result.Cursor, nil 225 - } 226 - 227 - // describeRepo calls com.atproto.repo.describeRepo on the PDS (public, no auth) 228 - // and returns the handle for the given DID. This avoids DNS-based verification 229 - // so accounts with broken handles still resolve correctly. 230 - func describeRepo(ctx context.Context, pdsHost, did string) (string, error) { 231 - params := url.Values{} 232 - params.Set("repo", did) 233 - 234 - endpoint := pdsHost + "/xrpc/com.atproto.repo.describeRepo?" + params.Encode() 235 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 236 - if err != nil { 237 - return "", fmt.Errorf("building describeRepo request: %w", err) 238 - } 239 - 240 - resp, err := http.DefaultClient.Do(req) 241 - if err != nil { 242 - return "", fmt.Errorf("executing describeRepo request: %w", err) 243 - } 244 - defer resp.Body.Close() 245 - 246 - if resp.StatusCode != http.StatusOK { 247 - var errBody struct { 248 - Error string `json:"error"` 249 - Message string `json:"message"` 250 - } 251 - json.NewDecoder(resp.Body).Decode(&errBody) 252 - return "", fmt.Errorf("PDS returned %d: %s — %s", resp.StatusCode, errBody.Error, errBody.Message) 253 - } 254 - 255 - var result describeRepoResponse 256 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 257 - return "", fmt.Errorf("decoding describeRepo response: %w", err) 258 - } 259 - 260 - return result.Handle, nil 261 - } 262 - 263 - // domainFromURL strips the scheme from a URL and returns the bare hostname. 264 - func domainFromURL(rawURL string) (string, error) { 265 - if !strings.Contains(rawURL, "://") { 266 - return rawURL, nil 267 - } 268 - u, err := url.Parse(rawURL) 269 - if err != nil { 270 - return "", err 271 - } 272 - host := u.Hostname() 273 - if host == "" { 274 - return "", fmt.Errorf("no hostname in URL %q", rawURL) 275 - } 276 - return host, nil 277 - }
+22 -124
docs/DOCS.md
··· 278 278 git push tangled main 279 279 ``` 280 280 281 - # Hosting websites on Tangled 282 - 283 - You can serve static websites directly from your git repositories on 284 - Tangled. If you've used GitHub Pages or Codeberg Pages, this should feel 285 - familiar. 286 - 287 - ## Overview 288 - 289 - Every user gets a sites domain. If you signed up through Tangled's own 290 - PDS (`tngl.sh`), your sites domain is automatically 291 - `<your-handle>.tngl.sh` no setup needed. Otherwise, you can claim a 292 - `<subdomain>.tngl.io` domain from your settings. 293 - 294 - You can serve multiple sites per domain: 295 - 296 - - One **index site** served at the root of your domain (e.g. 297 - `alice.tngl.sh`) 298 - - Any number of **sub-path sites** served under the repository name 299 - (e.g. `alice.tngl.sh/my-project`) 300 - 301 - ## Claiming a domain 302 - 303 - If you don't have a `tngl.sh` handle, you need to claim a domain before 304 - publishing sites: 305 - 306 - 1. Go to **Settings → Sites** 307 - 2. Enter a subdomain (e.g. `alice` to claim `alice.tngl.io`) 308 - 3. Click **claim** 309 - 310 - You can only hold one domain at a time. Releasing a domain puts it in a 311 - 30-day cooldown before anyone else can claim it. 312 - 313 - ## Configuring a site for a repository 314 - 315 - 1. Navigate to your repository 316 - 2. Go to **Settings → Sites** 317 - 3. Choose a **branch** to deploy from 318 - 4. Set the **deploy directory** — the path within the repository 319 - containing your `index.html`. Use `/` for the root, or a subdirectory 320 - like `/docs` or `/public` 321 - 5. Choose the **site type**: 322 - - **Index site** — served at the root of your domain (e.g. 323 - `alice.tngl.sh`) 324 - - **Sub-path site** — served under the repository name (e.g. 325 - `alice.tngl.sh/my-project`) 326 - 6. Click **save** 327 - 328 - The site will be deployed automatically. You can see the status of your 329 - previous deploys in the **Recent Deploys** section at the bottom of the 330 - page. 331 - 332 - Sites are redeployed automatically on every push to the configured 333 - branch. 334 - 335 - ## Custom domains 336 - 337 - Tangled currently doesn't support custom domains for sites. This will be 338 - added in a future update. 339 - 340 - ## Deploy directory 341 - 342 - The deploy directory is the path within your repository that Tangled 343 - serves as the site root. It must contain an `index.html`. 344 - 345 - | Deploy directory | Result | 346 - |---|---| 347 - | `/` | Serves the repository root | 348 - | `/docs` | Serves the `docs/` subdirectory | 349 - | `/public` | Serves the `public/` subdirectory | 350 - 351 - Directories are served with automatic `index.html` resolution -- a 352 - request to `/about` will serve `/about/index.html` if it exists. 353 - 354 - ## Site types 355 - 356 - | Type | URL | 357 - |---|---| 358 - | Index site | `alice.tngl.sh` | 359 - | Sub-path site | `alice.tngl.sh/my-project` | 360 - 361 - Only one repository can be the index site for a given domain at a time. 362 - If another repository already holds the index site, you will see a 363 - notice in the settings and only the sub-path option will be available. 364 - 365 - ## Deploy triggers 366 - 367 - A deployment is triggered automatically when: 368 - 369 - - You push to the configured branch 370 - - You change the site configuration (branch, deploy directory, or site 371 - type) 372 - 373 - ## Disabling a site 374 - 375 - To stop serving a site, go to **Settings → Sites** in your repository 376 - and click **Disable**. This removes the site configuration and stops 377 - serving the site. The deployed files are also deleted from storage. 378 - 379 - Releasing your domain from **Settings → Sites** at the account level 380 - will disable all sites associated with it and delete their files. 381 - 382 - 383 281 # Knot self-hosting guide 384 282 385 283 So you want to run your own knot server? Great! Here are a few prerequisites: ··· 827 725 MY_ENV_VAR: "MY_ENV_VALUE" 828 726 ``` 829 727 830 - By default, the following environment variables are set: 728 + By default, the following environment variables set: 831 729 832 730 - `CI` - Always set to `true` to indicate a CI environment 833 731 - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline ··· 1338 1236 1339 1237 To set up a webhook for your repository: 1340 1238 1341 - 1. Navigate to your repository 1342 - 2. Go to **Settings → Hooks** 1343 - 3. Click **new webhook** 1239 + 1. Navigate to your repository settings 1240 + 2. Click the "hooks" tab 1241 + 3. Click "add webhook" 1344 1242 4. Configure your webhook: 1345 1243 - **Payload URL**: The endpoint that will receive the webhook POST requests 1346 - - **Secret**: An optional secret key for verifying webhook authenticity (leave blank to send unsigned webhooks) 1244 + - **Secret**: An optional secret key for verifying webhook authenticity (auto-generated if left blank) 1347 1245 - **Events**: Select which events trigger the webhook (currently only push events) 1348 1246 - **Active**: Toggle whether the webhook is enabled 1349 1247 ··· 1572 1470 1573 1471 ## Running the appview 1574 1472 1575 - The appview requires Redis and OAuth JWKs. Start these 1576 - first, before launching the appview itself. 1473 + The Nix flake also exposes a few `app` attributes (run `nix 1474 + flake show` to see a full list of what the flake provides), 1475 + one of the apps runs the appview with the `air` 1476 + live-reloader: 1577 1477 1578 1478 ```bash 1479 + TANGLED_DEV=true nix run .#watch-appview 1480 + 1481 + # TANGLED_DB_PATH might be of interest to point to 1482 + # different sqlite DBs 1483 + 1484 + # in a separate shell, you can live-reload tailwind 1485 + nix run .#watch-tailwind 1486 + ``` 1487 + 1488 + To authenticate with the appview, you will need Redis and 1489 + OAuth JWKs to be set up: 1490 + 1491 + ``` 1579 1492 # OAuth JWKs should already be set up by the Nix devshell: 1580 1493 echo $TANGLED_OAUTH_CLIENT_SECRET 1581 1494 z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc ··· 1596 1509 1597 1510 # Run Redis in a new shell to store OAuth sessions 1598 1511 redis-server 1599 - ``` 1600 - 1601 - The Nix flake exposes a few `app` attributes (run `nix 1602 - flake show` to see a full list of what the flake provides), 1603 - one of the apps runs the appview with the `air` 1604 - live-reloader: 1605 - 1606 - ```bash 1607 - TANGLED_DEV=true nix run .#watch-appview 1608 - 1609 - # TANGLED_DB_PATH might be of interest to point to 1610 - # different sqlite DBs 1611 - 1612 - # in a separate shell, you can live-reload tailwind 1613 - nix run .#watch-tailwind 1614 1512 ``` 1615 1513 1616 1514 ## Running knots and spindles
+7 -1
docs/search.html
··· 1 - <div class="pagefind-search"></div> 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
+91 -206
docs/styles.css
··· 1 - :root { 2 - --pagefind-ui-scale: 0.875 !important; 3 - --pagefind-ui-border-width: 1px !important; 4 - --pagefind-ui-border-radius: 4px !important; 5 - --pagefind-ui-border: #d1d5db !important; 6 - --pagefind-ui-background: #f9fafb !important; 7 - --pagefind-ui-text: #111827 !important; 8 - --pagefind-ui-primary: #6b7280 !important; 9 - --pagefind-ui-font: inherit !important; 10 - } 11 - 12 - .pagefind-ui__search-input { 13 - font-weight: normal !important; 14 - } 15 - 16 - @media (prefers-color-scheme: dark) { 17 - :root { 18 - --pagefind-ui-border: #4b5563 !important; 19 - --pagefind-ui-background: #1f2937 !important; 20 - --pagefind-ui-text: #f9fafb !important; 21 - --pagefind-ui-primary: #9ca3af !important; 22 - } 23 - } 24 - 25 - @media (max-width: 768px) { 26 - .pagefind-ui__result-thumb { 27 - width: 0 !important; 28 - } 29 - .pagefind-ui__result-excerpt, 30 - .pagefind-ui__result-title { 31 - overflow-wrap: break-word; 32 - word-break: break-word; 33 - } 34 - .pagefind-ui__search-input, 35 - .pagefind-ui__search-clear { 36 - max-width: 100%; 37 - } 38 - .pagefind-ui__form { 39 - overflow: hidden; 40 - } 41 - } 42 - 43 1 svg { 44 - width: 16px; 45 - height: 16px; 2 + width: 16px; 3 + height: 16px; 46 4 } 47 5 48 6 :root { 49 - --syntax-alert: #d20f39; 50 - --syntax-annotation: #fe640b; 51 - --syntax-attribute: #df8e1d; 52 - --syntax-basen: #40a02b; 53 - --syntax-builtin: #1e66f5; 54 - --syntax-controlflow: #8839ef; 55 - --syntax-char: #04a5e5; 56 - --syntax-constant: #fe640b; 57 - --syntax-comment: #9ca0b0; 58 - --syntax-commentvar: #7c7f93; 59 - --syntax-documentation: #9ca0b0; 60 - --syntax-datatype: #df8e1d; 61 - --syntax-decval: #40a02b; 62 - --syntax-error: #d20f39; 63 - --syntax-extension: #4c4f69; 64 - --syntax-float: #40a02b; 65 - --syntax-function: #1e66f5; 66 - --syntax-import: #40a02b; 67 - --syntax-information: #04a5e5; 68 - --syntax-keyword: #8839ef; 69 - --syntax-operator: #179299; 70 - --syntax-other: #8839ef; 71 - --syntax-preprocessor: #ea76cb; 72 - --syntax-specialchar: #04a5e5; 73 - --syntax-specialstring: #ea76cb; 74 - --syntax-string: #40a02b; 75 - --syntax-variable: #8839ef; 76 - --syntax-verbatimstring: #40a02b; 77 - --syntax-warning: #df8e1d; 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 78 36 } 79 37 80 38 @media (prefers-color-scheme: dark) { 81 - :root { 82 - --syntax-alert: #f38ba8; 83 - --syntax-annotation: #fab387; 84 - --syntax-attribute: #f9e2af; 85 - --syntax-basen: #a6e3a1; 86 - --syntax-builtin: #89b4fa; 87 - --syntax-controlflow: #cba6f7; 88 - --syntax-char: #89dceb; 89 - --syntax-constant: #fab387; 90 - --syntax-comment: #6c7086; 91 - --syntax-commentvar: #585b70; 92 - --syntax-documentation: #6c7086; 93 - --syntax-datatype: #f9e2af; 94 - --syntax-decval: #a6e3a1; 95 - --syntax-error: #f38ba8; 96 - --syntax-extension: #cdd6f4; 97 - --syntax-float: #a6e3a1; 98 - --syntax-function: #89b4fa; 99 - --syntax-import: #a6e3a1; 100 - --syntax-information: #89dceb; 101 - --syntax-keyword: #cba6f7; 102 - --syntax-operator: #94e2d5; 103 - --syntax-other: #cba6f7; 104 - --syntax-preprocessor: #f5c2e7; 105 - --syntax-specialchar: #89dceb; 106 - --syntax-specialstring: #f5c2e7; 107 - --syntax-string: #a6e3a1; 108 - --syntax-variable: #cba6f7; 109 - --syntax-verbatimstring: #a6e3a1; 110 - --syntax-warning: #f9e2af; 111 - } 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 112 70 } 113 71 114 72 /* pandoc syntax highlighting classes */ 115 - code span.al { 116 - color: var(--syntax-alert); 117 - font-weight: bold; 118 - } /* alert */ 119 - code span.an { 120 - color: var(--syntax-annotation); 121 - font-weight: bold; 122 - font-style: italic; 123 - } /* annotation */ 124 - code span.at { 125 - color: var(--syntax-attribute); 126 - } /* attribute */ 127 - code span.bn { 128 - color: var(--syntax-basen); 129 - } /* basen */ 130 - code span.bu { 131 - color: var(--syntax-builtin); 132 - } /* builtin */ 133 - code span.cf { 134 - color: var(--syntax-controlflow); 135 - font-weight: bold; 136 - } /* controlflow */ 137 - code span.ch { 138 - color: var(--syntax-char); 139 - } /* char */ 140 - code span.cn { 141 - color: var(--syntax-constant); 142 - } /* constant */ 143 - code span.co { 144 - color: var(--syntax-comment); 145 - font-style: italic; 146 - } /* comment */ 147 - code span.cv { 148 - color: var(--syntax-commentvar); 149 - font-weight: bold; 150 - font-style: italic; 151 - } /* commentvar */ 152 - code span.do { 153 - color: var(--syntax-documentation); 154 - font-style: italic; 155 - } /* documentation */ 156 - code span.dt { 157 - color: var(--syntax-datatype); 158 - } /* datatype */ 159 - code span.dv { 160 - color: var(--syntax-decval); 161 - } /* decval */ 162 - code span.er { 163 - color: var(--syntax-error); 164 - font-weight: bold; 165 - } /* error */ 166 - code span.ex { 167 - color: var(--syntax-extension); 168 - } /* extension */ 169 - code span.fl { 170 - color: var(--syntax-float); 171 - } /* float */ 172 - code span.fu { 173 - color: var(--syntax-function); 174 - } /* function */ 175 - code span.im { 176 - color: var(--syntax-import); 177 - font-weight: bold; 178 - } /* import */ 179 - code span.in { 180 - color: var(--syntax-information); 181 - font-weight: bold; 182 - font-style: italic; 183 - } /* information */ 184 - code span.kw { 185 - color: var(--syntax-keyword); 186 - font-weight: bold; 187 - } /* keyword */ 188 - code span.op { 189 - color: var(--syntax-operator); 190 - } /* operator */ 191 - code span.ot { 192 - color: var(--syntax-other); 193 - } /* other */ 194 - code span.pp { 195 - color: var(--syntax-preprocessor); 196 - } /* preprocessor */ 197 - code span.sc { 198 - color: var(--syntax-specialchar); 199 - } /* specialchar */ 200 - code span.ss { 201 - color: var(--syntax-specialstring); 202 - } /* specialstring */ 203 - code span.st { 204 - color: var(--syntax-string); 205 - } /* string */ 206 - code span.va { 207 - color: var(--syntax-variable); 208 - } /* variable */ 209 - code span.vs { 210 - color: var(--syntax-verbatimstring); 211 - } /* verbatimstring */ 212 - code span.wa { 213 - color: var(--syntax-warning); 214 - font-weight: bold; 215 - font-style: italic; 216 - } /* warning */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+7 -22
docs/template.html
··· 34 34 $header-includes$ 35 35 $endfor$ 36 36 37 - <link href="$root$pagefind/pagefind-ui.css" rel="stylesheet"> 38 - <script src="$root$pagefind/pagefind-ui.js"></script> 39 - <script> 40 - window.addEventListener("DOMContentLoaded", () => { 41 - document.querySelectorAll(".pagefind-search").forEach((el) => { 42 - new PagefindUI({ 43 - element: el, 44 - showSubResults: true, 45 - resetStyles: false, 46 - }); 47 - }); 48 - }); 49 - </script> 50 - <link rel="preload" href="$root$static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 51 - <link rel="icon" href="$root$static/logos/dolly.ico" sizes="48x48"/> 52 - <link rel="icon" href="$root$static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 53 - <link rel="apple-touch-icon" href="$root$static/logos/dolly.png"/> 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 39 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 40 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 54 41 55 42 </head> 56 - <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"$if(single-page)$ data-pagefind-ignore$endif$> 43 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 57 44 $for(include-before)$ 58 45 $include-before$ 59 46 $endfor$ 60 47 61 48 $if(toc)$ 62 49 <!-- mobile TOC trigger --> 63 - <div data-pagefind-ignore class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 50 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 64 51 <button 65 52 type="button" 66 53 popovertarget="mobile-toc-popover" ··· 75 62 <div 76 63 id="mobile-toc-popover" 77 64 popover 78 - data-pagefind-ignore 79 65 class="mobile-toc-popover 80 66 bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 81 - h-full overflow-x-hidden overflow-y-auto shadow-sm 67 + h-full overflow-y-auto shadow-sm 82 68 px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 83 69 > 84 70 <div class="flex flex-col min-h-full"> ··· 103 89 <nav 104 90 id="$idprefix$TOC" 105 91 role="doc-toc" 106 - data-pagefind-ignore 107 92 class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 108 93 bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 109 94 p-4 z-50 overflow-y-auto">
+3 -55
flake.lock
··· 16 16 "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 17 17 } 18 18 }, 19 - "fenix": { 20 - "inputs": { 21 - "nixpkgs": [ 22 - "nixpkgs" 23 - ], 24 - "rust-analyzer-src": "rust-analyzer-src" 25 - }, 26 - "locked": { 27 - "lastModified": 1772176312, 28 - "narHash": "sha256-Yjo/QCJvY9GUhAzwac/m6Rx3oxvRyEaiT5DQ5o+T6g4=", 29 - "owner": "nix-community", 30 - "repo": "fenix", 31 - "rev": "92d91250c1acd59beabc51208192adc92f31aeb5", 32 - "type": "github" 33 - }, 34 - "original": { 35 - "owner": "nix-community", 36 - "repo": "fenix", 37 - "type": "github" 38 - } 39 - }, 40 19 "flake-compat": { 41 20 "flake": false, 42 21 "locked": { ··· 169 148 "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 170 149 } 171 150 }, 172 - "mermaid-src": { 173 - "flake": false, 174 - "locked": { 175 - "narHash": "sha256-/YOdECG2V5c3kJ1QfGvhziTT6K/Dx/4mOk2mr3Fs/do=", 176 - "type": "file", 177 - "url": "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js" 178 - }, 179 - "original": { 180 - "type": "file", 181 - "url": "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js" 182 - } 183 - }, 184 151 "nixpkgs": { 185 152 "locked": { 186 - "lastModified": 1771848320, 187 - "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 188 155 "owner": "nixos", 189 156 "repo": "nixpkgs", 190 - "rev": "2fc6539b481e1d2569f25f8799236694180c0993", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 191 158 "type": "github" 192 159 }, 193 160 "original": { ··· 200 167 "root": { 201 168 "inputs": { 202 169 "actor-typeahead-src": "actor-typeahead-src", 203 - "fenix": "fenix", 204 170 "flake-compat": "flake-compat", 205 171 "gomod2nix": "gomod2nix", 206 172 "htmx-src": "htmx-src", ··· 209 175 "indigo": "indigo", 210 176 "inter-fonts-src": "inter-fonts-src", 211 177 "lucide-src": "lucide-src", 212 - "mermaid-src": "mermaid-src", 213 178 "nixpkgs": "nixpkgs", 214 179 "sqlite-lib-src": "sqlite-lib-src" 215 - } 216 - }, 217 - "rust-analyzer-src": { 218 - "flake": false, 219 - "locked": { 220 - "lastModified": 1772094427, 221 - "narHash": "sha256-TiVs6OUBJEvajHdJZ5nIq0KognNJooUWuLGPFfQacSw=", 222 - "owner": "rust-lang", 223 - "repo": "rust-analyzer", 224 - "rev": "56b59a832858329c2f947f9b7bdf1a49da39c981", 225 - "type": "github" 226 - }, 227 - "original": { 228 - "owner": "rust-lang", 229 - "ref": "nightly", 230 - "repo": "rust-analyzer", 231 - "type": "github" 232 180 } 233 181 }, 234 182 "sqlite-lib-src": {
+3 -46
flake.nix
··· 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 - fenix = { 7 - url = "github:nix-community/fenix"; 8 - inputs.nixpkgs.follows = "nixpkgs"; 9 - }; 10 6 gomod2nix = { 11 7 url = "github:nix-community/gomod2nix"; 12 8 inputs.nixpkgs.follows = "nixpkgs"; ··· 41 37 url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 42 38 flake = false; 43 39 }; 44 - mermaid-src = { 45 - url = "https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js"; 46 - flake = false; 47 - }; 48 40 ibm-plex-mono-src = { 49 41 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 50 42 flake = false; ··· 58 50 outputs = { 59 51 self, 60 52 nixpkgs, 61 - fenix, 62 53 gomod2nix, 63 54 indigo, 64 55 htmx-src, ··· 68 59 sqlite-lib-src, 69 60 ibm-plex-mono-src, 70 61 actor-typeahead-src, 71 - mermaid-src, 72 62 ... 73 63 }: let 74 64 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 95 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 96 86 goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 97 87 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 98 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src mermaid-src; 88 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 99 89 }; 100 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 101 91 docs = self.callPackage ./nix/pkgs/docs.nix { 102 92 inherit inter-fonts-src ibm-plex-mono-src lucide-src; 103 - inherit (pkgs) pagefind; 104 93 }; 105 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 106 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; ··· 191 180 pkgs.tailwindcss 192 181 pkgs.nixos-shell 193 182 pkgs.redis 194 - pkgs.worker-build 195 - pkgs.cargo-generate 196 - (fenix.packages.${system}.combine [ 197 - fenix.packages.${system}.stable.cargo 198 - fenix.packages.${system}.stable.rustc 199 - fenix.packages.${system}.stable.rust-src 200 - fenix.packages.${system}.stable.clippy 201 - fenix.packages.${system}.stable.rustfmt 202 - fenix.packages.${system}.targets.wasm32-unknown-unknown.stable.rust-std 203 - ]) 204 183 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 205 184 packages'.lexgen 206 185 packages'.treefmt-wrapper 207 186 ]; 208 187 shellHook = '' 209 188 mkdir -p appview/pages/static 210 - # temporary self-heal for workspaces that copied static assets as read-only 211 - [ -d appview/pages/static/icons ] && [ ! -w appview/pages/static/icons ] && chmod -R u+rwX appview/pages/static 212 189 # no preserve is needed because watch-tailwind will want to be able to overwrite 213 - cp -fr --no-preserve=ownership,mode ${packages'.appview-static-files}/* appview/pages/static 190 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 214 191 export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 215 192 export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 216 193 ''; ··· 241 218 type = "app"; 242 219 program = toString (pkgs.writeShellScript "watch-appview" '' 243 220 echo "copying static files to appview/pages/static..." 244 - mkdir -p appview/pages/static 245 - ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership,mode ${packages'.appview-static-files}/* appview/pages/static 221 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 246 222 ${air-watcher "appview" ""}/bin/run 247 223 ''); 248 224 }; ··· 254 230 type = "app"; 255 231 program = ''${air-watcher "spindle" ""}/bin/run''; 256 232 }; 257 - watch-blog = { 258 - type = "app"; 259 - program = toString (pkgs.writeShellScript "watch-blog" '' 260 - echo "copying static files to appview/pages/static..." 261 - mkdir -p appview/pages/static 262 - ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership,mode ${packages'.appview-static-files}/* appview/pages/static 263 - ${air-watcher "blog" "serve"}/bin/run 264 - ''); 265 - }; 266 233 watch-tailwind = { 267 234 type = "app"; 268 235 program = ''${tailwind-watcher}/bin/run''; 269 - }; 270 - serve-docs = { 271 - type = "app"; 272 - program = toString (pkgs.writeShellScript "serve-docs" '' 273 - echo "building docs..." 274 - docsOut=$(nix build --no-link --print-out-paths .#docs) 275 - echo "serving docs at http://localhost:1414" 276 - cd "$docsOut" 277 - exec ${pkgs.python3}/bin/python3 -m http.server 1414 278 - ''); 279 236 }; 280 237 vm = let 281 238 guestSystem =
+1 -21
go.mod
··· 7 7 github.com/alecthomas/assert/v2 v2.11.0 8 8 github.com/alecthomas/chroma/v2 v2.23.1 9 9 github.com/avast/retry-go/v4 v4.6.1 10 - github.com/aws/aws-sdk-go-v2 v1.41.1 11 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 12 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 13 10 github.com/blevesearch/bleve/v2 v2.5.3 14 11 github.com/bluekeyes/go-gitdiff v0.8.1 15 12 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e ··· 18 15 github.com/carlmjohnson/versioninfo v0.22.5 19 16 github.com/casbin/casbin/v2 v2.103.0 20 17 github.com/charmbracelet/log v0.4.2 21 - github.com/cloudflare/cloudflare-go/v6 v6.7.0 18 + github.com/cloudflare/cloudflare-go v0.115.0 22 19 github.com/cyphar/filepath-securejoin v0.4.1 23 20 github.com/dgraph-io/ristretto v0.2.0 24 21 github.com/docker/docker v28.2.2+incompatible ··· 51 48 github.com/yuin/goldmark-emoji v1.0.6 52 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 53 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 54 - go.abhg.dev/goldmark/mermaid v0.6.0 55 51 golang.org/x/crypto v0.40.0 56 52 golang.org/x/image v0.31.0 57 53 golang.org/x/net v0.42.0 ··· 61 57 62 58 require ( 63 59 dario.cat/mergo v1.0.1 // indirect 64 - github.com/BurntSushi/toml v0.3.1 // indirect 65 60 github.com/Microsoft/go-winio v0.6.2 // indirect 66 61 github.com/ProtonMail/go-crypto v1.3.0 // indirect 67 62 github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 68 - github.com/adrg/frontmatter v0.2.0 // indirect 69 63 github.com/alecthomas/repr v0.5.2 // indirect 70 64 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 71 - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 72 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect 73 - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect 74 - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect 75 - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 76 - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect 77 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect 78 - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect 79 - github.com/aws/smithy-go v1.24.0 // indirect 80 65 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 81 66 github.com/aymerick/douceur v0.2.0 // indirect 82 67 github.com/beorn7/perks v1.0.1 // indirect ··· 200 185 github.com/ryanuber/go-glob v1.0.0 // indirect 201 186 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 202 187 github.com/spaolacci/murmur3 v1.1.0 // indirect 203 - github.com/tidwall/gjson v1.18.0 // indirect 204 - github.com/tidwall/match v1.2.0 // indirect 205 - github.com/tidwall/pretty v1.2.1 // indirect 206 - github.com/tidwall/sjson v1.2.5 // indirect 207 188 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 208 189 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 209 190 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect ··· 233 214 gopkg.in/fsnotify.v1 v1.4.7 // indirect 234 215 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 235 216 gopkg.in/warnings.v0 v0.1.2 // indirect 236 - gopkg.in/yaml.v2 v2.4.0 // indirect 237 217 gotest.tools/v3 v3.5.2 // indirect 238 218 lukechampine.com/blake3 v1.4.1 // indirect 239 219 )
+2 -57
go.sum
··· 4 4 github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 5 5 github.com/Blank-Xu/sql-adapter v1.1.1 h1:+g7QXU9sl/qT6Po97teMpf3GjAO0X9aFaqgSePXvYko= 6 6 github.com/Blank-Xu/sql-adapter v1.1.1/go.mod h1:o2g8EZhZ3TudnYEGDkoU+3jCTCgDgx1o/Ig5ajKkaLY= 7 - github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 8 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 9 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 10 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= ··· 12 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 13 12 github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 14 13 github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 15 - github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= 16 - github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= 17 14 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 18 15 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 19 16 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= ··· 25 22 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 26 23 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 27 24 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 28 - github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= 29 - github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= 30 - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= 31 - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= 32 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= 33 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= 34 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= 35 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= 36 - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= 37 - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= 38 - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= 39 - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= 40 - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= 41 - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= 42 - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= 43 - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= 44 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= 45 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= 46 - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= 47 - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= 48 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= 49 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= 50 - github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 51 - github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 52 25 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 53 26 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 54 27 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= ··· 132 105 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 133 106 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 134 107 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 135 - github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= 136 - github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= 137 - github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= 138 - github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= 139 - github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= 140 - github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 141 108 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 142 109 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 143 110 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 144 111 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 145 112 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 146 - github.com/cloudflare/cloudflare-go/v6 v6.7.0 h1:MP6Xy5WmsyrxgTxoLeq/vraqR0nbTtXoHhW4vAYc4SY= 147 - github.com/cloudflare/cloudflare-go/v6 v6.7.0/go.mod h1:Lj3MUqjvKctXRpdRhLQxZYRrNZHuRs0XYuH8JtQGyoI= 113 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 114 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 148 115 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 149 116 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 150 117 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 208 175 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 209 176 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 210 177 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 211 - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= 212 - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 213 178 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 214 179 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 215 180 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= ··· 224 189 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 225 190 github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 226 191 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 227 - github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 228 - github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 229 - github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 230 - github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 231 - github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 232 - github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 233 192 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 234 193 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 235 194 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= ··· 523 482 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 524 483 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 525 484 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 526 - github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 527 - github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 528 - github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 529 - github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 530 - github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= 531 - github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 532 - github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 533 - github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 534 - github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 535 - github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 536 - github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 537 485 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 538 486 github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 539 487 github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= ··· 568 516 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 569 517 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 570 518 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 571 - go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY= 572 - go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I= 573 519 go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 574 520 go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 575 521 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= ··· 773 719 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 774 720 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 775 721 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 776 - gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 777 722 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 778 723 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 779 724 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+27 -91
input.css
··· 90 90 } 91 91 92 92 label { 93 - @apply block text-gray-900 text-sm py-2 dark:text-gray-100; 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 94 } 95 - input, 96 - textarea { 97 - @apply block rounded p-3 95 + input, textarea { 96 + @apply 97 + block rounded p-3 98 98 bg-gray-50 dark:bg-gray-800 dark:text-white 99 99 border border-gray-300 dark:border-gray-600 100 100 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; ··· 126 126 } 127 127 128 128 .btn-flat { 129 - @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 129 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 130 130 bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 131 131 before:absolute before:inset-0 before:-z-10 before:block before:rounded 132 132 before:border before:border-gray-200 before:bg-white ··· 215 215 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 216 216 } 217 217 218 - /* Mermaid diagrams */ 219 - .prose pre.mermaid { 220 - @apply flex justify-center my-4 overflow-x-auto bg-transparent border-0; 221 - } 222 - 223 218 /* Base callout */ 224 219 details[data-callout] { 225 220 @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; ··· 277 272 details[data-callout] > summary::-webkit-details-marker { 278 273 display: none; 279 274 } 275 + 280 276 } 281 277 @layer utilities { 282 278 .error { ··· 285 281 .success { 286 282 @apply py-1 text-gray-900 dark:text-gray-100; 287 283 } 288 - 289 - @keyframes scroll { 290 - 0% { transform: translateX(0); } 291 - 100% { transform: translateX(-50%); } 292 - } 293 - 294 - .animate-marquee { 295 - animation: scroll 60s linear infinite; 296 - } 297 - 298 - .animate-marquee:hover { 299 - animation-play-state: paused; 300 - } 301 - 302 - @media (prefers-reduced-motion: reduce) { 303 - .animate-marquee { 304 - animation: none; 305 - transform: none; 306 - } 307 - } 308 - 309 - @keyframes progress { 310 - from { width: 0%; } 311 - to { width: 100%; } 312 - } 313 - 314 - .animate-progress { 315 - animation: progress 10s linear forwards; 316 - } 317 - 318 - @keyframes fadeIn { 319 - from { opacity: 0; } 320 - to { opacity: 1; } 321 - } 322 - 323 - @keyframes fadeOut { 324 - from { opacity: 1; } 325 - to { opacity: 0; } 326 - } 284 + } 327 285 328 - .animate-fadein { 329 - animation: fadeIn 0.25s ease-in forwards; 330 - } 331 - 332 - .animate-fadeout { 333 - animation: fadeOut 0.25s ease-out forwards; 334 - } 335 - } 336 286 } 337 287 338 288 /* Background */ ··· 371 321 /* LineHighlight */ 372 322 .chroma .hl { 373 323 @apply bg-amber-400/30 dark:bg-amber-500/20; 374 - } 375 - 376 - .line-quote-hl, .line-range-hl { 377 - @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 378 - } 379 - 380 - :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] { 381 - @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 382 - } 383 - 384 - :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] a { 385 - @apply !text-black dark:!text-white; 386 324 } 387 325 388 326 /* LineNumbersTable */ ··· 1021 959 } 1022 960 1023 961 actor-typeahead { 1024 - --color-background: #ffffff; 1025 - --color-border: #d1d5db; 1026 - --color-shadow: #000000; 1027 - --color-hover: #f9fafb; 1028 - --color-avatar-fallback: #e5e7eb; 1029 - --radius: 0; 1030 - --padding-menu: 0rem; 1031 - z-index: 1000; 962 + --color-background: #ffffff; 963 + --color-border: #d1d5db; 964 + --color-shadow: #000000; 965 + --color-hover: #f9fafb; 966 + --color-avatar-fallback: #e5e7eb; 967 + --radius: 0.0; 968 + --padding-menu: 0.0rem; 969 + z-index: 1000; 1032 970 } 1033 971 1034 972 actor-typeahead::part(handle) { 1035 - color: #111827; 973 + color: #111827; 1036 974 } 1037 975 1038 976 actor-typeahead::part(menu) { 1039 - box-shadow: 1040 - 0 4px 6px -1px rgb(0 0 0 / 0.1), 1041 - 0 2px 4px -2px rgb(0 0 0 / 0.1); 977 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1042 978 } 1043 979 1044 980 @media (prefers-color-scheme: dark) { 1045 - actor-typeahead { 1046 - --color-background: #1f2937; 1047 - --color-border: #4b5563; 1048 - --color-shadow: #000000; 1049 - --color-hover: #374151; 1050 - --color-avatar-fallback: #4b5563; 1051 - } 981 + actor-typeahead { 982 + --color-background: #1f2937; 983 + --color-border: #4b5563; 984 + --color-shadow: #000000; 985 + --color-hover: #374151; 986 + --color-avatar-fallback: #4b5563; 987 + } 1052 988 1053 - actor-typeahead::part(handle) { 1054 - color: #f9fafb; 1055 - } 989 + actor-typeahead::part(handle) { 990 + color: #f9fafb; 991 + } 1056 992 }
+34 -24
knotserver/git.go
··· 5 5 "fmt" 6 6 "io" 7 7 "net/http" 8 - "os" 8 + "path/filepath" 9 9 "strings" 10 10 11 + securejoin "github.com/cyphar/filepath-securejoin" 11 12 "github.com/go-chi/chi/v5" 12 13 "tangled.org/core/knotserver/git/service" 13 14 ) 14 15 15 16 func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 + did := chi.URLParam(r, "did") 16 18 name := chi.URLParam(r, "name") 17 - repoPath, ok := repoPathFromcontext(r.Context()) 18 - if !ok { 19 - w.WriteHeader(http.StatusInternalServerError) 20 - w.Write([]byte("Failed to find repository path")) 19 + repoName, err := securejoin.SecureJoin(did, name) 20 + if err != nil { 21 + gitError(w, "repository not found", http.StatusNotFound) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 + return 24 + } 25 + 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 + if err != nil { 28 + gitError(w, "repository not found", http.StatusNotFound) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 21 30 return 22 31 } 23 32 ··· 48 57 } 49 58 50 59 func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 51 - repo, ok := repoPathFromcontext(r.Context()) 52 - if !ok { 53 - w.WriteHeader(http.StatusInternalServerError) 54 - w.Write([]byte("Failed to find repository path")) 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 55 66 return 56 67 } 57 68 ··· 93 104 } 94 105 95 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 96 - repo, ok := repoPathFromcontext(r.Context()) 97 - if !ok { 98 - w.WriteHeader(http.StatusInternalServerError) 99 - w.Write([]byte("Failed to find repository path")) 107 + did := chi.URLParam(r, "did") 108 + name := chi.URLParam(r, "name") 109 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 110 + if err != nil { 111 + gitError(w, err.Error(), http.StatusInternalServerError) 112 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 100 113 return 101 114 } 102 115 ··· 140 153 } 141 154 142 155 func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 156 + did := chi.URLParam(r, "did") 143 157 name := chi.URLParam(r, "name") 158 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 159 + if err != nil { 160 + gitError(w, err.Error(), http.StatusForbidden) 161 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 162 + return 163 + } 164 + 144 165 h.RejectPush(w, r, name) 145 166 } 146 167 ··· 169 190 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName) 170 191 } 171 192 fmt.Fprintf(w, "\n\n") 172 - } 173 - 174 - func isDir(path string) (bool, error) { 175 - info, err := os.Stat(path) 176 - if err == nil && info.IsDir() { 177 - return true, nil 178 - } 179 - if os.IsNotExist(err) { 180 - return false, nil 181 - } 182 - return false, err 183 193 } 184 194 185 195 func gitError(w http.ResponseWriter, msg string, status int) {
-40
knotserver/router.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 - "path/filepath" 9 8 "strings" 10 9 11 - securejoin "github.com/cyphar/filepath-securejoin" 12 10 "github.com/go-chi/chi/v5" 13 11 "tangled.org/core/idresolver" 14 12 "tangled.org/core/jetstream" ··· 83 81 84 82 r.Route("/{did}", func(r chi.Router) { 85 83 r.Use(h.resolveDidRedirect) 86 - r.Use(h.resolveRepo) 87 84 r.Route("/{name}", func(r chi.Router) { 88 85 // routes for git operations 89 86 r.Get("/info/refs", h.InfoRefs) ··· 141 138 suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 142 139 newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery) 143 140 http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 144 - }) 145 - } 146 - 147 - type ctxRepoPathKey struct{} 148 - 149 - func repoPathFromcontext(ctx context.Context) (string, bool) { 150 - v, ok := ctx.Value(ctxRepoPathKey{}).(string) 151 - return v, ok 152 - } 153 - 154 - // resolveRepo is a http middleware that constructs git repo path from given did & name pair. 155 - // It will reject the requests to unknown repos (when dir doesn't exist) 156 - func (h *Knot) resolveRepo(next http.Handler) http.Handler { 157 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 158 - did := chi.URLParam(r, "did") 159 - name := chi.URLParam(r, "name") 160 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 161 - if err != nil { 162 - w.WriteHeader(http.StatusNotFound) 163 - w.Write([]byte("Repository not found")) 164 - return 165 - } 166 - 167 - exist, err := isDir(repoPath) 168 - if err != nil { 169 - w.WriteHeader(http.StatusInternalServerError) 170 - w.Write([]byte("Failed to check repository path")) 171 - return 172 - } 173 - if !exist { 174 - w.WriteHeader(http.StatusNotFound) 175 - w.Write([]byte("Repository not found")) 176 - return 177 - } 178 - 179 - ctx := context.WithValue(r.Context(), "repoPath", repoPath) 180 - next.ServeHTTP(w, r.WithContext(ctx)) 181 141 }) 182 142 } 183 143
+1 -9
knotserver/xrpc/repo_blob.go
··· 74 74 75 75 mimeType := http.DetectContentType(contents) 76 76 77 - // override MIME types for formats that http.DetectContentType does not recognize 78 - switch filepath.Ext(treePath) { 79 - case ".svg": 77 + if filepath.Ext(treePath) == ".svg" { 80 78 mimeType = "image/svg+xml" 81 - case ".avif": 82 - mimeType = "image/avif" 83 - case ".jxl": 84 - mimeType = "image/jxl" 85 - case ".heic", ".heif": 86 - mimeType = "image/heif" 87 79 } 88 80 89 81 if raw {
+3 -54
nix/gomod2nix.toml
··· 32 32 [mod."github.com/avast/retry-go/v4"] 33 33 version = "v4.6.1" 34 34 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 35 - [mod."github.com/aws/aws-sdk-go-v2"] 36 - version = "v1.41.1" 37 - hash = "sha256-umafTZB+cuy8+Kzpl2WrlygJO3tR3D2WkTC0eTY5G/g=" 38 - [mod."github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"] 39 - version = "v1.7.4" 40 - hash = "sha256-ZY/Jn1p0IgDe8MONhp0RFHZmRgTBZZ5ddqXlNWEo7Ys=" 41 - [mod."github.com/aws/aws-sdk-go-v2/credentials"] 42 - version = "v1.19.9" 43 - hash = "sha256-eqM5BmetQ/MrxTwoUCqRpkm5frFAspuq+QWod+5wzrU=" 44 - [mod."github.com/aws/aws-sdk-go-v2/internal/configsources"] 45 - version = "v1.4.17" 46 - hash = "sha256-W+d0WDYBrVJxdfRSvoe3cvZYgIgUSVyXsVdQlMcIZvc=" 47 - [mod."github.com/aws/aws-sdk-go-v2/internal/endpoints/v2"] 48 - version = "v2.7.17" 49 - hash = "sha256-dKJx0+K1DKi2LJKQNwnTofZH4GWLDgY6/ZI2XR2oCGI=" 50 - [mod."github.com/aws/aws-sdk-go-v2/internal/v4a"] 51 - version = "v1.4.17" 52 - hash = "sha256-PKwNn9nFf+7TqocgQprxP9r1Fs4IxwGLGd05MrsxmLg=" 53 - [mod."github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding"] 54 - version = "v1.13.4" 55 - hash = "sha256-Rm6czqOnOULP080D97WQQSqkBhmN6ei1qZaTa51SRj8=" 56 - [mod."github.com/aws/aws-sdk-go-v2/service/internal/checksum"] 57 - version = "v1.9.8" 58 - hash = "sha256-DZhR0aqHrgAFBGSlnsSQ6XeAJ/q504RG/LBWhtQqRVg=" 59 - [mod."github.com/aws/aws-sdk-go-v2/service/internal/presigned-url"] 60 - version = "v1.13.17" 61 - hash = "sha256-qS7Db9S/KQ24kvJdL8qH4gnoN116J1ezwOnbovCiFEI=" 62 - [mod."github.com/aws/aws-sdk-go-v2/service/internal/s3shared"] 63 - version = "v1.19.17" 64 - hash = "sha256-x/Cb4j3HFlg1+U21YCAIhBnfpf1yehJU2Ss/PxamEMI=" 65 - [mod."github.com/aws/aws-sdk-go-v2/service/s3"] 66 - version = "v1.96.0" 67 - hash = "sha256-lzAn2KHIkd742mDwEZqGEaIXlEfvltL6HdP+sEQ/8YA=" 68 - [mod."github.com/aws/smithy-go"] 69 - version = "v1.24.0" 70 - hash = "sha256-ZPFhf2Yv3BQpUn3cN4wSnoO7uBki8oCisZxL6F09nnE=" 71 35 [mod."github.com/aymanbagabas/go-osc52/v2"] 72 36 version = "v2.0.1" 73 37 hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" ··· 183 147 [mod."github.com/cloudflare/circl"] 184 148 version = "v1.6.2-0.20250618153321-aa837fd1539d" 185 149 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 186 - [mod."github.com/cloudflare/cloudflare-go/v6"] 187 - version = "v6.7.0" 188 - hash = "sha256-ycQpx1II/JgBgrCRwY5qiVKStGv5wuCANy1091sJ5Zw=" 150 + [mod."github.com/cloudflare/cloudflare-go"] 151 + version = "v0.115.0" 152 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 189 153 [mod."github.com/containerd/errdefs"] 190 154 version = "v1.0.0" 191 155 hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" ··· 545 509 [mod."github.com/stretchr/testify"] 546 510 version = "v1.10.0" 547 511 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 548 - [mod."github.com/tidwall/gjson"] 549 - version = "v1.18.0" 550 - hash = "sha256-CO6hqDu8Y58Po6A01e5iTpwiUBQ5khUZsw7czaJHw0I=" 551 - [mod."github.com/tidwall/match"] 552 - version = "v1.2.0" 553 - hash = "sha256-O2wTU0SmNIEEOxfncl2BW2czgWeIW5vqR6+A7dtNtXI=" 554 - [mod."github.com/tidwall/pretty"] 555 - version = "v1.2.1" 556 - hash = "sha256-S0uTDDGD8qr415Ut7QinyXljCp0TkL4zOIrlJ+9OMl8=" 557 - [mod."github.com/tidwall/sjson"] 558 - version = "v1.2.5" 559 - hash = "sha256-OYGNolkmL7E1Qs2qrQ3IVpQp5gkcHNU/AB/z2O+Myps=" 560 512 [mod."github.com/urfave/cli/v3"] 561 513 version = "v3.3.3" 562 514 hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" ··· 593 545 [mod."gitlab.com/yawning/tuplehash"] 594 546 version = "v0.0.0-20230713102510-df83abbf9a02" 595 547 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 596 - [mod."go.abhg.dev/goldmark/mermaid"] 597 - version = "v0.6.0" 598 - hash = "sha256-JmjaCfzJU/M/R0TnXSzNwBaHmoLLooiXwQJeVRbZ3AQ=" 599 548 [mod."go.etcd.io/bbolt"] 600 549 version = "v1.4.0" 601 550 hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps="
+1 -2
nix/pkgs/appview-static-files.nix
··· 6 6 inter-fonts-src, 7 7 ibm-plex-mono-src, 8 8 actor-typeahead-src, 9 - mermaid-src, 9 + sqlite-lib, 10 10 tailwindcss, 11 11 dolly, 12 12 src, ··· 21 21 mkdir -p $out/{fonts,icons,logos} && cd $out 22 22 cp -f ${htmx-src} htmx.min.js 23 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 24 - cp -f ${mermaid-src} mermaid.min.js 25 24 cp -rf ${lucide-src}/*.svg icons/ 26 25 cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 27 26 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
-4
nix/pkgs/docs.nix
··· 1 1 { 2 2 pandoc, 3 - pagefind, 4 3 tailwindcss, 5 4 runCommandLocal, 6 5 inter-fonts-src, ··· 60 59 61 60 # styles 62 61 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 63 - 64 - # search index 65 - ${pagefind}/bin/pagefind --site $out --output-path $out/pagefind 66 62 ''
-4
sites/.gitignore
··· 1 - target 2 - node_modules 3 - .wrangler 4 - build
-758
sites/Cargo.lock
··· 1 - # This file is automatically @generated by Cargo. 2 - # It is not intended for manual editing. 3 - version = 4 4 - 5 - [[package]] 6 - name = "async-trait" 7 - version = "0.1.89" 8 - source = "registry+https://github.com/rust-lang/crates.io-index" 9 - checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 10 - dependencies = [ 11 - "proc-macro2", 12 - "quote", 13 - "syn", 14 - ] 15 - 16 - [[package]] 17 - name = "autocfg" 18 - version = "1.5.0" 19 - source = "registry+https://github.com/rust-lang/crates.io-index" 20 - checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 21 - 22 - [[package]] 23 - name = "bumpalo" 24 - version = "3.20.2" 25 - source = "registry+https://github.com/rust-lang/crates.io-index" 26 - checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 27 - 28 - [[package]] 29 - name = "bytes" 30 - version = "1.11.1" 31 - source = "registry+https://github.com/rust-lang/crates.io-index" 32 - checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 33 - 34 - [[package]] 35 - name = "cfg-if" 36 - version = "1.0.4" 37 - source = "registry+https://github.com/rust-lang/crates.io-index" 38 - checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 39 - 40 - [[package]] 41 - name = "chrono" 42 - version = "0.4.44" 43 - source = "registry+https://github.com/rust-lang/crates.io-index" 44 - checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 45 - dependencies = [ 46 - "js-sys", 47 - "num-traits", 48 - "wasm-bindgen", 49 - ] 50 - 51 - [[package]] 52 - name = "displaydoc" 53 - version = "0.2.5" 54 - source = "registry+https://github.com/rust-lang/crates.io-index" 55 - checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 56 - dependencies = [ 57 - "proc-macro2", 58 - "quote", 59 - "syn", 60 - ] 61 - 62 - [[package]] 63 - name = "form_urlencoded" 64 - version = "1.2.2" 65 - source = "registry+https://github.com/rust-lang/crates.io-index" 66 - checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 67 - dependencies = [ 68 - "percent-encoding", 69 - ] 70 - 71 - [[package]] 72 - name = "futures-channel" 73 - version = "0.3.32" 74 - source = "registry+https://github.com/rust-lang/crates.io-index" 75 - checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 76 - dependencies = [ 77 - "futures-core", 78 - ] 79 - 80 - [[package]] 81 - name = "futures-core" 82 - version = "0.3.32" 83 - source = "registry+https://github.com/rust-lang/crates.io-index" 84 - checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 85 - 86 - [[package]] 87 - name = "futures-io" 88 - version = "0.3.32" 89 - source = "registry+https://github.com/rust-lang/crates.io-index" 90 - checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 91 - 92 - [[package]] 93 - name = "futures-macro" 94 - version = "0.3.32" 95 - source = "registry+https://github.com/rust-lang/crates.io-index" 96 - checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 97 - dependencies = [ 98 - "proc-macro2", 99 - "quote", 100 - "syn", 101 - ] 102 - 103 - [[package]] 104 - name = "futures-sink" 105 - version = "0.3.32" 106 - source = "registry+https://github.com/rust-lang/crates.io-index" 107 - checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 108 - 109 - [[package]] 110 - name = "futures-task" 111 - version = "0.3.32" 112 - source = "registry+https://github.com/rust-lang/crates.io-index" 113 - checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 114 - 115 - [[package]] 116 - name = "futures-util" 117 - version = "0.3.32" 118 - source = "registry+https://github.com/rust-lang/crates.io-index" 119 - checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 120 - dependencies = [ 121 - "futures-core", 122 - "futures-io", 123 - "futures-macro", 124 - "futures-sink", 125 - "futures-task", 126 - "memchr", 127 - "pin-project-lite", 128 - "slab", 129 - ] 130 - 131 - [[package]] 132 - name = "http" 133 - version = "1.4.0" 134 - source = "registry+https://github.com/rust-lang/crates.io-index" 135 - checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 136 - dependencies = [ 137 - "bytes", 138 - "itoa", 139 - ] 140 - 141 - [[package]] 142 - name = "http-body" 143 - version = "1.0.1" 144 - source = "registry+https://github.com/rust-lang/crates.io-index" 145 - checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 146 - dependencies = [ 147 - "bytes", 148 - "http", 149 - ] 150 - 151 - [[package]] 152 - name = "icu_collections" 153 - version = "2.1.1" 154 - source = "registry+https://github.com/rust-lang/crates.io-index" 155 - checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 156 - dependencies = [ 157 - "displaydoc", 158 - "potential_utf", 159 - "yoke", 160 - "zerofrom", 161 - "zerovec", 162 - ] 163 - 164 - [[package]] 165 - name = "icu_locale_core" 166 - version = "2.1.1" 167 - source = "registry+https://github.com/rust-lang/crates.io-index" 168 - checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 169 - dependencies = [ 170 - "displaydoc", 171 - "litemap", 172 - "tinystr", 173 - "writeable", 174 - "zerovec", 175 - ] 176 - 177 - [[package]] 178 - name = "icu_normalizer" 179 - version = "2.1.1" 180 - source = "registry+https://github.com/rust-lang/crates.io-index" 181 - checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 182 - dependencies = [ 183 - "icu_collections", 184 - "icu_normalizer_data", 185 - "icu_properties", 186 - "icu_provider", 187 - "smallvec", 188 - "zerovec", 189 - ] 190 - 191 - [[package]] 192 - name = "icu_normalizer_data" 193 - version = "2.1.1" 194 - source = "registry+https://github.com/rust-lang/crates.io-index" 195 - checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 196 - 197 - [[package]] 198 - name = "icu_properties" 199 - version = "2.1.2" 200 - source = "registry+https://github.com/rust-lang/crates.io-index" 201 - checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" 202 - dependencies = [ 203 - "icu_collections", 204 - "icu_locale_core", 205 - "icu_properties_data", 206 - "icu_provider", 207 - "zerotrie", 208 - "zerovec", 209 - ] 210 - 211 - [[package]] 212 - name = "icu_properties_data" 213 - version = "2.1.2" 214 - source = "registry+https://github.com/rust-lang/crates.io-index" 215 - checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" 216 - 217 - [[package]] 218 - name = "icu_provider" 219 - version = "2.1.1" 220 - source = "registry+https://github.com/rust-lang/crates.io-index" 221 - checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 222 - dependencies = [ 223 - "displaydoc", 224 - "icu_locale_core", 225 - "writeable", 226 - "yoke", 227 - "zerofrom", 228 - "zerotrie", 229 - "zerovec", 230 - ] 231 - 232 - [[package]] 233 - name = "idna" 234 - version = "1.1.0" 235 - source = "registry+https://github.com/rust-lang/crates.io-index" 236 - checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 237 - dependencies = [ 238 - "idna_adapter", 239 - "smallvec", 240 - "utf8_iter", 241 - ] 242 - 243 - [[package]] 244 - name = "idna_adapter" 245 - version = "1.2.1" 246 - source = "registry+https://github.com/rust-lang/crates.io-index" 247 - checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 248 - dependencies = [ 249 - "icu_normalizer", 250 - "icu_properties", 251 - ] 252 - 253 - [[package]] 254 - name = "itoa" 255 - version = "1.0.17" 256 - source = "registry+https://github.com/rust-lang/crates.io-index" 257 - checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 258 - 259 - [[package]] 260 - name = "js-sys" 261 - version = "0.3.90" 262 - source = "registry+https://github.com/rust-lang/crates.io-index" 263 - checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" 264 - dependencies = [ 265 - "once_cell", 266 - "wasm-bindgen", 267 - ] 268 - 269 - [[package]] 270 - name = "litemap" 271 - version = "0.8.1" 272 - source = "registry+https://github.com/rust-lang/crates.io-index" 273 - checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 274 - 275 - [[package]] 276 - name = "matchit" 277 - version = "0.7.3" 278 - source = "registry+https://github.com/rust-lang/crates.io-index" 279 - checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 280 - 281 - [[package]] 282 - name = "memchr" 283 - version = "2.8.0" 284 - source = "registry+https://github.com/rust-lang/crates.io-index" 285 - checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 286 - 287 - [[package]] 288 - name = "num-traits" 289 - version = "0.2.19" 290 - source = "registry+https://github.com/rust-lang/crates.io-index" 291 - checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 292 - dependencies = [ 293 - "autocfg", 294 - ] 295 - 296 - [[package]] 297 - name = "once_cell" 298 - version = "1.21.3" 299 - source = "registry+https://github.com/rust-lang/crates.io-index" 300 - checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 301 - 302 - [[package]] 303 - name = "percent-encoding" 304 - version = "2.3.2" 305 - source = "registry+https://github.com/rust-lang/crates.io-index" 306 - checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 307 - 308 - [[package]] 309 - name = "pin-project" 310 - version = "1.1.10" 311 - source = "registry+https://github.com/rust-lang/crates.io-index" 312 - checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" 313 - dependencies = [ 314 - "pin-project-internal", 315 - ] 316 - 317 - [[package]] 318 - name = "pin-project-internal" 319 - version = "1.1.10" 320 - source = "registry+https://github.com/rust-lang/crates.io-index" 321 - checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" 322 - dependencies = [ 323 - "proc-macro2", 324 - "quote", 325 - "syn", 326 - ] 327 - 328 - [[package]] 329 - name = "pin-project-lite" 330 - version = "0.2.16" 331 - source = "registry+https://github.com/rust-lang/crates.io-index" 332 - checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 333 - 334 - [[package]] 335 - name = "potential_utf" 336 - version = "0.1.4" 337 - source = "registry+https://github.com/rust-lang/crates.io-index" 338 - checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 339 - dependencies = [ 340 - "zerovec", 341 - ] 342 - 343 - [[package]] 344 - name = "proc-macro2" 345 - version = "1.0.106" 346 - source = "registry+https://github.com/rust-lang/crates.io-index" 347 - checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 348 - dependencies = [ 349 - "unicode-ident", 350 - ] 351 - 352 - [[package]] 353 - name = "quote" 354 - version = "1.0.44" 355 - source = "registry+https://github.com/rust-lang/crates.io-index" 356 - checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" 357 - dependencies = [ 358 - "proc-macro2", 359 - ] 360 - 361 - [[package]] 362 - name = "rustversion" 363 - version = "1.0.22" 364 - source = "registry+https://github.com/rust-lang/crates.io-index" 365 - checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 366 - 367 - [[package]] 368 - name = "ryu" 369 - version = "1.0.23" 370 - source = "registry+https://github.com/rust-lang/crates.io-index" 371 - checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 372 - 373 - [[package]] 374 - name = "serde" 375 - version = "1.0.228" 376 - source = "registry+https://github.com/rust-lang/crates.io-index" 377 - checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 378 - dependencies = [ 379 - "serde_core", 380 - "serde_derive", 381 - ] 382 - 383 - [[package]] 384 - name = "serde-wasm-bindgen" 385 - version = "0.6.5" 386 - source = "registry+https://github.com/rust-lang/crates.io-index" 387 - checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" 388 - dependencies = [ 389 - "js-sys", 390 - "serde", 391 - "wasm-bindgen", 392 - ] 393 - 394 - [[package]] 395 - name = "serde_core" 396 - version = "1.0.228" 397 - source = "registry+https://github.com/rust-lang/crates.io-index" 398 - checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 399 - dependencies = [ 400 - "serde_derive", 401 - ] 402 - 403 - [[package]] 404 - name = "serde_derive" 405 - version = "1.0.228" 406 - source = "registry+https://github.com/rust-lang/crates.io-index" 407 - checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 408 - dependencies = [ 409 - "proc-macro2", 410 - "quote", 411 - "syn", 412 - ] 413 - 414 - [[package]] 415 - name = "serde_json" 416 - version = "1.0.149" 417 - source = "registry+https://github.com/rust-lang/crates.io-index" 418 - checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 419 - dependencies = [ 420 - "itoa", 421 - "memchr", 422 - "serde", 423 - "serde_core", 424 - "zmij", 425 - ] 426 - 427 - [[package]] 428 - name = "serde_urlencoded" 429 - version = "0.7.1" 430 - source = "registry+https://github.com/rust-lang/crates.io-index" 431 - checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 432 - dependencies = [ 433 - "form_urlencoded", 434 - "itoa", 435 - "ryu", 436 - "serde", 437 - ] 438 - 439 - [[package]] 440 - name = "sites" 441 - version = "0.1.0" 442 - dependencies = [ 443 - "serde", 444 - "serde_json", 445 - "worker", 446 - ] 447 - 448 - [[package]] 449 - name = "slab" 450 - version = "0.4.12" 451 - source = "registry+https://github.com/rust-lang/crates.io-index" 452 - checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 453 - 454 - [[package]] 455 - name = "smallvec" 456 - version = "1.15.1" 457 - source = "registry+https://github.com/rust-lang/crates.io-index" 458 - checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 459 - 460 - [[package]] 461 - name = "stable_deref_trait" 462 - version = "1.2.1" 463 - source = "registry+https://github.com/rust-lang/crates.io-index" 464 - checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 465 - 466 - [[package]] 467 - name = "syn" 468 - version = "2.0.117" 469 - source = "registry+https://github.com/rust-lang/crates.io-index" 470 - checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 471 - dependencies = [ 472 - "proc-macro2", 473 - "quote", 474 - "unicode-ident", 475 - ] 476 - 477 - [[package]] 478 - name = "synstructure" 479 - version = "0.13.2" 480 - source = "registry+https://github.com/rust-lang/crates.io-index" 481 - checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 482 - dependencies = [ 483 - "proc-macro2", 484 - "quote", 485 - "syn", 486 - ] 487 - 488 - [[package]] 489 - name = "tinystr" 490 - version = "0.8.2" 491 - source = "registry+https://github.com/rust-lang/crates.io-index" 492 - checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 493 - dependencies = [ 494 - "displaydoc", 495 - "zerovec", 496 - ] 497 - 498 - [[package]] 499 - name = "tokio" 500 - version = "1.49.0" 501 - source = "registry+https://github.com/rust-lang/crates.io-index" 502 - checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" 503 - dependencies = [ 504 - "pin-project-lite", 505 - ] 506 - 507 - [[package]] 508 - name = "unicode-ident" 509 - version = "1.0.24" 510 - source = "registry+https://github.com/rust-lang/crates.io-index" 511 - checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 512 - 513 - [[package]] 514 - name = "url" 515 - version = "2.5.8" 516 - source = "registry+https://github.com/rust-lang/crates.io-index" 517 - checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 518 - dependencies = [ 519 - "form_urlencoded", 520 - "idna", 521 - "percent-encoding", 522 - "serde", 523 - ] 524 - 525 - [[package]] 526 - name = "utf8_iter" 527 - version = "1.0.4" 528 - source = "registry+https://github.com/rust-lang/crates.io-index" 529 - checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 530 - 531 - [[package]] 532 - name = "wasm-bindgen" 533 - version = "0.2.113" 534 - source = "registry+https://github.com/rust-lang/crates.io-index" 535 - checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" 536 - dependencies = [ 537 - "cfg-if", 538 - "once_cell", 539 - "rustversion", 540 - "wasm-bindgen-macro", 541 - "wasm-bindgen-shared", 542 - ] 543 - 544 - [[package]] 545 - name = "wasm-bindgen-futures" 546 - version = "0.4.63" 547 - source = "registry+https://github.com/rust-lang/crates.io-index" 548 - checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" 549 - dependencies = [ 550 - "cfg-if", 551 - "futures-util", 552 - "js-sys", 553 - "once_cell", 554 - "wasm-bindgen", 555 - "web-sys", 556 - ] 557 - 558 - [[package]] 559 - name = "wasm-bindgen-macro" 560 - version = "0.2.113" 561 - source = "registry+https://github.com/rust-lang/crates.io-index" 562 - checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" 563 - dependencies = [ 564 - "quote", 565 - "wasm-bindgen-macro-support", 566 - ] 567 - 568 - [[package]] 569 - name = "wasm-bindgen-macro-support" 570 - version = "0.2.113" 571 - source = "registry+https://github.com/rust-lang/crates.io-index" 572 - checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" 573 - dependencies = [ 574 - "bumpalo", 575 - "proc-macro2", 576 - "quote", 577 - "syn", 578 - "wasm-bindgen-shared", 579 - ] 580 - 581 - [[package]] 582 - name = "wasm-bindgen-shared" 583 - version = "0.2.113" 584 - source = "registry+https://github.com/rust-lang/crates.io-index" 585 - checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" 586 - dependencies = [ 587 - "unicode-ident", 588 - ] 589 - 590 - [[package]] 591 - name = "wasm-streams" 592 - version = "0.5.0" 593 - source = "registry+https://github.com/rust-lang/crates.io-index" 594 - checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" 595 - dependencies = [ 596 - "futures-util", 597 - "js-sys", 598 - "wasm-bindgen", 599 - "wasm-bindgen-futures", 600 - "web-sys", 601 - ] 602 - 603 - [[package]] 604 - name = "web-sys" 605 - version = "0.3.90" 606 - source = "registry+https://github.com/rust-lang/crates.io-index" 607 - checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" 608 - dependencies = [ 609 - "js-sys", 610 - "wasm-bindgen", 611 - ] 612 - 613 - [[package]] 614 - name = "worker" 615 - version = "0.7.5" 616 - source = "registry+https://github.com/rust-lang/crates.io-index" 617 - checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" 618 - dependencies = [ 619 - "async-trait", 620 - "bytes", 621 - "chrono", 622 - "futures-channel", 623 - "futures-util", 624 - "http", 625 - "http-body", 626 - "js-sys", 627 - "matchit", 628 - "pin-project", 629 - "serde", 630 - "serde-wasm-bindgen", 631 - "serde_json", 632 - "serde_urlencoded", 633 - "tokio", 634 - "url", 635 - "wasm-bindgen", 636 - "wasm-bindgen-futures", 637 - "wasm-streams", 638 - "web-sys", 639 - "worker-macros", 640 - "worker-sys", 641 - ] 642 - 643 - [[package]] 644 - name = "worker-macros" 645 - version = "0.7.5" 646 - source = "registry+https://github.com/rust-lang/crates.io-index" 647 - checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" 648 - dependencies = [ 649 - "async-trait", 650 - "proc-macro2", 651 - "quote", 652 - "syn", 653 - "wasm-bindgen", 654 - "wasm-bindgen-futures", 655 - "wasm-bindgen-macro-support", 656 - "worker-sys", 657 - ] 658 - 659 - [[package]] 660 - name = "worker-sys" 661 - version = "0.7.5" 662 - source = "registry+https://github.com/rust-lang/crates.io-index" 663 - checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" 664 - dependencies = [ 665 - "cfg-if", 666 - "js-sys", 667 - "wasm-bindgen", 668 - "web-sys", 669 - ] 670 - 671 - [[package]] 672 - name = "writeable" 673 - version = "0.6.2" 674 - source = "registry+https://github.com/rust-lang/crates.io-index" 675 - checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 676 - 677 - [[package]] 678 - name = "yoke" 679 - version = "0.8.1" 680 - source = "registry+https://github.com/rust-lang/crates.io-index" 681 - checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 682 - dependencies = [ 683 - "stable_deref_trait", 684 - "yoke-derive", 685 - "zerofrom", 686 - ] 687 - 688 - [[package]] 689 - name = "yoke-derive" 690 - version = "0.8.1" 691 - source = "registry+https://github.com/rust-lang/crates.io-index" 692 - checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 693 - dependencies = [ 694 - "proc-macro2", 695 - "quote", 696 - "syn", 697 - "synstructure", 698 - ] 699 - 700 - [[package]] 701 - name = "zerofrom" 702 - version = "0.1.6" 703 - source = "registry+https://github.com/rust-lang/crates.io-index" 704 - checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 705 - dependencies = [ 706 - "zerofrom-derive", 707 - ] 708 - 709 - [[package]] 710 - name = "zerofrom-derive" 711 - version = "0.1.6" 712 - source = "registry+https://github.com/rust-lang/crates.io-index" 713 - checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 714 - dependencies = [ 715 - "proc-macro2", 716 - "quote", 717 - "syn", 718 - "synstructure", 719 - ] 720 - 721 - [[package]] 722 - name = "zerotrie" 723 - version = "0.2.3" 724 - source = "registry+https://github.com/rust-lang/crates.io-index" 725 - checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 726 - dependencies = [ 727 - "displaydoc", 728 - "yoke", 729 - "zerofrom", 730 - ] 731 - 732 - [[package]] 733 - name = "zerovec" 734 - version = "0.11.5" 735 - source = "registry+https://github.com/rust-lang/crates.io-index" 736 - checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 737 - dependencies = [ 738 - "yoke", 739 - "zerofrom", 740 - "zerovec-derive", 741 - ] 742 - 743 - [[package]] 744 - name = "zerovec-derive" 745 - version = "0.11.2" 746 - source = "registry+https://github.com/rust-lang/crates.io-index" 747 - checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 748 - dependencies = [ 749 - "proc-macro2", 750 - "quote", 751 - "syn", 752 - ] 753 - 754 - [[package]] 755 - name = "zmij" 756 - version = "1.0.21" 757 - source = "registry+https://github.com/rust-lang/crates.io-index" 758 - checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
-18
sites/Cargo.toml
··· 1 - [package] 2 - name = "sites" 3 - version = "0.1.0" 4 - edition = "2024" 5 - authors = ["Anirudh Oppiliappan <anirudh@tangled.org>"] 6 - 7 - [lib] 8 - crate-type = ["cdylib"] 9 - path = "src/lib.rs" 10 - 11 - [dependencies] 12 - worker = "0.7.0" 13 - serde = { version = "1", features = ["derive"] } 14 - serde_json = "1" 15 - 16 - [profile.release] 17 - opt-level = "s" 18 - lto = true
-153
sites/src/lib.rs
··· 1 - use std::collections::HashMap; 2 - 3 - use serde::Deserialize; 4 - use worker::*; 5 - 6 - /// The JSON value stored in Workers KV, keyed by domain. 7 - /// 8 - /// Example KV entry: 9 - /// key: "foo.example.com" 10 - /// value: {"did": "did:plc:...", "repos": {"my_repo": true, "other_repo": false}} 11 - /// 12 - /// The boolean on each repo indicates whether it is the index site for the 13 - /// domain (true) or a sub-path site (false). At most one repo may be true. 14 - #[derive(Deserialize)] 15 - struct DomainMapping { 16 - did: String, 17 - /// repo name → is_index 18 - repos: HashMap<String, bool>, 19 - } 20 - 21 - impl DomainMapping { 22 - /// Returns the repo that is marked as the index site, if any. 23 - fn index_repo(&self) -> Option<&str> { 24 - self.repos 25 - .iter() 26 - .find_map(|(name, &is_index)| if is_index { Some(name.as_str()) } else { None }) 27 - } 28 - } 29 - 30 - /// Build the R2 object key for a given did/repo and intra-site path. 31 - /// `site_path` should start with a `/` or be empty. 32 - fn r2_key(did: &str, repo: &str, site_path: &str) -> String { 33 - let base = format!("{}/{}/", did, repo); 34 - if site_path.is_empty() || site_path == "/" { 35 - format!("{}index.html", base) 36 - } else { 37 - let trimmed = site_path.trim_start_matches('/'); 38 - if trimmed.is_empty() || trimmed.ends_with('/') { 39 - format!("{}{}index.html", base, trimmed) 40 - } else { 41 - format!("{}{}", base, trimmed) 42 - } 43 - } 44 - } 45 - 46 - /// Fetch an object from R2, falling back to appending /index.html if the 47 - /// key looks like a directory (no file extension in the last segment). 48 - async fn fetch_from_r2(bucket: &Bucket, key: &str) -> Result<Option<Object>> { 49 - if let Some(obj) = bucket.get(key).execute().await? { 50 - return Ok(Some(obj)); 51 - } 52 - 53 - let last_segment = key.rsplit('/').next().unwrap_or(key); 54 - if !last_segment.contains('.') { 55 - let index_key = format!("{}/index.html", key.trim_end_matches('/')); 56 - if let Some(obj) = bucket.get(&index_key).execute().await? { 57 - return Ok(Some(obj)); 58 - } 59 - } 60 - 61 - Ok(None) 62 - } 63 - 64 - /// Build a Response from an R2 Object, forwarding the content-type header. 65 - fn response_from_object(obj: Object) -> Result<Response> { 66 - let content_type = obj 67 - .http_metadata() 68 - .content_type 69 - .unwrap_or_else(|| "application/octet-stream".to_string()); 70 - 71 - let body = obj.body().ok_or_else(|| Error::RustError("empty R2 body".into()))?; 72 - let mut resp = Response::from_body(body.response_body()?)?; 73 - resp.headers_mut().set("Content-Type", &content_type)?; 74 - resp.headers_mut().set("Cache-Control", "public, max-age=60")?; 75 - Ok(resp) 76 - } 77 - 78 - fn is_excluded(path: &str) -> bool { 79 - let excluded = ["/.well-known/atproto-did"]; 80 - excluded.iter().any(|&prefix| path.starts_with(prefix)) 81 - } 82 - 83 - #[event(fetch)] 84 - async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> { 85 - let kv = env.kv("SITES")?; 86 - let bucket = env.bucket("SITES_BUCKET")?; 87 - 88 - // Extract host, stripping any port. 89 - let host = req.headers().get("host")?.unwrap_or_default(); 90 - let host = host.split(':').next().unwrap_or("").to_string(); 91 - 92 - if host.is_empty() { 93 - return Response::error("Bad Request: missing host", 400); 94 - } 95 - 96 - let url = req.url()?; 97 - let path = url.path(); 98 - 99 - if is_excluded(path) { 100 - return Fetch::Request(req).send().await; 101 - } 102 - 103 - // Single KV lookup for the whole domain. 104 - let mapping = match kv.get(&host).text().await? { 105 - Some(raw) => match serde_json::from_str::<DomainMapping>(&raw) { 106 - Ok(m) => m, 107 - Err(_) => return Response::error("Internal Error: bad mapping", 500), 108 - }, 109 - None => return Response::error("site not found!", 404), 110 - }; 111 - 112 - let path = url.path(); // always starts with "/" 113 - 114 - // First path segment, e.g. "my_repo" from "/my_repo/page.html" 115 - let first_segment = path 116 - .trim_start_matches('/') 117 - .split('/') 118 - .next() 119 - .unwrap_or("") 120 - .to_string(); 121 - 122 - // 1. sub-path site 123 - // If the first path segment matches a non-index repo, serve from it. 124 - if !first_segment.is_empty() { 125 - if let Some(&is_index) = mapping.repos.get(&first_segment) { 126 - if !is_index { 127 - // Strip the leading "/{first_segment}" to get the intra-site path. 128 - let site_path = path 129 - .trim_start_matches('/') 130 - .trim_start_matches(&first_segment) 131 - .to_string(); 132 - 133 - let key = r2_key(&mapping.did, &first_segment, &site_path); 134 - return match fetch_from_r2(&bucket, &key).await? { 135 - Some(obj) => response_from_object(obj), 136 - None => Response::error("Not Found", 404), 137 - }; 138 - } 139 - } 140 - } 141 - 142 - // 2. index site 143 - // Fall back to the repo marked as the index site, serving the full path. 144 - if let Some(index_repo) = mapping.index_repo() { 145 - let key = r2_key(&mapping.did, index_repo, path); 146 - return match fetch_from_r2(&bucket, &key).await? { 147 - Some(obj) => response_from_object(obj), 148 - None => Response::error("Not Found", 404), 149 - }; 150 - } 151 - 152 - Response::error("Not Found", 404) 153 - }
-27
sites/wrangler.toml
··· 1 - name = "sites" 2 - main = "build/index.js" 3 - compatibility_date = "2026-02-27" 4 - 5 - [build] 6 - command = "worker-build --release" 7 - 8 - [[routes]] 9 - pattern = "*.tngl.io/*" 10 - zone_name = "tngl.io" 11 - 12 - [[routes]] 13 - pattern = "*.tngl.sh/*" 14 - zone_name = "tngl.sh" 15 - 16 - [[kv_namespaces]] 17 - binding = "SITES" 18 - id = "252122843e3a4b6396e90f15d204659c" 19 - 20 - [[r2_buckets]] 21 - binding = "SITES_BUCKET" 22 - bucket_name = "tangled-sites" 23 - 24 - [observability] 25 - [observability.logs] 26 - enabled = true 27 - invocation_logs = true
+70 -83
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: [ 6 - "./appview/pages/templates/**/*.html", 7 - "./appview/pages/chroma.go", 8 - "./docs/*.html", 9 - "./blog/templates/**/*.html", 10 - "./blog/posts/**/*.md", 11 - ], 12 - darkMode: "media", 13 - theme: { 14 - container: { 15 - padding: "2rem", 16 - center: true, 17 - screens: { 18 - sm: "500px", 19 - md: "600px", 20 - lg: "800px", 21 - xl: "1000px", 22 - "2xl": "1200px", 23 - }, 24 - }, 25 - extend: { 26 - fontFamily: { 27 - sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 28 - mono: [ 29 - "IBMPlexMono", 30 - "ui-monospace", 31 - "SFMono-Regular", 32 - "Menlo", 33 - "Monaco", 34 - "Consolas", 35 - "Liberation Mono", 36 - "Courier New", 37 - "monospace", 38 - ], 39 - }, 40 - typography: { 41 - DEFAULT: { 42 - css: { 43 - maxWidth: "none", 44 - pre: { 45 - "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": 46 - {}, 47 - }, 48 - code: { 49 - "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": 50 - {}, 51 - }, 52 - "code::before": { 53 - content: '""', 54 - }, 55 - "code::after": { 56 - content: '""', 57 - }, 58 - blockquote: { 59 - quotes: "none", 60 - }, 61 - "h1, h2, h3, h4": { 62 - "@apply mt-4 mb-2": {}, 63 - }, 64 - h1: { 65 - "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": 66 - {}, 67 - }, 68 - h2: { 69 - "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": 70 - {}, 71 - }, 72 - h3: { 73 - "@apply mt-2": {}, 74 - }, 75 - img: { 76 - "@apply rounded border border-gray-200 dark:border-gray-700": {}, 77 - }, 78 - }, 79 - }, 80 - }, 81 - gridTemplateColumns: { 82 - 14: "repeat(14, minmax(0, 1fr))", 83 - 28: "repeat(28, minmax(0, 1fr))", 84 - }, 85 - }, 86 - }, 87 - plugins: [require("@tailwindcss/typography")], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 + darkMode: "media", 7 + theme: { 8 + container: { 9 + padding: "2rem", 10 + center: true, 11 + screens: { 12 + sm: "500px", 13 + md: "600px", 14 + lg: "800px", 15 + xl: "1000px", 16 + "2xl": "1200px", 17 + }, 18 + }, 19 + extend: { 20 + fontFamily: { 21 + sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 22 + mono: [ 23 + "IBMPlexMono", 24 + "ui-monospace", 25 + "SFMono-Regular", 26 + "Menlo", 27 + "Monaco", 28 + "Consolas", 29 + "Liberation Mono", 30 + "Courier New", 31 + "monospace", 32 + ], 33 + }, 34 + typography: { 35 + DEFAULT: { 36 + css: { 37 + maxWidth: "none", 38 + pre: { 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 40 + }, 41 + code: { 42 + "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {}, 43 + }, 44 + "code::before": { 45 + content: '""', 46 + }, 47 + "code::after": { 48 + content: '""', 49 + }, 50 + blockquote: { 51 + quotes: "none", 52 + }, 53 + 'h1, h2, h3, h4': { 54 + "@apply mt-4 mb-2": {} 55 + }, 56 + h1: { 57 + "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {} 58 + }, 59 + h2: { 60 + "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {} 61 + }, 62 + h3: { 63 + "@apply mt-2": {} 64 + }, 65 + }, 66 + }, 67 + }, 68 + gridTemplateColumns: { 69 + '14': 'repeat(14, minmax(0, 1fr))', 70 + '28': 'repeat(28, minmax(0, 1fr))', 71 + } 72 + }, 73 + }, 74 + plugins: [require("@tailwindcss/typography")], 88 75 };

History

5 rounds 20 comments
sign up or login to add to the discussion
1 commit
expand
appview/ogcard: split rendering into external worker service using satori and resvg-wasm
expand 5 comments

for some reason the amount of files changed here (36) is lower than the amount of files changed on my fork (42).. any ideas? 0927d254

it seems like this pr doesn't have the exact same content as my commit?

i definitely see 42 changed files with +2214/-1481 on here! (you may be viewing an earlier round perhaps?)!

commit message is perfect, i do have some minor code change comments, but i am happy to handle that outside of this PR.

ah yes you're right, i didn't realize i was still on an earlier round! great, lmk if you need anything else :)

appview/ogcard/bun.lock:1

Not really related to this PR, but somehow Bun's lockfile is not collapsed by default. Maybe a bug in go-enry?

yup it is a known issue in enry!

pull request successfully merged
23 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
appview/ogcard: improve fixtures.ts mock text
appview/ogcard: remove comment from logo.tsx
appview/ogcard: add knip dependency
appview/ogcard: commit bun.lock
appview/ogcard/components: add more font constants and use them
appview/ogcard/icons: remove unused exports
appview/ogcard: remove unused export from validation.ts
appview/ogcard: simplify wrangler.jsonc
appview/ogcard: fix language circles not being draw properly
appview/ogcard: show comments and reaction count only when larger than 0
appview/ogcard: switch repo card "updated at" to "created at"
appview/ogcard: replace remnant s with s
appview/ogcard: fix type issues
appview/ogcard: add TypeScript declaration for wasm module imports
appview/ogcard: pin satori to exact version to ensure wasm compatibility
appview/ogcard: add global_navigator compatibility flag for cf workers
appview/ogcard: load satori wasm module directly instead of from cdn
appview/ogcard: switch to wasm resvg and bundled fonts for cf Workers
appview/ogcard: extract satori/resvg runtime into separate package
appview/ogcard: format code with prettier
expand 0 comments
5 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
appview/ogcard: improve fixtures.ts mock text
appview/ogcard: remove comment from logo.tsx
expand 11 comments

appview/ogcard/src/index.tsx:85 I think that if we 500 out, we should not show the stack to the public, just as a security measure

appview/ogcard/wrangler.jsonc:6 is this subbed out at runtime?

appview/ogcard/.gitignore:2 bun huh? :P I think it would be nice to commit the lockfile in fact

appview/ogcard/src/components/shared/language-circles.tsx:9 Another unused constant, or maybe ctrl+f on this PR page doesn't catch the full picture

appview/ogcard/src/components/shared/footer-stats.tsx:19 would be cool if we did conditional rendering here for the 0 reactions case so that people don't get sad that their repo has 0 reactions advertised so blatantly heh

appview/repo/opengraph.go:83 we don't have an updatedAt on git repos, do we...

appview/repo/opengraph.go:92 doesn't this just render the actual timestamp itself?

appview/ogcard/src/lib/render.ts:17 I'm not super familiar with cf workers but fetching inside a worker doesn't seem super nice. could we inline it? I also noticed that package.json has this at ^0.25.0 which would make a mismatch pretty quickly

my only comment: can we squash the to 1 (and rebase on latest master perhaps)?

would be ripe if you could update the new commit to adhere to the commit guidelines, it would also need DCO.

don't pay attention to #3... it was uh... nothing feel free to take a look at the latest squashed commit, let me know your thoughts!

3 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
expand 2 comments

appview/ogcard/src/components/shared/logo.tsx:1

nit: this has been customized already, yes? :P

good catch 😸

eti.tf submitted #0
3 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
expand 2 comments

could you rebase on latest master?

might need a rebase!